PAIPAI

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 shapeConfigureWhat watch uses
One runtime in one processlive: { mode: "singleRuntime" }The runtime's local live bus
Multiple runtimes with low-latency updateslive: { mode: "distributed" } plus a realtime providerCross-runtime events and generation replay
Multiple runtimes without realtimelive: { mode: "distributed", fallbackPollIntervalMs: 1000 }Server-side snapshot polling and repair
Custom transport without watchThread()No watch methodClient-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.

On this page