PAIPAI

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 call

Message 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 answers

The 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:

  • text and reasoning;
  • source-url and source-document;
  • file and provider-defined custom content;
  • static tool-${name} parts and dynamic-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 attachment part.

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 null

Command 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: finish

A 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.

On this page