Message Format
The SDK-native transcript shape, PAI message metadata, tool enrichment, streaming, and model replay.
PAI persists and transports complete AI SDK UIMessage values. Applications
consume one native-first projection, PaiMessage[], through
ThreadState.messages and useChat().messages.
PAI does not maintain a second persisted part grammar or a separate feed DTO. Standard AI SDK parts keep their native names and discriminants. PAI adds only the durable thread, run, tenancy, queue, suspension, ToolData, and file semantics that the SDK does not provide.
stored UIMessage
-> native UIMessageChunk replay
-> private SDK reducer
-> PaiMessage[] for applications
-> ModelMessage[] for the next provider callMessage granularity
One model call produces one assistant message. A single PAI run can therefore contain several assistant messages:
user message
assistant model step: calls getWeather
assistant model step: uses the tool result and answersThe messages remain separate because each step has its own durable identity,
usage, diagnostics, provider content, and recovery boundary. A renderer can
group messages by metadata.pai.runId when it wants turn-like presentation,
without changing the canonical transcript.
Public shape
PaiMessage keeps the ordinary AI SDK message layout:
type PaiMessage = {
id: string;
role: "system" | "user" | "assistant";
metadata: {
// Application-authored public metadata stays at the top level.
[applicationKey: string]: JsonValue;
pai: {
producer: "send" | "trigger" | "model" | "lifecycle" | "admin";
status: "open" | "committed" | "pending";
runId?: string;
relation?: "input" | "context" | "output";
createdAt: string;
updatedAt: string;
usage?: UsageSummary;
diagnostics?: MessageDiagnosticsRecord;
};
};
parts: PaiMessagePart[];
};metadata.pai is reserved. Application message metadata cannot author or
overwrite it.
The common consumer loop is a normal native-part switch:
const chat = AgentAI.useChat();
return chat.messages.map((message) => (
<article key={message.id}>
{message.parts.map((part, index) => {
if (part.type === "text") {
return <p key={index}>{part.text}</p>;
}
if (part.type === "reasoning") {
return <Reasoning key={index} text={part.text} summary={part.summary} />;
}
if (isPaiToolPart(part)) {
return (
<AgentAI.Tool
key={part.toolCallId}
message={message}
part={part}
/>
);
}
return null;
})}
</article>
));Native standard parts
PAI preserves AI SDK standard parts instead of translating them into PAI-specific records:
textandreasoning;source-urlandsource-document;fileand provider-defined custom content;- static
tool-${name}parts anddynamic-tool; - application-authored
data-${name}parts; and - provider metadata and tool metadata on their native owners.
Application data parts pass through unchanged and remain typed from the
agent's uiDataSchemas registry.
PAI consumes four reserved data-pai-* parts internally. Consumers see their
joined result:
- ToolData and suspension episodes enrich their native tool part;
- a reasoning summary becomes
reasoning.summary; and - a PAI file reference becomes a first-class
attachmentpart.
Raw reserved parts are not a second public API.
Tool parts
Tool calls use the AI SDK's state-discriminated native union. For a registered
getWeather tool, the part type is tool-getWeather; provider or
request-time dynamic tools use dynamic-tool.
if (part.type === "tool-getWeather") {
switch (part.state) {
case "input-streaming":
break;
case "input-available":
renderInput(part.input);
break;
case "output-available":
renderWeather(part.output);
break;
case "output-error":
renderError(part.errorText);
break;
}
}Native approval states remain visible when a provider emits them. PAI's
running, waiting, and cancelled concepts are presentation facts derived
from native state plus run and pending-action control state; they are not
written into the serializable tool part.
React tool renderers receive those two authorities separately:
type ToolRenderProps = {
part: PaiStaticToolPart | PaiDynamicToolPart;
runStatus: RunLifecycleStatus | null;
action: PendingActionView | null;
};part.state remains the provider/replay authority. runStatus is only for
UI presentation.
ToolData and suspension
ToolData is projected onto its owning tool by channel:
const latestProgress = part.data.progress?.at(-1);
if (latestProgress) {
renderProgress(latestProgress.value);
}Durable records survive refresh. Transient records are live-only overlays and
are removed by a retained replacement, completion, or snapshot repair.
Identity is (toolCallId, channel, id); a retained record with the same tuple
replaces the previous value without changing its position.
Arbitrary suspension history is also joined onto the tool:
part.suspensions; // every durable episode
part.pendingSuspension; // the current pending episode, or nullCommand methods are sidecars, not serialized message fields. Resolve the action for the exact message/tool pair:
const action = thread.getPendingAction(message, part);
if (action) {
await action.submit({ approved: true });
}This also covers unresolved client tools. Native AI SDK approval response commands are a separate future adoption; their native parts remain preserved today.
Streaming and refresh
An active assistant step is an ordinary message whose
metadata.pai.status is "open". The runtime emits native
UIMessageChunk values inside PAI's authorized, sequenced generation
envelope. Client and server reducers use the AI SDK's long-lived UI-message
reducer to assemble text, reasoning, tools, sources, files, metadata, and data
parts.
generation.started
generation.message.chunk: start
generation.message.chunk: text-start
generation.message.chunk: text-delta
generation.message.chunk: text-end
generation.message.chunk: finishA generation replay is authoritative for its model messages. On reconnect, the client resets those messages to trusted empty shells and rebuilds their parts from the replay, preventing checkpointed content and replayed chunks from being applied twice.
Durable storage keeps complete folded message snapshots, not the chunk log. Committed messages and checkpointed tool/data facts are the recovery authority; the runtime does not promise replay of tokens that were never checkpointed.
Model replay
Before the next provider call, PAI selects context-visible native messages,
materializes authorized attachment references without changing the stored
message, and delegates standard conversion to the AI SDK's
convertToModelMessages.
This preserves SDK behavior for text, reasoning, files, sources, provider
metadata, tool calls, provider-executed results, and application tool errors.
PAI does not store a tool-role transcript message merely because the model
converter may produce one for provider input.
A successful static tool whose application return value is undefined is
stored and replayed as JSON null, because native output-available
requires an output field and persisted messages are strict JSON.
Queued messages
Queued input is not part of ThreadState.messages until admission. Each queue
item carries the same complete projected user-message shape:
for (const item of state.queue.items) {
for (const message of item.messages) {
renderQueuedInput(message.parts);
}
}Admission moves the logical message into the transcript without converting its parts. This avoids fake queued or cancelled messages in ordinary conversation history.
Attachments
PAI-managed files appear publicly as:
type PaiAttachmentPart = {
type: "attachment";
id: string;
fileId: string;
mediaType: string;
filename?: string;
byteSize?: number;
metadata?: JsonObject;
};The durable native message stores the provider-neutral reference in a reserved data part. Before a model call, the configured file provider can expand one reference into one or more ordered model-content parts. The stored message is not rewritten with provider-specific URLs or bytes.
Visibility and private facts
Visibility, scope, and private metadata live in the thin storage envelope, not
inside the public UIMessage:
type MessageVisibility = {
transcript: boolean;
context: boolean;
};The runtime filters and authorizes envelopes before producing
ThreadState.messages. Applications never receive the storage wrapper.
Invariants
The runtime preserves these message invariants:
- one complete assistant message is created per model step;
- only the tail assistant message may remain open and mutable;
- queued inputs do not enter the transcript before admission;
- run-associated messages name an existing run and retain transcript order;
- standard persisted parts use the pinned AI SDK grammar;
- every schema-parsed value persisted in a message is a strict-JSON fixed point;
- native tool state is the serializable authority;
- pending commands and PAI display states remain derived sidecars;
- complete tool input and terminal output/error are durable and model-visible;
- storage and wire boundaries never accept the removed PAI part grammar; and
PaiMessage[]is the only ordinary consumer transcript collection.
For the internal storage envelope and reserved data-part contracts, see SDK-Native Messages.