InlayDocs

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 / canCancelDrive 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.

On this page