daybook/docs/api.md
peter 2b901f7844 v0.3.1: bearer-token reads; public skill + capabilities
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>
2026-09-04 14:50:48 +01:00

621 lines
20 KiB
Markdown

# 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:
```text
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:
```http
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:
```json
{
"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:
```http
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. `&lt;html&gt;` 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`:
```json
{"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:
```powershell
$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:
```json
{
"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:
```http
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 `csv` artifact, it renders as a
plain sortable/searchable table. If preserving that formatting matters
more than interactivity, post as `html_fragment` with a hand-built
`<table>` instead.
A corrupt or unreadable `.xlsx` file returns `400`.
PowerShell/curl example:
```powershell
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.
```http
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:
```json
{
"id": "ab12cd34ef56",
"url": "http://127.0.0.1:8001/assets/ab12cd34ef56",
"content_type": "image/png",
"size_bytes": 20481
}
```
Then reference the URL normally:
```markdown
![Chart](http://127.0.0.1:8001/assets/ab12cd34ef56)
```
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:
```http
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:
```powershell
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:
```http
GET /api/artifacts/{id}
```
Example:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/a1b2c3d4"
```
## Update Artifact Metadata
Endpoint:
```http
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:
```powershell
$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:
```http
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.
```bash
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:
```http
GET /a/{id}
```
Raw/source view:
```http
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.
```http
GET /s/{share_token}
```
- `share_token` is a high-entropy random string (unrelated to the artifact's
own `id`), 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_shared` and `share_url` (both in every artifact response — see
`GET /api/artifacts/{id}`) report current sharing status; `share_url` is
`null` when not shared.
## Capabilities
Endpoint:
```http
GET /api/capabilities
```
Example response:
```json
{
"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:
```bash
curl http://127.0.0.1:8001/api/capabilities
```
## Posting Skill
Endpoint:
```http
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"}`.
```bash
# 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`.
```http
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.
```bash
# 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 (`PATCH` edits metadata only,
never `content`).
- Spreadsheet conversion covers `.xlsx` → `csv` only; other Office formats
(`.docx`, `.pptx`, legacy `.xls`) are not converted.
- Manual browser upload/drag-and-drop is deferred.
- Pagination/cursors are not implemented yet.