Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 provider is ALSO named, to that provider’s own candidates). Naming both tier and provider pins 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 with 400 rather than creating a turn that would queue forever with no worker able to claim it — and unlike the plain-provider cold-start exception above, an entirely empty presence history is NOT treated leniently for a tiered create; it correctly reports nothing available.
  • Follow-up — tier narrows 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 a 400 — a follow-up to an already-open chat is never refused for lack of current advertisement, tier or not.
  • Fork — tier narrows to the SOURCE session’s own provider (the fork always keeps that provider, same as root_id/working_directory above) AND does intersect against worker presence, the same strict admission Create applies: an empty result after that intersection is a 400 “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:

  1. Start since = -1, knownGeneration = null.
  2. GET /api/turns/{id}/events?since={since}.
  3. Check reclaim BEFORE updating anything: it’s a reclaim if knownGeneration was not null AND this snapshot’s claim_generation differs from it.
  4. Set knownGeneration = claim_generation unconditionally, every single successful snapshot — not only on a reclaim, and not skipped on the very first poll (where it simply moves knownGeneration from null to 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.
  5. 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 advance since from 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.
  6. Otherwise (no reclaim), apply events in order (append to whatever you’re rendering) and set since = next_since — note this is a durable cursor from the server, not just events.length, precisely because a reclaim can leave earlier-generation rows hidden. If done: true, stop: ok/error are the terminal outcome, and session_pk is populated the first time a “new chat” turn’s session actually gets created.
  7. Whenever you didn’t just stop (a reclaim round, or an ordinary not-yet-done round), 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 same since, 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.