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/streamalso 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
- MCP tools - the agent surface over the same store
- Knowledge base pages - the
kbroutes in full - Human interface - the app's screens and rules
- Operating model - the admin boundary