PAIPAI

Threads, Runs, And State

The durable state model that applications render and mutate.

A thread is one durable conversation or task. Most application code works with ThreadState, the client-safe projection of that durable state.

type ThreadState<TContract extends AgentContract = AgentContract> = {
  thread: PaiThreadHead<TContract>;
  messages: PaiMessage<TContract>[];
  runs: RunState<TContract>[];
  activeRunId: RunId | null;
  queue: ThreadQueueView<TContract>;
  usage: UsageSummary;
  capabilities: ThreadCapabilityView;
  error: ThreadErrorRecord | null;
};

messages is the one ordinary transcript collection. It contains projected AI SDK UIMessage values in transcript order. Runs and queue items stay normalized beside it because they have their own identities and lifecycles.

Use Message Format for message parts, PAI metadata, attachments, ToolData, suspension, and tool rendering state.

Messages, Runs, And Turns

PAI stores one complete assistant UIMessage per model step. A single run can therefore contain a user input followed by several assistant messages as tools execute and the model continues.

const run = await thread.send("Draft a report");
const state = await run.waitUntilIdle();

const runMessages = state.messages.filter(
  (message) => message.metadata.pai.runId === run.runId,
);

This preserves the AI SDK message shape while keeping durable run causality. If a UI needs turn containers, group the same message objects by message.metadata.pai.runId and join them to state.runs. That grouping is a view, not a second canonical transcript.

Queued input remains in state.queue.items[].messages until admission. Once admitted, the same message identity enters state.messages and the run appears in state.runs.

Message Metadata

PAI-owned message facts live under message.metadata.pai:

for (const message of state.messages) {
  const { producer, relation, runId, status } = message.metadata.pai;
  renderMessage({ message, producer, relation, runId, status });
}

Application metadata remains beside pai and keeps its contract-inferred type. Private metadata and authorization fields are not projected into ThreadState.messages.

Active And Waiting Runs

One thread can have one execution lease. state.activeRunId identifies the run currently executing and is null while no run owns that lease. A run that is waiting for a client tool or named suspension remains in state.runs with status: "waiting" even though activeRunId is null.

const activeRun = state.activeRunId
  ? state.runs.find((run) => run.runId === state.activeRunId)
  : undefined;

Pending action commands are control-plane sidecars, not another public state collection. Resolve one from its message and tool part:

for (const message of state.messages) {
  for (const part of message.parts) {
    if (!isPaiToolPart(part)) continue;
    const action = thread.getPendingAction(message, part);
    if (action) renderAction(action);
  }
}

Native SDK approval parts remain visible in their native state. PAI command adoption for native approval responses is intentionally deferred.

Watching And Refreshing

thread.watch() yields the same public ThreadState shape returned by thread.getState() and thread.refresh():

for await (const state of thread.watch()) {
  render(state.messages, state.runs, state.queue);
}

The transport privately reduces SDK UIMessageChunk values and projects PAI's reserved data parts. Applications never need to reconcile a second feed DTO or read storage envelopes.

Durable Runtime State

Runtime and storage code use an internal thread snapshot containing thin message envelopes, normalized run records, queue records, leases, and the exact thread incarnation/version. It is deliberately not a public client format.

Storage providers implement selective, version-consistent reads. Ordinary clients should use ThreadState; trusted lifecycle and administration APIs receive their own projected server views instead of raw persisted envelopes.

Failures

Run failure is run-level state, not a permanent thread lock. A failed run stores a client-safe error, releases the active lease, and exposes the latest recoverable failure as state.error. Raw provider or infrastructure exceptions are not persisted.

The next accepted send clears the thread error. Queued work behind a failed run is removed with the run-failed reason rather than admitted after a broken turn. See Error Handling.

Usage

Each model-produced assistant message represents one model step, so its metadata.pai.usage describes that call. RunState.usage aggregates one accepted unit of work and ThreadState.usage aggregates the thread.

const runState = state.runs.find((candidate) => candidate.runId === run.runId);
console.log(runState?.usage.totalTokens);
console.log(state.usage.totalTokens);

Usage is provider/model-call usage, not a deduplicated transcript size. Prompt cache fields describe how the provider served repeated input when available.

State Changes

Thread state changes through explicit operations such as send, regenerate, submit or cancel an action, stop, trigger, and recall. The runtime validates each operation against the trusted scope and current thread incarnation, then updates the normalized thread, message, run, and queue facts atomically.

On this page