Live State
Refresh, watch, poll, and render native thread messages.
Choose the simplest update mode that gives your UI the latency it needs.
thread.refresh(), thread.watch(), and the run wait helpers all expose the
same public ThreadState shape.
Refresh
const state = await thread.refresh();
render(state.messages);Use refresh for explicit reloads and recovery. A local handle for an unrealized thread ID returns empty state until its first accepted send creates the durable thread.
Watch
for await (const state of thread.watch()) {
render(state.messages, state.runs);
}Use watch for CLIs, scripts, workers, custom clients, and UI stores. React and similar bindings can own this loop and expose ordinary component state.
watch() yields ThreadState, not transport frames. The client internally
folds native AI SDK UIMessageChunk values, applies normalized run and queue
updates, and repairs from a snapshot when stream continuity is uncertain.
Connection Recovery
A watch outlives its own connection. Server restarts, dropped sockets, proxy
timeouts, and failed reconnect attempts are all retried with exponential
backoff for as long as the caller's signal is live. Thread and run watches
re-subscribe from the latest durable snapshot, so they repair to current state
after downtime. You do not need to retry around watch().
The loop ends only when the caller aborts, or when retrying cannot safely succeed — the thread does not exist, belongs to another agent, changed incarnation, or the transport reports another permanent failure. Those throw.
for await (const state of thread.watch({
signal,
reconnect: {
initialDelayMs: 500,
maxDelayMs: 10_000,
onReconnect: ({ attempt, delayMs }) =>
setStatus(`reconnecting in ${delayMs}ms (attempt ${attempt})`),
onConnected: ({ reconnected }) =>
setStatus(reconnected ? "reconnected" : "live"),
},
})) {
render(state.messages, state.runs);
}onReconnect and onConnected exist to drive connection status in a UI;
recovery happens whether or not you pass them. HTTP watches report connected
after receiving a successful, valid SSE response, so a healthy quiet thread
does not remain stuck in a connecting state while it waits for its first event.
client.threads.watchHeads() recovers the same way. Because head events are
deltas rather than snapshots, changes may fall between the list read and the
first subscription or occur while a connection is down. Reconcile the list on
every onConnected callback. The useThreads hook already does this in the
background and coalesces repeated reconnects.
Worker Process Crashes
Transport reconnection does not restart execution. If a server restart also
kills the worker that was running the agent, the durable snapshot can still
show running until that worker's active-run lease expires.
While the thread is watched, the serving runtime checks the active lease at
its deadline. All watchers for the same scoped thread in that runtime share one
check. A healthy renewal moves the deadline; if the storage provider confirms
that the same version, run, and lease owner are still expired, PAI atomically
fails the run with run_interrupted, finalizes its abandoned assistant tail,
and returns the thread to idle.
While a model response is active, the runtime also checkpoints the complete
native assistant message on a bounded, coalesced cadence. Token delivery does
not wait for those writes. Crash finalization preserves the latest snapshot
that storage accepted, marks streaming text and reasoning terminal, and turns
unfinished tool parts into output-error. The partial assistant content can
therefore remain visible even though its run correctly reports
run_interrupted.
With a healthy store, the newest durable text and reasoning normally trail the live stream by no more than roughly the internal one-second cadence. A slow or failing checkpoint can make that rollback window longer because generation is kept live-first. The cadence is an internal runtime policy, not a public watch or client option.
The resulting terminal snapshot supersedes any generation replay left open by the dead worker, so the watch does not wait for an SSE or realtime close event that can never arrive. PAI does not automatically rerun or resume interrupted work because a tool may already have caused an external side effect. This is bounded terminalization of a crashed run, not cross-process continuation.
The deadline work is lazy: there is no thread scan, no client polling loop, and no timer when the thread has no watchers. A later run admission can also repair an expired lease before starting new work.
Choosing Realtime
Realtime is backend infrastructure, not a different application API.
| Deployment shape | Configure | What watch uses |
|---|---|---|
| One runtime in one process | live: { mode: "singleRuntime" } | The runtime's local live bus |
| Multiple runtimes with low-latency updates | live: { mode: "distributed" } plus a realtime provider | Cross-runtime events and generation replay |
| Multiple runtimes without realtime | live: { mode: "distributed", fallbackPollIntervalMs: 1000 } | Server-side snapshot polling and repair |
Custom transport without watchThread() | No watch method | Client-side snapshot refresh and polling |
Use external realtime when one runtime may serve the watcher while another executes the run. Without it, snapshots still provide correctness, but updates arrive on polling or refresh.
Polling
while (!done) {
const state = await thread.refresh();
render(state.messages);
await delay(1000);
}Polling is valid. Prefer explicit polling only when the application needs
direct control over interval, backoff, visibility, or bandwidth; otherwise use
thread.watch(), run.waitUntilBlocked(), or run.waitUntilIdle().
Run Updates
Runs are normalized in state.runs rather than wrapping messages:
const run = await thread.send("Write the draft");
for await (const state of thread.watch()) {
const current = state.runs.find(
(candidate) => candidate.runId === run.runId,
);
renderRun(current);
render(state.messages);
}state.activeRunId names only the run holding the current execution lease. It
is null while a run waits for external input. The waiting run itself remains
in state.runs with status: "waiting".
For a one-shot flow, run.watch() yields the same ThreadState and stops at a
selected lifecycle boundary:
for await (const state of run.watch({ stopOn: "blocked" })) {
render(state.messages);
}Use run.getState() or run.waitUntilStatus(status) when only the lower-level
run record is needed.
Usage arrives through the same state stream. state.usage is the thread
aggregate, each entry in state.runs owns its run aggregate, and a completed
model step can expose usage through message.metadata.pai.usage.
waitUntilBlocked
run.waitUntilBlocked() resolves when the run reaches "waiting" or a
terminal status and returns the latest owning ThreadState.
const run = await thread.send("Draft and approve the report");
const state = await run.waitUntilBlocked();
for (const message of state.messages) {
for (const part of message.parts) {
if (!isPaiToolPart(part)) continue;
const action = thread.getPendingAction(message, part);
if (action?.name === "approveReport.approval") {
await action.submit({ approved: true });
}
}
}This pattern covers named suspend/resume, client tools, backend declarations
without execute, and human-in-the-loop flows. The command object is a sidecar
resolved from the exact message and native tool part.
waitUntilIdle
run.waitUntilIdle() follows the run through queued, running, and waiting
states until it reaches "completed", "failed", or "cancelled".
const run = await thread.send("Write the draft");
const finalState = await run.waitUntilIdle();
render(finalState.messages);The runtime does not need a connected client to continue working. Wait helpers observe durable state until the requested boundary is reached.
Open Messages
While a model step streams, it is already an ordinary PaiMessage in the sole
transcript:
for await (const state of thread.watch()) {
for (const message of state.messages) {
if (message.metadata.pai.status === "open") {
renderStreamingMessage(message);
} else {
renderMessage(message);
}
}
}The client retains the message identity while native chunks update its parts.
When the step commits, metadata.pai.status changes to "committed" on the
same logical message.
Optimistic user sends are also ordinary messages, with
metadata.pai.status: "pending". They have no run ID until admission and are
reconciled by their final message ID.
Tools While Streaming
Tool calls remain native AI SDK parts. Static tools use
type: "tool-${name}"; dynamic tools use type: "dynamic-tool".
function ToolPart({ message, part, thread }) {
const action = thread.getPendingAction(message, part);
switch (part.state) {
case "input-streaming":
return <ToolPreparing input={part.input} />;
case "input-available":
return action ? (
<ToolWaiting input={action.input} submit={action.submit} />
) : (
<ToolInput input={part.input} />
);
case "approval-requested":
case "approval-responded":
return <ToolApproval approval={part.approval} />;
case "output-available":
return <ToolResult output={part.output} />;
case "output-error":
return <ToolError message={part.errorText} />;
case "output-denied":
return <ToolDenied reason={part.approval.reason} />;
}
}PAI-specific ToolData is available on part.data; suspension history is on
part.suspensions, with the current pending episode on
part.pendingSuspension. Those enrichments do not replace part.state.
React renderers also receive the owning run's runStatus and the action
sidecar. Presentation state is deliberately not derived by PAI and never
serialized into the native tool state; renderers compose part.state,
part.cancelled, and runStatus for themselves.