Message Types
The eight roles AgentMessage covers, the content blocks they share, and which fields are authoritative — including the one timestamp type that catches everyone.
You write an extension that renders tool results. It works on the streaming events, fails on the saved session, and fails differently on the model request. The three surfaces use the same types — but not the same values, and one field in particular is not what its name suggests.
This chapter is the reference for the shapes themselves.
One union, four surfaces
“Pi uses AgentMessage values in SDK state, lifecycle events, RPC responses, and persisted session message entries.”
Four surfaces, one type. That is why a message that renders correctly while streaming can be wrong on disk: the streaming value is provisional, the persisted one is final.
In the coding agent the union is equivalent to:
type AgentMessage =
| SystemMessage
| UserMessage
| AssistantMessage
| ToolResultMessage
| BashExecutionMessage
| CustomMessage
| BranchSummaryMessage
| CompactionSummaryMessage;
“The coding-agent package extends AgentMessage with four roles.” Four — BashExecutionMessage, CustomMessage, BranchSummaryMessage, CompactionSummaryMessage. The other four come from the base packages.
And one caveat for anything that consumes messages from someone else’s host: “Applications can add roles through TypeScript declaration merging, so consumers should tolerate unknown custom roles when they accept messages from an augmented host.”
The timestamp trap
Read this twice, because it is the most common bug in code that reads sessions.
Wrong: “The entry’s timestamp and the message’s timestamp are the same field.”
Correct: “Message timestamps are Unix timestamps in milliseconds. They are different from the ISO 8601 timestamps on session entries.”
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234401000}}
The entry’s timestamp is "2024-12-03T14:00:01.000Z". The message’s nested timestamp is 1733234401000. Same instant, two representations, same field name. Sort on the wrong one and you get NaN or an ordering that looks fine until it does not.
Content blocks
Four blocks, and two of them carry fields you must not interpret:
interface TextContent { type: "text"; text: string; textSignature?: string; }
interface ImageContent { type: "image"; data: string; mimeType: string; }
interface ThinkingContent { type: "thinking"; thinking: string; thinkingSignature?: string; redacted?: boolean; }
interface ToolCall { type: "toolCall"; id: string; name: string; arguments: Record<string, any>; thoughtSignature?: string; namespace?: string; }
ImageContent.data is base64-encoded. ToolCall.namespace “identifies an OpenAI Responses namespace for dynamically loaded or namespaced tools.”
The documentation is unusually clear about the signature fields, and the reason matters. On textSignature: “contains provider-specific message metadata. Treat it as opaque.” On thinking: “Thinking signatures contain provider-specific replay data. Treat them as opaque.”
Opaque means round-trip it and never branch on it. A redacted thinking block is the concrete case: “A redacted block can have no visible thinking text while retaining an encrypted payload in thinkingSignature.” So an empty thinking string is not a signal that nothing happened.
Usage
interface Usage {
input: number; output: number; cacheRead: number; cacheWrite: number;
cacheWrite1h?: number; reasoning?: number; totalTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number; };
}
Two rules that will save you a wrong total. “When present, reasoning is already included in output; do not add it again.” And “cacheWrite1h is the subset of cacheWrite written with one-hour retention.”
So reasoning is a breakdown of output, not an addition to it, and cacheWrite1h is a subset of cacheWrite. Both are safe to display; neither is safe to sum.
“Assistant messages always contain usage. Tool results can contain usage when the tool performed nested model work.”
SystemMessage
interface SystemMessage {
role: "system";
content: string | TextContent[];
sections?: Record<string, string | null>;
toolsAdded?: Tool[];
toolsRemoved?: ToolReference[];
replace?: boolean;
timestamp: number;
}
This is the one role that is not a single fact about the conversation. “The leading system message declares the initial prompt and tools. Later system messages can append instructions, replace or remove named prompt sections, and add or remove tools. Replaying them in order yields the current state. A message with replace: true discards the earlier state and establishes a complete new baseline.”
Three mechanics in one type: sections is a named map where a null value removes a section; toolsAdded and toolsRemoved patch the tool set without redeclaring it; replace: true throws the accumulated state away.
The practical consequence: you cannot read the current prompt from one system message. You replay them all, in order. session-format.md confirms “there is no separate prompt state entry.”
{"type":"message","id":"a0b1c2d3","parentId":null,"timestamp":"2024-12-03T14:00:00.000Z","message":{"role":"system","content":"","sections":{"preamble":"You are an expert coding assistant...","tools":"<tools>\n- read: ...\n</tools>","cwd":"/project"},"toolsAdded":[{"name":"read","description":"...","parameters":{}}],"timestamp":1733234400000}}
“Sessions created before system messages existed have no leading system message; the first request declares the current prompt as a later system message, which replays the same way.” A parser that assumes entry one is the prompt will break on old sessions.
UserMessage
interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}
The string-or-array union is not cosmetic — it is the shape of the data you are given, not a choice. A user message with an image is an array; a plain prompt is often a bare string.
AssistantMessage
The heaviest type, and the one with the most fields that are not interchangeable.
interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: string;
provider: string;
model: string;
responseModel?: string;
responseId?: string;
providerThinkingLevel?: string;
diagnostics?: AssistantMessageDiagnostic[];
usage: Usage;
stopReason: "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";
deferred?: DeferredHandle;
errorMessage?: string;
rawStopReason?: string;
endTurn?: boolean;
timestamp: number;
}
The model identity is three-way. model is what was requested; responseModel “records a concrete provider response model when it differs from the requested model.” If you are counting spend per model, responseModel is the field that tells you what actually answered.
stopReason has seven values, and two of them mean the response is not finished. "pending" is “used for a partial assistant message while it streams,” and “Pi does not persist "pending" assistant messages in session JSONL” — so a renderer that persists what it sees writes a value the reader is not prepared for. "deferred" means the provider has not answered, and deferred carries a DeferredHandle with provider, modelId, api, id, optional expiresAt, pollAfterMs and data for retrieving it. The remaining five are terminal stop reasons: "stop", "length", "toolUse", and the failure pair "error" and "aborted".
rawStopReason and diagnostics “preserve provider or runtime details” — again opaque, again worth logging and never worth branching on. endTurn is the field that says the model considers the turn finished.
"length" deserves attention because it has consequences beyond the message. compaction.md: “A provider context-overflow error or an early final stopReason: "length" can select one compact-and-retry recovery attempt.”
ToolResultMessage
interface ToolResultMessage<TDetails = any> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: TDetails;
usage?: Usage;
isError: boolean;
timestamp: number;
}
toolCallId is the join key back to the ToolCall. details is tool-specific and is where extension state belongs when it should follow the active branch — extensions.md puts it in its state table as “Tool state that follows the active branch.”
usage here is special: it “reports nested model work performed by the tool and contributes to full-session statistics, but it is not part of the main model-call usage.” So when you sum usage for billing, include it. When you display “tokens in context,” do not — it was never in the context.
isError is a field, not an exception. extensions.md: “Throw from execute() to produce a failed tool result” and, for the case where you want both an error and data, “return the result with isError: true instead of throwing: the model sees an error, and scripts still receive structuredContent.”
The four coding-agent roles
BashExecutionMessage — “Created by direct shell commands, including the RPC bash command. It is not an LLM tool result.” Its fields are command, output, exitCode, cancelled, truncated, optional fullOutputPath, optional excludeFromContext, and a timestamp.
Two of those matter. excludeFromContext is a real switch — “Unless excludeFromContext is true, Pi converts this message to user-role text before the next model request.” And truncated plus fullOutputPath mean output is not necessarily the whole thing. Chapter 9’s !! prefix is the user-facing form of the same idea.
CustomMessage — “Created when an extension sends a context message.” It carries customType, a content union, a display boolean, optional details, and a timestamp. “Pi converts its content to a user message for model requests. display controls terminal rendering; details is not sent to the model.”
So display: false is invisible to the user but present to the model — the inverse of the custom entry type from chapter 19, which is invisible to the model and renderable by an entry renderer.
BranchSummaryMessage carries a summary and a fromId that is string | null. CompactionSummaryMessage carries a summary and a tokensBefore. Both are Pi-generated, and neither is constructed by anything you write: they exist so that a compaction or branch_summary entry can become a message in the model context — the conversion step session-format.md describes as compaction → “complete system checkpoint followed by compactionSummary” and branch_summary → branchSummary.
Mapping roles to entries
The conversion is worth stating plainly, because it is where the four surfaces differ:
| Session entry | Message the model receives |
|---|---|
message |
the stored AgentMessage |
compaction |
system checkpoint, then compactionSummary |
branch_summary |
branchSummary |
custom_message |
CustomMessage |
context_edit |
nothing of its own |
usage, custom |
nothing |
context_edit is the row that misleads: no message of its own, because it modifies another entry’s contribution. usage and custom contribute nothing at all — which is why you need both to bill from a session file and neither to reconstruct the conversation.
Writing a handler against this
Three rules that follow from the definitions. Narrow on role before reading fields — an AssistantMessage has no content string. Treat stopReason "pending" as an implementation state, not a persisted one. And branch on model or responseModel deliberately, because they are different questions.
The types above are the contract. The next chapter unwraps one level, because the same AgentMessage value stops being the same thing the moment it becomes a line in an append-only file — where compaction and context edits decide what the model actually receives.