Agent surface
The MCP tools agents call, and the session bootstrap convention.
Agents reach the hub over the Model Context Protocol: streamable HTTP on the
hub's own listener at /mcp, with a bearer token that resolves to one agent
identity. One MCP server exposes the tools below, and every tool ships.
sequenceDiagram participant A as Agent participant H as Hub participant P as Human A->>H: whoami A->>H: feed_read(project_id) A->>H: session_start(project_id, session_name) H-->>A: session_id, recovery_path, handoff A->>H: brain_get(recovery_path) A->>H: question_post(project_id, subject) H->>P: inbox item, action P-->>H: answer_post(question_id, body) A->>H: inbox_wait H-->>A: the answer
A harness that speaks only stdio runs agent-hub mcp as a proxy to that
endpoint: one connection for the life of the process, every request forwarded,
so the tools and the errors are the hub's and a tool the hub gains needs no
new client. Because the hub tracks the active session per connection, a
session started through a proxy stays active for the rest of that process.
With no hub configured the same command still serves the local data directory
standalone, as the human admin, and says so on startup; that mode opens the
data directory itself and so cannot run beside a hub on it. See
the hub client.
Hooks are shell commands with no MCP client, so the binary also makes one-shot
calls: agent-hub call <tool> [json] prints the tool's JSON result on stdout
and exits 0, or prints the hub's error object on stderr and exits non-zero
(1 a tool error, including a denied project or a missing resource, 2 usage,
69 unreachable, 77 the token itself refused, 78 unconfigured).
agent-hub tools lists the hub's tools. A call is its own connection, so it
holds no session: it is for stateless reads and writes that name their target,
and session-bound work goes through the proxy. A one-shot call still reaches a
session brain when it names the session explicitly, so
agent-hub call session_start '{...}' followed by
agent-hub call brain_get '{"path":"/fs/RECOVERY.md","store":"session","session":{...}}'
reads a session's recovery document from a hook. There is no session or
brain shorthand: the tool name and its JSON are the whole interface.
An agent arriving without credentials enrols via agent-hub enrol, or by calling
POST /api/v1/enrol with a single-line explanation (under 200 characters) and
long-polling GET /api/v1/enrol/status?wait=30. The operator approves or
refuses the request from the inbox: deciding an enrol_request admits or refuses
the enrolling agent in the same transaction, and the subject is read from the
approval event (its actor must be a pending agent and the event must sit in that
agent's own personal project), never from the payload, so an active agent cannot
forge an admission. Upon shared approval, the client records the issued token and
the hub URL in config.toml at file mode 0600, so its next command needs no
environment. Pending tokens are refused by all ordinary routes with the exact
same unauthenticated response as unrecognised tokens.
An enrolment is rate-limited by its socket peer. A caller cannot set that
identity: a body field is ignored, and forwarded headers are honoured only when
the peer address is listed in trust_proxy, where the last hop the proxy
appended is the client. A hub holding enrol_pending_max pending requests
refuses more, and a request older than enrol_pending_ttl_secs is treated as
abandoned and removed along with its request event and its search row. Over the
optional embedded tailnet the peer is not available, so those requests share one
identity.
The agent guide
The hub serves its bootstrap guide (skills/agent-hub/bootstrap.md) over MCP as
a resource, agenthub://skill, as well as at GET /bootstrap/SKILL.md, and the
operating guide as the agent-hub skill. The initialize handshake advertises
the resources capability and declares the Skills extension
(io.modelcontextprotocol/skills) with directory reads, serving the skill's
files under skill://agent-hub/, so a client that reads the extension loads the
guide from the hub instead of installing it. whoami returns the bootstrap's
URL, and agent-hub tools prints each tool's inputSchema, so an agent wired
only to MCP can discover both the tools and the conventions without a human
handing it the document.
Tools
| Tool | Purpose |
|---|---|
session_start |
Register or resume the caller's own session by project and session name; the agent is the authenticated identity. Idempotent on the name, so a resume reuses the same brain. Returns the handoff note the previous owner left. With from, it picks up another agent's session. |
session_end |
Mark a session ended, with an optional handoff note. Only its owner, or the human admin, may end it. Active leases clear and brain mutations under lock are refused. The brain is retained until the human prunes it. |
session_list |
List sessions with their owner, status, handoff note and lineage, confined to the projects the caller may read. |
feed_read |
Read a project feed, optionally filtered by kind or session. A stateful read: with no since it polls forward from the caller's own durable server-side cursor for the project and advances that cursor to the returned next_since, so a restarted agent resumes where it stopped; an explicit since is honoured and also advances the stored cursor. With since and no before, the page is oldest first, continuing forward from the cursor; otherwise it is newest first. |
signal_append |
Append an event to a project feed. An approval may carry a deadline (see Deadlines). A write past the project's event ceiling (HUB_EVENTS_PER_PROJECT) is refused with the cap named; artifact writes and the knowledge base's lifecycle signal are bounded by the same ceiling, while session lifecycle and audit records are exempt. |
question_post |
Ask the human a question. It lands in the inbox and the feed, and returns the question id. Questions are for the human: agent-to-agent messaging is deferred, so there is no addressee field. Optional options suggest answers the human can pick with one tap. It may carry a deadline (see Deadlines). |
answer_post |
Reply to a question by its question id. The answer lands in the feed and closes the thread. |
inbox_read |
Read the human's global inbox, optionally by status, project or actor, and from a since cursor; the response carries next_since. Each item carries its project_display_name beside project_id. A decided approval carries its decision: approved or declined, the note the human left, who decided and when. A resolved question carries its answer: the reply body, who answered and when. An item with a deadline carries expires_at and on_expiry, and a resolution the hub made at the deadline carries expired: true with hub as the actor. |
inbox_wait |
Wait up to wait_seconds (30 default, 60 maximum) for a resolved item or a new event to land, then return the page and a next_since cursor. It carries the same filters as inbox_read, is scoped to the caller's own items, and returns as soon as something arrives, so an agent need not poll. wait_seconds: 0 polls once. |
notify_subscribe |
Register a standing interest in feed events by kind, optionally scoped to one project, so they are delivered through the notification trailer instead of polled. An empty or unknown kind is refused. The cursor is seeded at the newest matching event, so nothing from before the subscription is reported. Returns the subscription_id and the cursor it started at. |
notify_unsubscribe |
Remove one of the caller's own subscriptions by subscription_id. An unknown id, or another agent's, is not_found. |
artifact_publish |
Publish an HTML or markdown artifact, public or password protected. |
artifact_update |
Publish a new version of an existing artifact, sealing any live version first. |
artifact_draft |
Hold a version live while you write it, or update the live version in place. Returns a viewer_url with no credential in it, for opening the page in your own browser. |
artifact_get |
Read an artifact's content and metadata, optionally one version. Includes total and open thread counts across all versions. |
artifact_versions |
List an artifact's immutable version history. |
artifact_list |
List a project's artifacts, optionally filtered by session. Each entry includes total and open thread counts across all versions. |
artifact_delete |
Delete an artifact, its history, and its index row. |
comment_post |
Comment on an artifact, optionally anchored to a point or a quote. |
comment_list |
List an artifact's comments. |
comment_resolve |
Mark a comment done or reopen it. |
comment_delete |
Delete a comment. |
brain_get |
Read a path from a session brain, the caller's own or another named by session, or from a project knowledge base. |
brain_put |
Write a path into a session brain, the caller's own active one or its own named by session, or a page into a project knowledge base. |
brain_list |
List a store's entries, each with its type and size. |
brain_delete |
Remove a path from either store. |
brain_promote |
Copy an entry from the caller's active session brain into a project knowledge base page that cites the session it came from. |
search |
Search feed events, artifacts, session brains, and knowledge base pages, scoped to a project, a session, or global. |
whoami |
Report the calling identity, its personal space, and the URL of the agent guide. |
version |
Report the server version, for a connectivity check. |
Notifications on tool results
There is no push channel and no per-harness timer. Every successful tool
result may carry a top-level notifications member, present only when there
is something to report, whose pending list holds the items that need the
caller's attention. Each item is {source, kind, id, project_id, title, at}:
source is attention for an answer to a question the caller posted or a
decision on an approval it posted, or subscription for a standing
subscription it registered. The item is a nudge, not the record: the detail
stays in the inbox and the feed, read by inbox_read, inbox_wait, or
feed_read.
Each item is delivered once. Two server-side cursors, keyed on the resolved
actor and never the token, record what has been shown: one for the caller's own
attention queue, and one per subscription. A delivered read advances its
cursor, so the next call carries only what is new; an empty read moves nothing.
An error result carries no notifications member, and neither does a result
when there is nothing to report. notify_subscribe seeds its cursor at the
newest matching event, so a subscription reports from the moment it was made
rather than replaying history, and an unscoped drain keeps to the projects the
caller may read at drain time: an event in a project it may not read is left
above the cursor rather than consumed, so it arrives if a grant is later given.
A publish carries a description and a version label. It
also records the publishing agent identity as actor, resolved directly from
the authenticated caller principal. A caller cannot set or spoof actor in the
publish payload; the server ignores any client-supplied actor field. The
creator identity stays: updating an artifact by a different agent appends an
update event to the feed but leaves the artifact row's actor intact.
Everywhere an artifact is returned (MCP artifact_get and artifact_list, REST
listing, and single read), it carries actor, which is null for rows written
before the field was added.
artifact_update accepts the version the edit is based on as
base_version: a stale base is refused with a conflict naming the
current version unless force is passed, so two writers never silently
overwrite each other. History and deletion follow the same access rules
as reads and writes, and an artifact a caller may not reach reads as
forbidden whether it is missing or denied. Commenting works the same
way: posting needs write access and returns a delete token shown once,
and resolving or deleting needs the token or write access. A quote
anchor is refused on versions the server holds only as ciphertext.
Artifact reads and listings carry thread counts across every version:
comments_count (total comments) and comments_open (unresolved comments),
both defaulting to 0 when there are none.
Deadlines on questions and approvals
An agent that cannot wait indefinitely says so when it asks. question_post,
and signal_append with kind approval, take an optional
expires_in_seconds, from 60 to 2592000 (30 days), counted from the moment the
hub accepts the item. An approval may also name on_expiry: approve or
decline. A deadline without one declines, the safe outcome. A question takes
no on_expiry: at its deadline it closes with no answer. A value out of range,
an unknown outcome, an outcome without a deadline, a deadline on any other
kind, and a deadline on a self-enrolment request are refused as
invalid_argument and nothing is written.
If the item is still open when the deadline passes, the hub resolves it. The
resolution is an answer on the item's thread, as a human's is, written in
the same transaction that resolves the inbox item, so the feed and the queue
agree. Its actor is hub, a reserved name no agent can enrol under, and its
payload carries expired: true. For an approval it also carries the
decision the agent named; for a question there is no body. inbox_read
shows it as decision or answer with expired: true, the cursor counts it
as it counts a human's, inbox_wait wakes on it, and the notification
trailer delivers it under the same kind as a human resolution, with
expired: true and a title that says so.
Whichever lands first wins. A decision or an answer and the expiry each take the item's single immediate transaction and check that it is still open, so an item is resolved once. A decision that arrives after the deadline is refused as a conflict even if the sweep has not recorded the expiry yet. The hub sweeps for due items every few seconds through an index on the deadline, whether it serves over HTTP or runs embedded on stdio, and a read that shows whether an item is open settles every due item first, so an item past its deadline never reads as open.
A store from before hub was reserved may already hold an agent by that name.
When the hub opens such a store it revokes that agent's token, recorded as any
revoke is, and refuses the name a new one, so nothing else signs as the hub.
The agent's history stays. To keep the agent working, create it again under
another id and give it the new token.
This is not retention. Nothing else in the hub expires: a deadline is one agent's statement about one item it asked, and the human remains the garbage collector for everything else (ADR 0027).
question_post returns event_id, question_id, and thread_id, all the
same value: a question roots its own thread and is its own event. answer_post
takes that value as question_id. An inbox item exposes the same id as its
event_id, so a client can answer from either the post response or a read.
question_post takes an optional options: 2 to 6 suggested answers, each
one line of at most 80 characters once trimmed, none blank and no two the
same. A list outside those bounds is refused whole with invalid_argument and
posts nothing; the hub never drops or cuts an option. The options are stored
trimmed in the question's payload as payload.options, so inbox_read,
inbox_wait, feed_read and the inbox routes return them wherever the
question is read. A picked option arrives as an ordinary answer whose body
is the option's text, exactly as offered, and the human may write their own
reply instead, so an agent reads answer.body as it always has and compares it
with its options when it wants to branch. The answer records no separate
marker for a pick.
The brain tools reach two stores through one store argument. "session" is
a session's own brain, the working state that is pruned with the session.
"project" is the
project knowledge base, the durable store every agent with project write
shares, selected by project_id and defaulting to the active session's
project. The argument is required on brain_put and brain_delete, because a
write that lands in the wrong store is silent either way, and defaults to
"session" on the reads, where a wrong guess is a not_found the caller
recovers from.
The project listing a client reads over GET /api/v1/projects carries every
project the caller can see, which includes every other agent's personal space:
one operator, and a token reads every ordinary project. Each row carries
is_personal, true for a personal space and false for an ordinary project, so
a client labels or filters one without inferring it from the space- id prefix.
They stay visible because they are readable, and the hub marks them rather than
hiding them.
brain_get and brain_list take an optional session naming another session
to read, either {session_id} or {agent, name} with a project_id that
defaults to the active session's project; omitted, it is the active session.
Read access to the target's project is the whole rule, so an agent with
read access to a project reads its sessions. Reading another session
touches neither the caller's active session nor its existence, so a caller that
never started one still reads. A read opens no file that is not already there,
and a session the human has pruned is not_found from the moment it is marked.
Once the undo window has passed the row is gone, so the hub can no longer tell
which project it belonged to, and it answers as it does for any session that
never existed. An agent without access cannot tell a session it may not
read from one that does not exist: both are the same forbidden.
Writes stay with the session's owner. brain_put and brain_delete take an
optional session, the same argument a read takes, and write the session it
names; that is how a caller with no active session of its own reaches its own
brain, which a one-shot agent-hub call has to be. The named session must be
one the calling agent owns and must still be running, and another agent's is
refused with forbidden and an owner= tail, because one working-state file
has one writer and two would clobber each other. With no session named the
write is the connection's active session, and a connection that has none is a
conflict. Knowledge meant for another agent belongs in the project knowledge
base.
search takes session_id to narrow results to one session's brain content,
under the same project confinement as every other search. A hit carries its
project's display name and what its family shows: the event kind and actor for
a feed hit, the current version and its size for an artifact, the session's
name and status for a brain entry. Those are read after the result is ranked
and confined, by the ids of the hits alone, and a corpus row shows nothing of
a row another project holds, so no field reaches past what the caller can see.
The served bootstrap document, GET /bootstrap/SKILL.md, names the fields.
Paths are namespaced: /fs/ for the filesystem and /kv/ for key-value
entries. A knowledge base holds pages only, so a path there must start with
/fs/; anything else is an invalid_argument naming /fs/ as the namespace
the store has, since the knowledge base has no /kv/ to be redirected to. No tool exposes a raw file handle or the server path of a
file: session_start returns the session id, its owner and status, the two
namespaces to address the brain with, the conventional recovery path, and the
handoff note the previous owner left. One value is
capped at 4 MiB and one knowledge base page at 1 MiB, and a larger write is
refused with payload_too_large before anything is stored.
A read returns a version, the content hash of the bytes it returns, and a
write returns the version of the bytes it stored. Passing one back as
if_version makes a write conditional: it applies only while the stored
content still hashes to that value, and otherwise is refused with a conflict
whose message ends current_version=sha256:.... The literal absent creates a
page only when nothing is stored at the path. Without if_version the last
writer wins. The comparison and the write happen under the store's writer lock,
so two callers holding the same version cannot both succeed.
Identity and access
Every HTTP call carries a bearer token bound to a stable identity, and the
server sets the actor; a request cannot forge it. A proxy and a one-shot call
are HTTP calls, so they are the token's identity, and HUB_AGENT_ID is
advisory there: only the embedded standalone stdio mode carries no token, acts
as the human admin, and takes its actor label from HUB_AGENT_ID. A token
reaches every ordinary project and its own personal space; confidential
projects require explicit grants, and a grant is access or no access, with no
levels. The operating model states the full posture. Global reads
are confined to the caller's visible projects, so a search or an inbox read
never crosses a boundary.
Pagination, errors, and idempotency
Feed cursors are exclusive event ids; since walks forward and before walks
back, with a default page of 50 and a cap of 500. A page returns next_since
(the newest id, for polling forward) and next_before (the oldest id, for
paging back). An empty forward poll returns the since cursor it was given,
so a polling client keeps its place instead of losing it. feed_read is a
stateful read: with no since it polls forward from the caller's own durable
server-side cursor for the project, keyed on the resolved actor, and advances
that cursor to the returned next_since, so a restarted agent resumes where it
stopped. An explicit since is honoured and also advances the stored cursor to
the returned next_since, so a targeted read records progress. A backward read
returns no next_since and moves nothing. Tool errors are
structured (code, message, retryable, details) rather than prose. A
resource a caller may not reach returns the same error whether it is missing
or denied, so an agent cannot use an error as an existence check. A write that
creates a durable record elsewhere (a feed event, a question, an answer, an
artifact, or a decision) accepts an optional idempotency key, so a retry after
a dropped connection returns the original result instead of a duplicate. An
idempotency key is scoped to its specific operation and event kind (such as
event:signal, event:question, answer, decision, artifact:publish,
and artifact:update) and is bound to its target entity, so a key reused
across different entities or operations is rejected or isolated rather than
replaying unrelated records. An answered question is resolved permanently and
refuses further answers. A
question or an approval is an open item on the human, and the hub caps how many
one actor may leave open in a project; a write past the cap is refused with
rate_limited and changes nothing, and resolving an item frees its slot. See
the inbox cap.
Kind families
Event kinds are a closed set of seven design families (signal, finished,
question, answer, approval, artifact, session) plus system.
Sub-actions ride in the payload, so an artifact has an action of published
or updated and a session has started, ended, adopted, forked, or
reassigned.
Session ownership and pickup
A session belongs to the agent that started it. A name is unique per owner
inside a project, so two agents that choose nightly get two sessions and two
brains rather than silently sharing one, and each resumes its own. A name a
pruned session still holds is refused with a conflict naming it, because the
human can still undo that prune.
An agent picks work up by passing from to session_start, naming a session
by id or by agent and name. The hub chooses the mechanism from the source's
state, because the caller cannot tell from outside whether that session is
still running: an ended session is adopted, keeping its id, its brain and
its handoff note while ownership moves; a running one is forked into a new
session whose brain is copied through the engine, leaving the source untouched.
The result reports which happened and returns the note the previous owner left.
Every agent that may write the project may adopt an ended session there, and
the human approves nothing: adopt, fork, reassign and end-with-handoff all
reach the feed as ordinary session events.
The human's own move is the reverse one: reassigning a running session to another agent from the control surface, for the case where the agent holding it is not coming back.
Bootstrap convention
An agent orients itself in three calls at session start. session_start
establishes or resumes its session by project and session name, and returns the
handoff note the previous owner left beside recovery_path. feed_read then
reads the project feed since the agent last looked. brain_get reads the
session's recovery document, and with store: "project" the project knowledge
base page that outlives the session. That is the whole convention: it is the
sequence an agent follows at session start, and it is how a harness wires the
hub in. The hub ships the primitives and a served guide (agenthub://skill),
not per-harness scaffolding: wiring a harness to call these at session start,
and migrating an existing notes file into a brain, are the operator's steps and
are described in using the hub as a brain.
The feed cursor an agent reads forward from is the hub's, kept per agent and
project: a feed_read with no since polls from the stored cursor and
advances it, so a restarted agent resumes where it stopped with nothing of its
own to carry. session_start returns no cursor because it has none of its own
to return, and the recovery document holds what the hub cannot know, which is
what the session is doing rather than where it got to.
See also
- Human surface - the same data through the human's eyes
- Data model - what these tools read and write