Sessions, threads & state — what lives where¶
This is the mental model for state when a React (or any SSE) frontend talks to a served agent. The one rule to remember: the client owns the wire; the server owns the memory.
UI (useAgent) wire (SSE) Server (A2A / serve)
───────────── ───────── ─────────────────────
turns, thinking, activity ◄─────── {contextId, event} ◄───────── per-context Agent (LRU cache)
status, error, SendResult ──────► POST {message, contextId} ───► ├─ memory: clear-then-save per run
contextRef.current (id only) ◄──── {contextId, done, text} ◄──── ├─ session: SessionManager(name:ctxId)
├─ checkpoints: FileCheckpointer(ctxId)
AgentState / invocationState / swarm sharedState — server only, never serialized to UI or model
1. Sessions and threads: the client only carries an id¶
- On the first send, the server mints
contextId(ctx-<hex36>) unless you supplied one. Every SSE frame carries it; the hook'sonContextcaptures it (send.toalso returns theSend-result's contextId). - Every subsequent
send()posts it back asparams.contextId. That is the entire client-side "session": an opaque handle. - What the id selects server-side (
A2AServer/serveFramework): - Per-context agent —
agentFactory(contextId)result cached in an LRU (maxContexts, default 1000). Same id ⇒ same agent instance ⇒ its memory/history continues the conversation. Never reuse one instance across contexts (that would leak user A's history into user B's). - Session persistence — with
sessionDirin framework.json, each context agent getsSessionManager(session_id = "<def-name>:<contextId>", storage: LocalFileStorage). History +AgentStaterestore on the first run and save after every run: the conversation survives server restarts while the UI holds no history at all. - ThreadId = the same
contextIdwhen acheckpointeris configured: checkpointed HITL (suspend → human decision → resume) is keyed per context; the server side of "pause" is a thread. - Want the conversation reset? Skip
contextId(or callreset()), and the server mints a fresh one. Want a resumable conversation later? PersistcontextId(e.g. localStorage keyed by user) and pass it viauseAgent({ contextId }).
2. The three server-side state lifetimes (never cross the wire)¶
| Lifetime | Lives in | Shared across | Serialized to model? | Serialized to UI? |
|---|---|---|---|---|
| Conversation history | agent memory / runLoop messages | steps in one run + turns of the session | yes (as messages) | no — only via token/done events |
AgentState (kv) |
Agent instance |
the whole agent lifetime | never | no |
invocationState (per-run) |
passed between hooks/tools by reference | one run | never | no |
swarm sharedState |
swarm bookkeeping | members of one topology | never | no |
invocationState is deliberately by-reference server-side: tools and hooks
read/write the same object mid-run (build resolvers, progress trackers,
rate-limit buckets). Because the loop's model calls only receive messages,
and the SSE stream only emits events, none of this escapes the server. If
you need server→UI values mid-run today, surface them as tool calls —
tool_call/tool_result events cross the wire and appear in activity
(names) and the SendResult.toolCalls list.
3. What the UI can actually see¶
| UI field | Source event | Server counterpart |
|---|---|---|
turns (text deltas / final reconcile) |
token / done |
completion text per run |
thinking |
thinking |
reasoning deltas |
activity (name lines) |
tool_call / tool_result |
tool executions in the run loop |
status / error |
stream lifecycle | run started/finished/failed |
contextId |
every frame's contextId |
the session/thread key |
await send(text) additionally resolves with { text, toolCalls, contextId }
— the awaited final text and the tools used in that run (names, in call
order — outputs live in server-side AgentResult.toolCalls records).
4. Recipes¶
Per-user conversations — key contextId by user: pass
useAgent({ contextId: userId }) (or your own ctx-<userId> scheme); the
server isolates agent + session + checkpoints per id automatically.
Server restarts — with checkpointer.dir set, suspended HITL runs resume
from disk; with sessionDir, finished runs restore their history + state.
The UI does nothing special: keep sending the stored contextId.
Inspecting server-side state while developing — serve with the semantic
tools or a write_state tool of your own and read results from the
SendResult/activity; or check sessions//runs/ directories on disk.
Multi-agent shared context — Swarm's sharedState moves data between
agents inside one server-side topology; the swarm-level result's
sharedContext is returned to the caller of swarm.run on the server, not
to a UI. Expose an A2A agent that wraps the swarm if the UI should see its
output.
5. Extending what crosses the wire¶
Today the stream carries step/token/thinking/tool_call/tool_result/done.
To push custom server-side data to the UI, three supported paths:
- Make it a tool — custom events come free via
tool_call/tool_result. - Use a middleware
after_modelthat embeds values in the completion text — they arrive as tokens. - For richer per-event typing, mount
AgentClientdirectly (headless) and implement your own state mapping — the hook is intentionally thin. Seedocs/api/react.mdanddocs/api/typescript.md.