REST API

The REST routes, what each response carries, and the auth model

A versioned JSON API backs the installable app and any other client. The binary serves the REST API, the app, and the MCP endpoint on one listener in one process, so the per-session write lock covers every writer and the prune sweeper always runs. A request body is capped at 4 MiB. The screen design is in Human interface; the agent side is in MCP tools.

Routes

GET    /healthz
GET    /readyz
GET    /metrics
GET    /bootstrap/SKILL.md

GET    /api/v1/home
GET    /api/v1/feed
GET    /api/v1/projects
POST   /api/v1/projects
GET    /api/v1/projects/:id
PATCH  /api/v1/projects/:id
DELETE /api/v1/projects/:id
GET    /api/v1/projects/:id/stats
GET    /api/v1/projects/:id/feed
POST   /api/v1/projects/:id/feed/seen
POST   /api/v1/projects/:id/ideas
GET    /api/v1/projects/:id/artifacts
GET    /api/v1/projects/:id/kb/...      the routes in Knowledge base pages

GET    /api/v1/inbox?status=&project=&limit=&unread_only=
POST   /api/v1/inbox/:id/read
POST   /api/v1/inbox/:id/unread
POST   /api/v1/inbox/read-all
POST   /api/v1/questions/:id/answer
POST   /api/v1/approvals/:id/decision
GET    /api/v1/stream

GET    /api/v1/sessions?project=
GET    /api/v1/sessions/:id
GET    /api/v1/sessions/:id/brain?path=
GET    /api/v1/sessions/:id/brain/entry?path=
GET    /api/v1/sessions/:id/replay
POST   /api/v1/sessions/:id/end
POST   /api/v1/sessions/:id/reassign

GET    /api/v1/agents
POST   /api/v1/agents
POST   /api/v1/agents/:id/token
DELETE /api/v1/agents/:id/token
GET    /api/v1/agents/:id/grants
POST   /api/v1/agents/:id/grants
DELETE /api/v1/agents/:id/grants/:projectId
POST   /api/v1/enrol
GET    /api/v1/enrol/status?wait=30
POST   /api/v1/enrol/:id/approve
POST   /api/v1/enrol/:id/refuse

GET    /api/v1/artifacts
GET    /api/v1/artifacts/:id
GET    /api/v1/artifacts/:id/versions
GET    /api/v1/artifacts/:id/live
GET    /api/v1/artifacts/:id/raw
GET    /api/v1/artifacts/:id/viewer-pass
POST   /api/v1/artifacts/:id/share
DELETE /api/v1/artifacts/:id/share
GET    /api/v1/artifacts/:id/share
DELETE /api/v1/artifacts/:id
GET    /api/v1/artifacts/:id/comments
POST   /api/v1/artifacts/:id/comments
PATCH  /api/v1/artifacts/:id/comments/:commentId
DELETE /api/v1/artifacts/:id/comments/:commentId

GET    /api/v1/storage
DELETE /api/v1/storage/sessions/:id
DELETE /api/v1/storage/sessions
DELETE /api/v1/storage/projects/:id/sessions
POST   /api/v1/prune/undo/:token
POST   /api/v1/backups
GET    /api/v1/search?q=&scope=&project=&type=&limit=&mode=

GET    /artifacts/:id
GET    /artifacts/:id/frame
GET    /artifacts/:id/og.svg
GET    /s/:token
GET    /s/:token/frame
GET    /s/:token/og.svg

The artifact content route accepts ?version=N to read one snapshot, or ?live=1 for the version an agent is writing when its number is not known. The listing and content routes carry thread counts across every version (comments_count and comments_open, defaulting to 0); the versions route lists the immutable history oldest first; the live route is a liveness read the content route cannot express, returning {state, version, live_rev, actor, session_id} where state is live, idle or sealed; the raw route returns the stored bytes as text, or a JSON envelope with base64 ciphertext for a protected artifact. Deletion removes the artifact, its history, and its search entry, and records a deleted event. The public page serves ?version=N the same way.

Sharing and the viewer pass

Sharing is the operator's act. POST /api/v1/artifacts/:id/share creates a plain share link pinned to the active version and DELETE revokes it, both admin-gated; GET /api/v1/artifacts/:id/share reports the current link. The link is served unauthenticated at /s/:token, with /s/:token/frame for the sandboxed content and /s/:token/og.svg for its preview card; a revoked or unknown token is 404.

While a link is live, /artifacts/:id and /artifacts/:id/frame are 404 for every caller without a viewer pass, and 404 again once the link is revoked. The pass comes from GET /api/v1/artifacts/:id/viewer-pass, admin-gated: it is a keyed digest of the admin token, the artifact id and a sixty-second window, recomputed on every request rather than stored, so it expires by itself and reads one artifact's public page and nothing else. The app embeds that page in a frame, and a frame navigation carries no bearer token, so the app mints the pass and carries it in the frame address; that is the whole reason the pass exists. Agents have no MCP tool to create or revoke a link, so their artifact workflow is publish, version and protect, and the human shares.

Probes

The two probes differ on purpose. GET /healthz is liveness: the process is up. GET /readyz is readiness, and it answers 200 only when the store is usable: the store's schema version is read, is not newer than the most this binary supports, and matches the version the process opened; the data directory and hub.db still have the device and inode identity captured at open, so a removed or replaced store is caught; a cheap content read confirms the file is still a hub store; and the volume has free space above a safety margin. Any leg failing is a 503 problem, so an orchestrator takes the node out of rotation instead of holding it there while every call fails.

Auth

  • The control surface uses a config-set admin token. It is required when the bind is not loopback, since the surface rejects every request without one.
  • The MCP endpoint uses bearer tokens. A token is an identity in its own right, and grants are binary per project. The stdio transport is the local admin and needs no token.
  • Shared artifacts use a password and browser-side encryption, so the recipient needs nothing else.
  • The freshness stream at GET /api/v1/stream also accepts any active agent token, so an agent can wake on inbox or feed changes as well as the app.
  • The app asks for the admin token on the Connect screen, and only there, so a token is never kept without the hub having accepted it first. It keeps the token in local storage, so a script running in the hub origin could read it. The shell is served with a strict content security policy, every agent-controlled field is escaped, and agent-authored HTML and rendered markdown run only in a sandboxed frame without same-origin access, so there is no hub-origin script injection path today. This is accepted for a single-operator node; a browser cookie is not a clean fit for a static app that also reaches the API.

The REST API is the operator's control surface. The destructive and privileged verbs on it require the admin token: prune and undo, agent and token administration, grants, deleting a project, making a confidential project public, and taking an online backup (POST /api/v1/backups, which writes only under the configured backup_dir; see Back up and restore). Agents reach the hub over MCP for their own work, and may also create and list the projects they can see. The trust posture behind this split is stated once in the operating model.

GET /bootstrap/SKILL.md is a public bootstrap guide. The hub renders the caller's own origin into it from the forwarded or request host, so an agent that can already reach the hub can fetch the connection details and the tool list before it has a token, and behind a reverse proxy it receives the public address rather than the internal bind.

Errors

Errors are RFC 9457 problem details, whatever part of the request is refused: a body over the limit is a 413 with payload_too_large, a body without the JSON content type a 415, a bad query string or path segment a 400, and a method the path does not serve a 405 that still names the methods it does. The admin gate runs before a request's body is read, so a caller without the admin token gets 401 whatever body it sent (a query string that cannot be read is still a 400 first), and an agent token is not the admin token.

Artifact pages

The public artifact route serves a host shell around a sandboxed frame: the shell owns the back button, title, version line, version picker, and theme toggle on the design tokens, and the frame runs authored content with scripts allowed but no network, storage, or same-origin access. For a protected artifact the shell shows a password gate with a ciphertext fingerprint and an optional device-local remember; it decrypts in the browser. A public markdown artifact renders in the page with tables, callouts, and diagrams, and any raw HTML in its source is escaped. Every page carries link-preview tags with a built-in card. Storage acts on sessions only, and a session prune returns an undo token valid for a short window. Deleting a whole project is the destructive endpoint under projects.

What each response carries

Every number a surface shows comes from one of these fields. Nothing is derived from a proxy, and a number that cannot be measured is absent rather than zero.

Route Carries
GET /api/v1/home unread, waiting, waiting_items (the newest five of the items that wait, newest first, each shaped as an inbox entry with its project_display_name; waiting stays the size of the whole queue), node (host, mode, the same datum Storage carries), agents_active, last_event_at, recent, unseen (per project, the feed events above its cursor), each recent event and each unseen row with its project_display_name beside project_id, storage (used_bytes, capacity_bytes, free_bytes) and prunable (sessions, bytes), so Home makes one request
GET /api/v1/projects and GET /api/v1/projects/:id each project with its id, display_name, owner_agent, created_at and unseen_events
GET /api/v1/projects/:id/feed events, next_since, next_before, and last_seen, the newest event the human has seen, so a client draws the unread dot on an event whose id is above it
POST /api/v1/projects/:id/feed/seen project_id, the resulting last_seen, and advanced, false when the cursor did not move
POST /api/v1/projects/:id/ideas id, the event id of the idea; the idea is appended as a needs-action signal and surfaces in the inbox for triage
GET /api/v1/storage total_bytes (what the projects hold) and used_bytes (the whole data directory, hub store included), capacity_bytes and free_bytes for the volume, data_path, node (host, mode), by_kind (events, sessions, artifacts, knowledge), events_shared_bytes, prunable, and a row per project with its project_display_name, its events_bytes, session_bytes, artifact_bytes and kb_bytes, its prunable sessions and bytes, and its optional last_write timestamp. Every project has a row, one that holds nothing with zeros, so a project a prune has just emptied keeps its row
GET /api/v1/projects/:id/stats events, artifacts, sessions, kb_pages, agents_active, threads, agents_written, and disk_bytes for the project header, its tab labels, and the deletion manifest
GET /api/v1/projects/:id/kb/... the knowledge base pages, their history, backlinks, lint and derived numbers, route by route in Knowledge base pages
GET /api/v1/sessions?project= each session, its owner, handoff, lineage and brain_bytes
GET /api/v1/sessions/:id the same fields plus events, the count of feed events the session produced, and last_event, the newest of them as one line
GET /api/v1/sessions/:id/replay session_id and events, the brain's tool-call audit entries oldest first; admin token required, and an empty list when the brain is gone
GET /api/v1/sessions/:id/brain?path= entries[] with path, type (key, file or dir) and size_bytes, one directory level per request, with path echoed and truncated when the level held more
GET /api/v1/sessions/:id/brain/entry?path= one entry's path, type (key or file), size_bytes, content as text, and written_at (null when unrecorded). Refuses directory paths with 409 and non-UTF-8 bytes with 422
GET /api/v1/search count, the hits on this page before grouping, truncated when the limit cut the result, took_ms around the store call, backend (the recall backend that served the page), and groups[] each with its own count; every hit carries its project_display_name beside project_id, and by family: event_kind and actor on a feed hit, version and size_bytes (the current version's) on an artifact hit, session_name and session_status on a session brain hit. They are read after the ranked fetch, one query per family on the page, and never change the order. mode=semantic asks for semantic recall; when no embedding model is configured the hub falls back to the lexical index and reports fts-fallback
GET /api/v1/inbox items[], each with event_id, project_id and its project_display_name (null for an id no project row carries), kind, actor, summary, payload, status, created_at and updated_at; a decided approval also carries decision (decision, approved or declined; note, null when none was left; actor; event_id, the answer that records it; decided_at); a resolved question carries answer (body, what was written in reply; actor; event_id, the answer that records it; answered_at)
POST /api/v1/approvals/:id/decision takes decision (approve or decline), an optional note and an optional idempotency_key; answers with event_id, the answer appended to the approval's thread, whose payload holds body, decision and, when one was left, note
POST /api/v1/inbox/:id/read and .../unread event_id, the status the entry carries now, and changed, false when the entry was already there or carries no read state
POST /api/v1/inbox/read-all marked, how many entries moved

count is the hits the page carries, not how many documents match, and truncated says when the limit cut it, so a capped page is never printed as a total. A hit's snippet is at most 200 characters of plain text. For an artifact, a brain entry or a knowledge base page it is the opening of the indexed text. For a feed hit it is what the agent wrote: the event payload's body when that is a string, otherwise the event's summary, and never the payload's serialized JSON. The corpus row of an event still holds the whole payload, so a word anywhere in it finds the event; only what is shown changed, which is why a store written before the change needs no rebuild.

The volume's capacity and free space come from one statvfs on the data directory, and the free figure is what a writer that is not root can use. When the call fails, capacity_bytes and free_bytes are null and the rest of the response is still served, so a surface renders "unknown" rather than 0 of 0. The numbers that cost a syscall or a file stat are memoised for ten seconds behind a counter every write bumps; the counts are indexed and never cached.

How the storage rows add up

Sessions, artifacts and knowledge are files and blobs that each belong to one project, so the rows' session_bytes, artifact_bytes and kb_bytes sum to by_kind.sessions, by_kind.artifacts and by_kind.knowledge exactly, and all three to total_bytes. The hub store is one file every project shares, so by_kind.events is that file's size and a row's events_bytes is what can honestly be given to one project: the bytes of its events' summaries and payloads, the hub's own audit events about the project included, since they go when the project does. The rest of the file (the indexes, the search corpus, the inbox, the identity tables, free pages) is events_shared_bytes, and the rows' events_bytes plus events_shared_bytes is by_kind.events. The feed is weighed once, on the first report after a start; later reports read only the events appended since, and the two things that remove events, a committed prune and a project delete, start the weighing over, as does every ten minutes. A report still weighing when events are removed does not keep what it weighed, and a project that is gone has no row whatever weights were held for it.

Reading, decisions, and the feed cursor

A decision may carry a note saying why. It is trimmed, a blank one is no note, and one over 2000 characters is a 413 with payload_too_large that decides nothing. The note is stored on the decision's feed event as payload.note, beside the one-line payload.body that reads "Declined: ..." wherever an answer is shown, and it comes back on the approval's inbox entry as decision.note, for the human and for the agent that asked.

Read is explicit. The human marks one inbox entry read or unread, or marks every unread entry read, optionally within one project; nothing is read by scrolling past it. The read routes move an entry between unread and read and nothing else: an entry that waits on the human, or one already resolved, is answered with its unchanged status and changed false, so marking an approval read never takes it out of what waits on you. They are idempotent, and an event with no inbox entry is a 404. The listing's unread_only is the Inbox header's filter and is refused when it contradicts an explicit status.

A feed is not read that way. Each project carries one cursor, the newest event the human has seen, and every event above it is unseen. Opening a project feed is what moves it: a client reads the page and posts the newest id it showed. The cursor only ever moves forward, and an id that is not an event of that project, or names nothing at all, leaves it where it was and says so. A client that reads a filtered page must post the newest id of the whole feed it has seen, not of the filtered view, since the cursor covers the feed and not the filter. The count above each cursor rides on the project listing and on Home, so a tab row and a Home row draw the same dot without a request of their own, and it counts only projects the listing returns. Upgrading an existing hub seeds every cursor at that project's newest event, so nothing is lit by the upgrade itself.

Projects and pruning

A project is settable after it is created. PATCH changes its display name and its artifact password policy (off, optional or required, and optional for every project that has not said otherwise); a field the body does not name is left alone, and an unknown field is ignored as it is on the create route. The id is the slug every MCP call and every other table names, so it is read-only after creation and a body that carries one is refused. An agent's personal space is settable like any other project; only deleting it is refused, because that is the agent's lifecycle rather than a setting.

A batch prune takes every ended session of one project, or of every project, with the same soft delete, undo window and sweep as pruning one. It never touches an active session, a feed event, an artifact or a project knowledge base. It returns one undo token per session rather than a token of its own, so undo is the route it already was, once per token.

See also