Integrate as a UI
A UI reads session history, searches across it, and drives turns through an
open-ended chat-and-poll surface — with a person watching, so the
interesting part is the polling loop that turns a raw event stream into
something you can render live. Every route below is under /api/* and
session-cookie gated (see Authentication)
unless noted otherwise. This page covers only the READ and CHAT surface;
session preferences (the model/effort/permission-mode override routes), the
diff and terminal job routes, the worker registry’s browser-facing control
routes, and push subscriptions are also browser-app /api/* routes, but
outside this guide’s scope.
The session model
A session is one conversation with an agent, tied to a provider (which
harness ran it) and a root_id (which machine/account owns it) —
provenance rides on every session, and an unattributed session is a normal,
filterable state rather than an error.
Messages are tree-shaped, not a flat list. Each MessageNode carries its
own native_id and a parent_native_id — null marks a tree root. Claude
Code’s own “rewind” creates sibling branches rather than overwriting
history, and is_sidechain flags a subagent’s own branch — a client that
assumes linear history will render a rewound conversation wrong. A
MessageNode also carries role, kind, timestamp, text, a blocks
array (structured content), and an extra bag for provider-specific detail.
curl -s https://hub.example.test/api/sessions/42/messages -H "Cookie: $SESSION_COOKIE"
returns the full tree, in original order. For a long session, page newest-first instead:
curl -s "https://hub.example.test/api/sessions/42/messages/page?limit=50" \
-H "Cookie: $SESSION_COOKIE"
# {"messages": [...], "next_cursor": {"ts": 1699999999000, "pk": 41} }
Pass next_cursor.pk/.ts back as cursor_pk/cursor_ts to fetch the next
(older) page; next_cursor: null means you’ve reached the start of the
session.
Listing and searching sessions
# List, with optional filters: provider, root_id, account,
# unattributed_only, include_archived, project, title_contains,
# updated_after, updated_before, limit, offset.
curl -s "https://hub.example.test/api/sessions?provider=claude&limit=20" \
-H "Cookie: $SESSION_COOKIE"
# Full-text search across message content, same filters plus a required q.
curl -s "https://hub.example.test/api/search?q=migration&provider=claude" \
-H "Cookie: $SESSION_COOKIE"
# [{"session": {...}, "message_native_id": "...", "role": "assistant",
# "timestamp": 1699999999000, "snippet": "...the [match]migration[/match]..."}]
A search hit’s snippet wraps matched text in [match]...[/match] markers
for you to highlight. GET /api/sessions/{pk} fetches one session; GET /api/projects lists every distinct project directory seen, with a session
count each.
Sending a chat and polling turn events
Every route in this section is a browser-session-cookie route — the
same credential as everything else on this page, never a bearer token (see
Authentication). This is the general-purpose
chat surface: unlike the fixed, single-profile turn a dispatcher can queue
via /api/integrations/* (see Integrate as a dispatcher),
here the caller picks the provider (or a tier) on every request. The root
and working directory, though, are a NEW-SESSION-ONLY choice: only
Create below accepts root_id/working_directory at all — a
Follow-up or a Fork always inherits the session’s own root and
working directory and has no field to override either.
All three turn-creating shapes below — Create, Follow-up, and Fork —
check the same thing first, before any other validation runs: if agent
authentication isn’t configured on this hub at all, every one of them
answers 503 {"error": "agent authentication is not configured"}
unconditionally, before even checking that text is non-blank or that a
provider/tier was named. This mirrors the same unconditional
/api/agent/* gate Authentication describes
for the worker-facing surface, just reached from the browser side instead.
Sending a message is two calls: create the turn, then poll its event stream
until it’s done. Three shapes create a turn, all returning
{"turn_id": "<string>"}:
Create — start a brand-new session. Name either provider (a fixed
choice) or tier (let a worker pick from a candidate list — see below); at
least one is required.
curl -s -X POST https://hub.example.test/api/sessions/new \
-H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{
"provider": "claude",
"text": "Summarize this repositorys test coverage.",
"root_id": "root-1",
"working_directory": "/home/tester/work/repo-a",
"model": "sonnet",
"effort": "high",
"permission_mode": "acceptEdits"
}'
# {"turn_id": "42"}
Admission — every rule a Create request is checked against, in
order: an explicit provider must be a KNOWN one (a registered harness
capable of being driven — a name your own product invented isn’t) AND
CURRENTLY served — some worker must have advertised it within the last
10 minutes (exactly 600s), or you get a 400 “no connected machine currently serves
‘<provider>’ — the chat would queue forever” rather than a turn nothing
will ever claim. The one exception: on a hub that has NEVER seen a single
worker poll at all (a genuine cold start, not “nothing serves this
provider right now”), the currently-served check is skipped leniently for
a plain provider — this exception does NOT extend to tier (see “Tiers
and candidates” below, which has no such leniency). root_id, if given,
must be a root the hub knows about (see Roots below), and — only
when provider is ALSO given — must belong to that same provider (a
pure-tier create has no provider yet to check the root against, so this
match isn’t enforced). working_directory, if given, requires root_id
and must be a directory already seen under it. effort and
permission_mode, if given, must each be one of a fixed allowlist (400
naming every accepted value otherwise). Omitting provider and giving
tier instead leaves the provider unresolved — see “Tiers and candidates”
below.
Follow-up — continue an existing session. No provider field: the turn
inherits the session’s own provider, since native session continuity pins
it for the session’s lifetime.
curl -s -X POST https://hub.example.test/api/sessions/42/chat \
-H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{"text": "What changed in the last release?"}'
# {"turn_id": "43", "warning": null}
A follow-up is never refused for a root nobody is currently serving —
the turn queues either way (the pending TTL turns an unclaimed turn into
an honest terminal expired event rather than letting it hang forever).
Instead the response carries a warning: null when a worker advertised
the session’s owning root within the last 10 minutes (the ordinary case),
otherwise a short human-readable string such as "helper for this session hasn't checked in for 12m — message will queue" (or "no helper has ever reported serving this session's root — message will queue until one polls" on a hub that has never seen one). Show it next to the composer;
do not treat it as an error.
All shapes accept an optional model and effort override, on top of
whatever a session already has recorded.
Tiers and candidates — provider as a constraint set
Name tier (quick / standard / heavy) instead of provider and the
hub doesn’t pick a provider for you — it resolves an ordered list of
acceptable (provider, model) pairs (a TierCandidate array) and stores
the turn with provider: null. The claiming worker, not the hub, makes
the actual pick from that list against its own local availability — see
Integrate as a worker. The exact candidate
list, and whether admission applies at all, depends on which of the three
shapes above is doing the naming:
- Create (this section, as above) — the list is narrowed to whichever
providers a worker has recently advertised as servable (or, if a
provideris ALSO named, to that provider’s own candidates). Naming bothtierandproviderpins the provider but still leaves the model list open. Admission applies in full here: if nothing currently serves the requested tier (or tier+provider combination), enqueueing fails with400rather than creating a turn that would queue forever with no worker able to claim it — and unlike the plain-providercold-start exception above, an entirely empty presence history is NOT treated leniently for a tiered create; it correctly reports nothing available. - Follow-up —
tiernarrows the candidate list to the SESSION’s own provider (continuity already pins that), but does NOT intersect against worker presence at all — no admission check runs, and an empty result is not an error. An empty candidate list here just means “the claiming worker should fall back to its own locally configured tier table,” never a400— a follow-up to an already-open chat is never refused for lack of current advertisement, tier or not. - Fork —
tiernarrows to the SOURCE session’s own provider (the fork always keeps that provider, same asroot_id/working_directoryabove) AND does intersect against worker presence, the same strict admission Create applies: an empty result after that intersection is a400“would queue forever,” since a fork mints a genuinely new session just like Create does.
The turn poll loop
curl -s "https://hub.example.test/api/turns/43/events?since=-1" \
-H "Cookie: $SESSION_COOKIE"
# {"events": [...], "next_since": 5, "claim_generation": 1,
# "done": false, "ok": null, "error": null, "session_pk": 42, "attempts": 0}
The reference web client’s loop is exactly this, and it’s worth copying precisely — the two easy mistakes are dropping the generation check and re-polling too aggressively:
- Start
since = -1,knownGeneration = null. GET /api/turns/{id}/events?since={since}.- Check reclaim BEFORE updating anything: it’s a reclaim if
knownGenerationwas notnullAND this snapshot’sclaim_generationdiffers from it. - Set
knownGeneration = claim_generationunconditionally, every single successful snapshot — not only on a reclaim, and not skipped on the very first poll (where it simply movesknownGenerationfromnullto the real value). Do this before branching on step 3’s result, so step 6’s sleep-and-repeat always resumes with the latest generation already recorded. - If step 3 found a reclaim: the turn was claimed by a fresh worker
attempt. Discard every event rendered so far (emit a “reset” to your
own UI state), discard this snapshot’s OWN events too (they may be a
partial tail of the new generation, not its start), and set
since = -1— don’t advancesincefrom this snapshot. This does NOT skip the sleep in step 6; a reclaim round waits the same interval as an ordinary in-progress one before polling again, it does not fast-path retry. - Otherwise (no reclaim), apply
eventsin order (append to whatever you’re rendering) and setsince = next_since— note this is a durable cursor from the server, not justevents.length, precisely because a reclaim can leave earlier-generation rows hidden. Ifdone: true, stop:ok/errorare the terminal outcome, andsession_pkis populated the first time a “new chat” turn’s session actually gets created. - Whenever you didn’t just stop (a reclaim round, or an ordinary
not-yet-
doneround), sleep briefly (the reference client uses 500ms) and repeat from step 2, with a request-level retry (backing off 500ms, 1s, 2s, 4s, 5s across a handful of attempts) on a transient network error or 5xx before giving up — always resuming from the samesince, never skipping ahead. Give the whole exchange an overall deadline (15 minutes is a reasonable default) past which you stop polling and let the person check back later rather than polling forever.
A fork (POST /api/sessions/{pk}/fork, same body shape as chat) starts
a new turn the identical way. A 400 here has more than one cause, not
just one: an unsupported provider answers
"forking is not available for '<provider>' sessions" (forking is a
claude-CLI-only concept today — a Hermes session, for instance, gets
this same message); a malformed request (empty text) or an inadmissible
one (a named tier this hub has no tier table for, or nothing currently
serving the tiered candidate list — see “Tiers and candidates” above)
answers 400 too, with its own distinct message in each case.
Capabilities and roots
# What one root's harness currently reports it can do: tools, MCP servers,
# skills, slash commands, subagents, and which model it's running.
curl -s "https://hub.example.test/api/capabilities?root_id=root-1" \
-H "Cookie: $SESSION_COOKIE"
# {"provider": "claude", "model": "sonnet", "tools": [...],
# "mcp_servers": [...], "skills": [...], "commands": [...], "agents": [...],
# "observed_at_ms": 1699999999000, "source": "live_probe"}
# Every root's most recently reported snapshot at once, instead of one
# root at a time — a different route, not the same one with no root_id.
curl -s https://hub.example.test/api/capabilities/roots -H "Cookie: $SESSION_COOKIE"
# {"roots": [{"root_id": "root-1", "capabilities": {"provider": "claude", ...}}]}
Roots
curl -s https://hub.example.test/api/roots -H "Cookie: $SESSION_COOKIE"
# {"roots": [{"id": "root-1", "provider": "claude", "label": "Home", "account": "personal",
# "last_seen_ms": 1757154000000, "online": true}]}
curl -s https://hub.example.test/api/roots/root-1/projects -H "Cookie: $SESSION_COOKIE"
# {"projects": [{"project": "/home/tester/work/repo-a", "count": 12}]}
A root is a machine/account pair a worker has ingested sessions from or can
create sessions under. last_seen_ms is the wall-clock time of the last
worker poll that advertised the root as servable (null if none ever has),
and online is the hub’s own fold of that against the same 10-minute
window the follow-up warning above uses — so a picker can grey out a
root whose machine is asleep without re-deriving the threshold. Use
GET /api/roots and .../projects to populate a
“where should this new chat run” picker — these are the exact same values
root_id/working_directory on turn creation
above are validated against.
Every route on this page accepts only the session cookie — there is no
bearer-token mode. A 401 means the session expired; send the person to
/auth/login rather than trying to recover in place.
What the docs host exposes
If your deployment runs this documentation site on its own hostname (see
Run your own), that hostname serves only this site,
/healthz, and the public OpenAPI routes (/api/openapi.json, /api/docs)
— never the app shell, /api/sessions/*, or anything else on this page. A
UI always talks to the app host, not the docs host; the two are a
presentation split, not two views onto the same API.