Fixes an inconsistency an agent hit: it could POST/PATCH/DELETE with its token
but GET (readback) required a session and 302-redirected to the browser login.
- GET /api/artifacts and GET /api/artifacts/{id} now accept a bearer token OR
a session, owner-scoped (a token reads only its owner's artifacts). Agents
can read back what they posted with the same token.
- Unauthenticated API requests return a JSON 403, never a browser-login
redirect (resolve_request_user helper; dropped @login_required on the JSON
read views).
- GET /api/skill and GET /api/capabilities are now public (no user data), so
an agent can fetch the contract + posting instructions before it has a key.
- Docs (api.md, CLAUDE.md) updated; tests for bearer readback, owner-scoping,
and the JSON-403-not-redirect behavior. Suite: 132 passing; verified live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
20 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>
Removes the artifact's database row and its files (primary file, markdown
sidecar, and any attached assets). Returns 204 with no body on success.
You can only delete artifacts your own token owns; others return 404, as
does a second delete of the same id.
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
-H "Authorization: Bearer <your-personal-token>"
Rendered And Raw Content
Rendered browser view:
GET /a/{id}
Raw/source view:
GET /a/{id}?raw=1
Raw currently exists for:
| Kind | Raw content |
|---|---|
markdown |
Preserved .md source. |
html_full |
Stored HTML file served as text. |
json |
Preserved original JSON text. |
csv |
Preserved original CSV text. |
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.