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.