Conversations
Reopen, list, cancel, and start fresh.
Running an agent produces a conversation. A user can close the tab and come back to its persisted state.
Reopening
Pass conversationId and the hook fetches persisted state on mount: a
persisted transcript window, bindings, and any pending tool call.
Changing the id rehydrates it.
function Sessions({ onPick }: { onPick: (id: string) => void }) {
const { conversations, isLoading } = useInlayHistory({ ...conn });
if (isLoading) return null;
return conversations.map((c) => (
<button key={c.id} onClick={() => onPick(c.id)}>
{c.title ?? "Untitled"}
</button>
));
}
function Reopened({ conversationId }: { conversationId: string }) {
const { snapshot } = useInlayChat({ ...conn, conversationId });
return <p>{snapshot.rows.length} rows restored</p>;
}resume(id) fetches the same state imperatively if you would rather not
drive it from props. Neither path reattaches to the original live SSE stream.
Rehydration replays the recorded rows in that window, including tool calls
and outputs. The live/replay parity test
(replay_parity.rs) checks folding the same recorded facts, not that every
live frame was persisted.
During execution, batched rows and text in an open assistant turn may not yet be durable. See Reopening during execution for when a successfully written node completion becomes readable. Storage failures can still leave live facts missing on reopen.
Long conversations
A reopen fetches a window of the log, not the whole thing: the most recent
persisted rows (200 by default), so a long conversation opens near its latest
recorded activity, not where it began. snapshot.hasEarlier tells you earlier
rows exist, and loadEarlier() pages back:
const { snapshot, loadEarlier } = useInlayChat(conn);
if (snapshot.hasEarlier) {
const added = await loadEarlier(); // rows added above the window
}Rows arrive with their durable ids, so paging back never duplicates — the
re-hydrate upserts. resume(id, { beforeSeq, afterSeq, limit }) takes the same
anchors if you want a specific window up front.
The reopen also rebuilds the run projection (useRun() / useNode() /
useSubagents()) from the recorded lifecycle in that window. See
Progress and topology. A conversation written
before lifecycle rows existed reopens transcript-only: the projection is
honestly empty, never approximated.
A promoted subagent artifact (one a subagent exposes to a parent output) appears under the parent's binding live and reopens in the same place when its persisted row is in the fetched window. One nuance, being tracked: the subagent's own typed accessor (
subagents.x().outputs.y) for a promoted name is populated while watching but not restored after a reload; the parent's binding covers the value either way.
Listing
useInlayHistory lists the current user's conversations for this agent, newest
first, with status, limit and offset for narrowing. It pages at
HISTORY_DEFAULT_LIMIT (100 — the server's own page size) when you pass no
limit, so hasMore and loadMore work out of the box and a long history is
reachable. Each row carries an id and a title, which is what you hand back
to conversationId.
A generated client bakes the agent id in, so useKitchenHistory({ ... }) cannot
accidentally list a different agent's conversations — passing agentId is a
compile error rather than a silent cross-agent read. Reopening another
agent's row READ-ONLY is the deliberate exception: allowForeignAgent: true
(on the hook, or resume(id, { allowForeignAgent: true })) hydrates the
transcript with every drive refused — the multi-agent-sidebar affordance
(loadEarlier pages; delete tombstones; cancel is a no-op — a read-only
view never has a live local run to stop).
Sending, stopping, resetting, deleting
Conversations are always continuable: a send on any settled row — paused, awaiting a client tool, completed, cancelled, or failed — continues the same thread, never a new one. Where the next turn picks up depends on what the row holds:
- Paused at a
user_turn— the message appends and the walk proceeds from the pause point (the classic resume). - Failed (retryable) — the message re-drives the failed node with your correction in history ("fix it and try again"); the failed attempt's rows stay in the transcript.
- Cancelled mid-run — the message re-drives the interrupted node the same way: stop the agent, correct, continue. A cancelled run's banked partial output stays, honestly, as an interrupted turn.
- Completed (or a failed/cancelled row with no re-drive-able position) — a fresh turn from the graph's entry over the accumulated record: prior turns are history rows and never re-execute; only the new turn's walk runs.
One gate: the graph must be interactive — contain a user_turn somewhere
(the realistic chat shape is llm → user_turn → llm). A pipeline with no
user_turn refuses the send (422): re-walking it would re-run settled work
with nothing to consume the message. A fresh conversation happens only when
none is held (reset(), or the first send of a new chat).
send(text) | Starts the conversation, or continues it — always the same thread once one exists. |
cancel() | Stops the in-flight run (a live verb: streaming, or the brief send/reopen startup windows). |
reset() | Abandons the view locally and returns to an empty, idle state. |
delete() | The one permanent end — the soft-delete tombstone (gone from the list, unresumable). |
canSend / canCancel | Drive your composer and Stop button off these. |
reset() and cancel() are local/live respectively — neither ends the
conversation server-side. An idle row (paused, awaiting a tool, terminal) is
continuable, not stoppable: cancel() no-ops there, and the server's cancel
route 409s ("nothing to stop"). The old "End conversation" flip of a paused row
is retired — a cancelled row continues exactly like a paused one, so the flag
only conflated declared done with was interrupted. Delete is the end
verb.
Version pinning
If you pass version (or use a generated client, which bakes it in), every run
is pinned to that agent version. Publishing a new version of the agent will not
move the graph under a client that was built against the old one — your app
keeps running the contract it compiled against until you re-run codegen.
Without a pin you always get the published version, which means a republish can change behaviour under a running app.
One case survives the pin: reopening a conversation that started under an older version. The SDK warns once when the running version differs from the one the client was built for, because that is the case where your types and the recorded conversation genuinely disagree.
The authoring half of this — drafts, publishing, rollback — is in Versions and publishing.
Next
Retrieval — grounded answers over your own corpus.