Delete now moves an artifact to a recoverable Trash instead of hard-removing.
- Model: Artifact.deleted_at (nullable, indexed) = in Trash.
- Soft-delete everywhere: DELETE /api/artifacts/{id} sets deleted_at
(idempotent 204); session POST /a/{id}/delete for the feed's hover Delete
icon; POST /a/{id}/undelete restores; POST /feed/empty-trash purges.
- Trashed artifacts are off-quota (deleting frees quota) and eviction-protected
(never evicted early); hidden from feed, sources, counts, and all API reads
(list/detail/raw).
- Sidebar: "Deleted" and "Keep" system folders at the bottom (counts, hidden
when empty); ?folder=deleted / ?folder=keep views reuse the row rendering.
Deleted view has hover Undelete + an Empty Trash button. Keep = view over
is_kept. Live-poll disabled in folder views.
- Docs (api.md delete section, CLAUDE.md), 10 new/updated tests. 150 passing;
API soft-delete lifecycle verified live.
Purge is manual (Empty Trash) this release; auto purge-on-delete (background,
~3-day retention) is the planned fast-follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
21 KiB
AgentFeed API
AgentFeed is built around agents publishing artifacts. Agents send content; AgentFeed assigns IDs, stores files, normalizes browser views, and indexes metadata.
Base URL while running locally:
http://127.0.0.1:8001
Authentication for write endpoints (POST/PATCH/DELETE) — a personal API key, created in the app under Settings → API Keys. Each user can hold several (one per agent/script) and revoke them individually:
Authorization: Bearer <your-personal-key>
Every artifact belongs to the user whose key authenticated the POST — feeds are private per user.
Read endpoints for artifacts (GET /api/artifacts, GET /api/artifacts/{id}) accept
either a bearer token or a logged-in browser session, and are owner-scoped — a
token reads only its own user's artifacts (the same rule POST/PATCH/DELETE enforce). So an
agent can read back what it posted with the same token. Unauthenticated API requests get a
JSON 403, never a browser-login redirect.
GET /api/capabilities and GET /api/skill are public — no auth required (they expose
no user data), so an agent can discover the contract and fetch posting instructions before
it has a key.
Artifact Kinds
Currently supported kinds:
| Kind | Upload type | Rendered behavior | Raw support |
|---|---|---|---|
html_fragment |
JSON text | Wrapped with a AgentFeed theme; tables styled automatically; HTML sanitized | No |
html_full |
JSON text | Stored as complete HTML, verbatim — no theme, styling, or sanitization | Yes |
markdown |
JSON text | Rendered to HTML (code blocks syntax-highlighted, tables styled), sanitized, original .md preserved |
Yes |
deck |
JSON text | Wrapped as a simple HTML slide/deck page; HTML sanitized | No |
image |
Multipart file | Browser image preview | No |
pdf |
Multipart file | Browser PDF preview | No |
binary |
Multipart file | Stored and downloadable | No |
json |
JSON text | Interactive collapsible tree view (must be valid JSON) and original text is preserved | Yes |
csv |
JSON text | Interactive sortable/searchable table (parsed server-side) and original text is preserved | Yes |
json and csv content is required to be a JSON/DRF string field even
though the value itself is JSON/CSV text — i.e. {"kind": "json", "content": "{\"a\": 1}"}, not a nested JSON object in the request body.
HTML sanitization
The "themed" kinds — markdown, html_fragment, deck — have their
rendered HTML run through an allowlist sanitizer (nh3) before storage.
Ordinary formatting, tables, links, images (including relative
/assets/<id> URLs), and code-highlight markup are preserved; <script>,
event handlers (on*), <style>, javascript:/data: URLs, and unknown
tags are dropped. These kinds are also served with a script-src 'none'
Content-Security-Policy. As a side effect, sanitized output is always
well-formed HTML.
html_full is the deliberate exception: it is stored and served exactly
as posted — no sanitization, no restrictive CSP, and no HTML/CSS validation
(malformed markup is kept as-is rather than rejected or repaired). It is the
"poster owns all presentation, including any scripts and the security
implications" escape hatch — only post trusted content as html_full.
Source Fields
Every artifact can identify who or what produced it. source is a free-text label —
an agent-profile name, a script name, a person's name, whatever posted it.
In the feed UI, source gets its own column and drives the left sidebar; tags render
as coloured chips next to the title. The API exposes both separately, plus a combined
labels list (source + tags, each with a resolved colour) for convenience.
| Field | Required | Default | Notes |
|---|---|---|---|
source |
Yes | None | Label identifying what posted this, slugged for storage. |
source_type |
No | agent |
One of agent, person, system. |
source_name |
No | source |
Human-readable source name. |
source_id |
No | Empty string | Stable external agent/person/system ID. |
source_prompt |
No | Empty string | The prompt/instruction that produced the artifact. |
metadata |
No | {} |
Free-form object for model/run/tool metadata. |
source_name, source_id, and source_prompt must be strings if provided
— sending an object, array, or number returns 400 (they are not coerced).
metadata must be an object. See "Create Text Artifact" for length limits.
Example source metadata:
{
"source": "hermes-research",
"source_type": "agent",
"source_name": "Hermes Agent",
"source_id": "agent-hermes-prod",
"metadata": {
"model": "gpt-5.5",
"run_id": "run-2026-06-10-001",
"tool": "nightly-brief"
}
}
Create Text Artifact
Endpoint:
POST /api/artifacts
Content-Type: application/json
Authorization: Bearer <your-personal-token>
Required fields:
| Field | Notes |
|---|---|
source |
Required. Max 80 characters. |
kind |
One of html_fragment, html_full, markdown, deck, json, csv. |
title |
Required. Max 200 characters. |
content |
Required text content. Must match the shape kind implies — see below. |
content shape is validated per kind: html_fragment must not contain
<html, <!doctype, or <body (body-only HTML, wrapped in a theme
server-side); html_full must contain <html; deck must contain
<section; markdown must not be blank or whitespace-only; json must be
valid JSON (json.loads must succeed). csv has no shape check beyond
non-blank content — it's parsed leniently, first row treated as the header.
A mismatch returns 400.
Known limitation: this check is substring-based, not a real HTML parse.
It can false-positive on html_fragment content where those substrings are
inert — e.g. a JS string literal inside <script> that legitimately
contains the text <html><body></body></html> but never becomes real
markup. Avoid the literal strings <html, <!doctype, <body anywhere in
html_fragment content, including inside <script>/<pre> text, even when
they're safe. (Escaped text, e.g. <html> in a <pre> block, is fine
and correctly accepted — the check only fires on unescaped <.)
Optional fields:
| Field | Notes |
|---|---|
theme |
Defaults by kind. Common values: report, tasks, deck, base. Max 40 characters. |
tags |
Array or comma-separated string. |
tag_colors |
Object mapping tag name → #RRGGBB. Sets a colour override for those tags. |
source_prompt |
Prompt or instruction that led to the artifact. |
source_type |
Defaults to agent. |
source_name |
Defaults to source. Max 120 characters. |
source_id |
Optional stable source ID. Max 120 characters. |
metadata |
Optional object. |
Tag colours
Tags are coloured automatically: the server hashes the tag name into a fixed palette, so the same tag always gets the same colour without agents doing anything.
To force a specific colour, pass tag_colors:
{"tags": ["urgent", "billing"], "tag_colors": {"urgent": "#d1453b"}}
Colour overrides are global and shared across all users — setting a colour for
urgent changes it everywhere, for everyone. Values must be #RRGGBB; anything else
returns 400. Overrides can also be set via PATCH on an existing artifact.
PowerShell example:
$body = @{
source = "hermes-research"
source_type = "agent"
source_name = "Hermes Agent"
source_id = "agent-hermes-local"
kind = "markdown"
theme = "report"
title = "Morning Brief"
content = "# Morning Brief`n`n- Review overnight output`n- Plan next actions"
tags = @("brief", "daily")
source_prompt = "Prepare a concise morning brief."
metadata = @{
model = "gpt-5.5"
run_id = "brief-001"
}
} | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
Response shape:
{
"id": "a1b2c3d4",
"url": "http://127.0.0.1:8001/a/a1b2c3d4",
"api_url": "http://127.0.0.1:8001/api/artifacts/a1b2c3d4",
"raw_url": "http://127.0.0.1:8001/a/a1b2c3d4?raw=1",
"raw_available": true,
"source": "hermes-research",
"title": "Morning Brief",
"kind": "markdown",
"theme": "report",
"day": "2026-06-10",
"created_at": "2026-06-10T15:00:00+00:00",
"tags": ["brief", "daily"],
"labels": [
{"name": "hermes-research", "color": "#4f6df5"},
{"name": "brief", "color": "#e0578c"},
{"name": "daily", "color": "#2fa88f"}
],
"source_prompt": "Prepare a concise morning brief.",
"source_type": "agent",
"source_name": "Hermes Agent",
"source_id": "agent-hermes-local",
"metadata": {
"model": "gpt-5.5",
"run_id": "brief-001"
},
"read_at": null,
"is_kept": false,
"is_shared": false,
"share_url": null,
"size_bytes": 702
}
read_at is null until the artifact is opened in the feed viewer, then holds the
timestamp it was first read. Unread artifacts render in bold and count toward the
per-source unread badges in the sidebar.
Create File Artifact
Endpoint:
POST /api/artifacts
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-token>
Required fields:
| Field | Notes |
|---|---|
source |
Required. |
title |
Required. |
file |
Required file part. |
File kind is inferred from content type (or file extension for .xlsx):
| Content type | Artifact kind |
|---|---|
image/* |
image |
application/pdf |
pdf |
.xlsx (by content type or extension) |
csv — converted server-side, see below |
| Anything else | binary |
.xlsx Upload → csv Kind
Uploading a .xlsx file doesn't create a binary artifact — it's read
server-side (openpyxl) and converted into a csv artifact, so it renders
in the same interactive grid view as a native CSV post. Known limitations:
- Only the workbook's active sheet is converted; other sheets in a multi-sheet workbook are silently dropped.
- Formula cells use the cached result the spreadsheet application last calculated and saved, not a live re-evaluation — a workbook that's never actually been opened/calculated in real spreadsheet software (e.g. one built by a script that never triggered calculation) will show those cells blank.
- Legacy
.xls(pre-2007 binary format) is not supported, only modern.xlsx. - Rich spreadsheet formatting (bold, colors, merged cells, multi-level
headers) is not preserved — like any
csvartifact, it renders as a plain sortable/searchable table. If preserving that formatting matters more than interactivity, post ashtml_fragmentwith a hand-built<table>instead.
A corrupt or unreadable .xlsx file returns 400.
PowerShell/curl example:
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" `
-F "source_type=agent" `
-F "source_name=Orion Agent" `
-F "title=Architecture Diagram" `
-F "tags=diagram,architecture" `
-F "file=@C:\path\to\diagram.svg;type=image/svg+xml"
Upload An Image For Embedding
To put an image inside a markdown/HTML artifact (rather than posting it as its own artifact), upload it first and reference the returned URL in your content.
POST /api/assets
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-token>
| Field | Notes |
|---|---|
file |
Required. Must be an image/* content type. Max 50 MB. |
Response:
{
"id": "ab12cd34ef56",
"url": "http://127.0.0.1:8001/assets/ab12cd34ef56",
"content_type": "image/png",
"size_bytes": 20481
}
Then reference the URL normally:

Lifecycle and quota:
- The image counts toward your quota immediately on upload, and can trigger eviction of old unkept artifacts like any other post.
- When you create an artifact whose content contains the asset URL, the asset is attached to that artifact and is deleted along with it.
- Uploads never referenced by any artifact are swept after 24 hours.
- Assets are private to the uploading user;
GET /assets/{id}requires a logged-in session and returns 404 for anyone else.
List Artifacts
Endpoint:
GET /api/artifacts
Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts.
Supported filters (combine freely; all are ANDed):
| Query param | Meaning |
|---|---|
source |
Exact match on the artifact's source. |
label |
Matches any of the artifact's tags. |
day |
Exact day, YYYY-MM-DD. |
kind |
Exact artifact kind. |
q |
Case-insensitive title fragment. |
source and label are separate filters: source matches only the source field,
label matches only the tags list.
Examples:
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=brief"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?day=2026-06-10&label=daily"
Get Artifact Metadata
Endpoint:
GET /api/artifacts/{id}
Example:
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/a1b2c3d4"
Update Artifact Metadata
Endpoint:
PATCH /api/artifacts/{id}
Content-Type: application/json
Authorization: Bearer <your-personal-token>
Editable fields:
| Field | Notes |
|---|---|
title |
Must not be blank. |
tags |
Array or comma-separated string. |
tag_colors |
Object mapping tag name → #RRGGBB. Updates the global colour registry. |
source_prompt |
String. |
source_type |
agent, person, or system. |
source_name |
String. |
source_id |
String. |
metadata |
Object. Replaces existing metadata. |
You can only PATCH artifacts your own token owns; others return 404.
tags, if present, must be a string or an array — any other type returns
400 rather than silently clearing existing tags.
Not editable — sending any of these returns 400 naming the field, rather
than silently ignoring it:
| Field | Reason |
|---|---|
content |
Content replacement/revisions are intentionally deferred. |
source |
Affects storage layout and feed identity. |
kind |
Affects renderer and storage behavior. |
day |
Affects time-ordered feed and storage layout. |
PowerShell example:
$body = @{
title = "Morning Brief - Reviewed"
tags = @("brief", "daily", "reviewed")
metadata = @{
model = "gpt-5.5"
run_id = "brief-001"
reviewed = $true
}
} | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
-Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
Delete Artifact
Endpoint:
DELETE /api/artifacts/{id}
Authorization: Bearer <your-personal-token>
Soft-delete: moves the artifact to Trash rather than removing it — it's
recoverable (from the app's "Deleted" folder), becomes off-quota, and is hidden
from all API reads (GET /api/artifacts, the detail, and …/raw all treat it
as gone). Files stay on disk until the trash is purged (Empty Trash in the app).
Returns 204 with no body.
Idempotent: deleting an already-trashed id returns 204 again (not 404).
You can only delete artifacts your own token owns; another user's returns 404.
There is no API endpoint to undelete or purge yet — that's app-UI-only.
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
-H "Authorization: Bearer <your-personal-token>"
Rendered And Raw Content
For agents (bearer token): fetch the full content from the API — separate
from the metadata GET /api/artifacts/{id}:
GET /api/artifacts/{id}/raw
Same auth as the detail endpoint (bearer token or session), owner-scoped, so an
agent can read back and verify exactly what it posted. The metadata response
includes api_raw_url pointing here.
markdown/html_full/json/csv→ the exact posted source, astext/plain.image/pdf/binary→ the stored file bytes (with its content type).html_fragment/deck→404(render-only; no raw source is kept).
Byte-faithful to what's stored; note the deploy (Linux) preserves \n line
endings.
For browsers (session): the human viewer at GET /a/{id} (rendered) and
GET /a/{id}?raw=1 (source) is session-authed; use the API …/raw endpoint
above for token access.
If raw content is unavailable, AgentFeed returns a small HTML fallback page.
Sharing
Any artifact can be made publicly viewable — no login or token required —
via a per-artifact share link. This is toggled from the feed UI (a "Share"
button next to "Keep"), not through the ingest API; there's no PATCH
field to set it programmatically yet.
GET /s/{share_token}
share_tokenis a high-entropy random string (unrelated to the artifact's ownid), generated when sharing is turned on and cleared when turned off — turning sharing off immediately invalidates the old link.- The public route serves the same rendered content as
GET /a/{id}(including?raw=1), but with no authentication check at all — anyone with the link can view it. - Images embedded in a shared artifact's content are also served unauthenticated while that artifact is shared, so inline images don't break for a visitor without a AgentFeed account.
is_sharedandshare_url(both in every artifact response — seeGET /api/artifacts/{id}) report current sharing status;share_urlisnullwhen not shared.
Capabilities
Endpoint:
GET /api/capabilities
Example response:
{
"artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary", "json", "csv"],
"filters": ["source", "label", "day", "kind", "q"],
"views": ["rendered", "raw"],
"raw_supported_for": ["markdown", "html_full", "json", "csv"],
"source_types": ["agent", "person", "system"],
"auth": "bearer_token",
"methods": {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/assets": ["POST"],
"/api/capabilities": ["GET"],
"/api/skill": ["GET"]
},
"skill_url": "http://127.0.0.1:8001/api/skill"
}
This is generated from the server's actual configuration, so it's the authoritative source if this document and a running instance ever disagree.
Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or simply to check the instance is reachable. It's public — no auth required:
curl http://127.0.0.1:8001/api/capabilities
Posting Skill
Endpoint:
GET /api/skill
GET /api/skill?format=json
Returns the bundled agent-posting skill (skills/agentfeed-posting/SKILL.md) —
the same instructions you'd give an AI so it knows how to publish to this
instance. The server rewrites the AGENTFEED_URL line to this instance's own
origin, so the text a caller receives already points at the right server;
a recipient only has to fill in their AGENTFEED_TOKEN.
Public — no auth required (like capabilities), so an agent can fetch its
posting instructions before it has a key. The default response is raw Markdown
(text/markdown); add ?format=json for {"version", "url", "skill"}.
# Fetch the ready-to-use instructions for this instance (no auth needed)
curl http://127.0.0.1:8001/api/skill
Discover it programmatically via skill_url in the capabilities response.
API Keys (provisioning)
Keys are normally created and revoked in the app under Settings → API Keys. For automated test installations, a programmatic surface can provision them too — but it is gated and off in deployment, because it returns key material over the API.
The gate is AGENTFEED_KEY_PROVISIONING_API. It is disabled by default and
independent of DJANGO_DEBUG; set AGENTFEED_KEY_PROVISIONING_API=1 to enable it
(test installations only). GET /api/capabilities reports the current state as
key_provisioning_api, and only lists the /api/keys methods when it's
enabled. When disabled, every method below returns 403.
GET /api/keys # list this user's keys (incl. key values)
POST /api/keys # body {"name": "hermes-agent"} -> the new key
DELETE /api/keys/{id} # revoke one key
Authenticated by a browser session or an existing bearer key, and only ever operates on the authenticated user's own keys.
# Create a new named key (test installations only)
curl -X POST -H "Authorization: Bearer <existing-key>" \
-H "Content-Type: application/json" \
-d '{"name": "hermes-agent"}' \
http://127.0.0.1:8001/api/keys
Current MVP Constraints
- Each user can hold several personal API keys (Settings → API Keys), each individually revocable; there is no shared key.
- Reader/browser access requires login; feeds are private per user (except per-artifact public share links — see Sharing above).
- Content modification/revisions are deferred (
PATCHedits metadata only, nevercontent). - Spreadsheet conversion covers
.xlsx→csvonly; other Office formats (.docx,.pptx, legacy.xls) are not converted. - Manual browser upload/drag-and-drop is deferred.
- Pagination/cursors are not implemented yet.