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 with streaming events, fails when replaying a saved session, and fails differently when inspecting a provider request. The difficulty is that a streaming message, a persisted session entry, a projected conversation, and a provider request are not interchangeable representations. 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.”
The same AgentMessage union appears on several surfaces, but that does not make the surrounding structures identical. An assistant message seen during streaming may be provisional; a persisted assistant message is finalized. A session entry also wraps the message with its own metadata, and request-time transformations can change what reaches the provider.
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.”
From the transcript to the request
Part III has followed a session through storage, branch selection, conversation projection, and provider requests. These are distinct stages, not four interchangeable views of a single object. The transformations between them are where the surprises live.
flowchart TD
E["persisted session entry<br/>JSONL, every branch, durable"] --> A["active branch<br/>entries up to the active leaf"]
A --> X["projected conversation<br/>compaction applied, edits applied"]
X --> C["request projection<br/>transformContext, convertToLlm"]
C --> P["provider request<br/>what the model received"]
X -.-> S["RPC get_messages<br/>projected conversation"]
P -.-> O["provider-request capture<br/>used in chapter 18 tests"]
Q["custom entry: persisted, excluded from model context;<br/>optionally rendered in the TUI"] -.-> E
Read the dashed edges as two ways of inspecting the state. The RPC get_messages command reports the session’s projected conversation, not necessarily the final provider request; chapter 27 examines the stored entries underneath it. A custom entry created by appendEntry() is persisted but excluded from the model’s context. It can be shown in the interactive transcript if an extension registers an entry renderer, as chapter 20 demonstrates; it is not automatically visible to the user.
The last arrow is the one that generates bugs. Compaction does not delete anything: the original entries stay in the file, but in the next request older projected messages are replaced by a summary and the retained suffix follows it, so model-visible history does not survive verbatim. Nothing on the dashed edges above the provider is what the model saw.
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 content-block types are shown below. Three carry provider-specific signature fields that applications should preserve without interpreting: textSignature, thinkingSignature, and thoughtSignature.
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 explicit about signature fields. textSignature contains provider-specific message metadata, and thinkingSignature contains provider-specific replay data. ToolCall.thoughtSignature is likewise a provider-specific signature. Preserve these values when passing messages through; do not treat them as portable, human-readable facts.
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[];
timestamp: number;
}
This is the one role that is not a single fact about the conversation. message-types.md says: “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.”
Two mechanics in one type: sections is a named map where a null value removes a section, and toolsAdded and toolsRemoved patch the tool set without redeclaring it.
A field the prose describes and the declaration does not have. In message-types.md the SystemMessage listing includes replace?: boolean, and the text says a message with replace: true discards the earlier state and establishes a new baseline. The exported 1.0.4 declaration of SystemMessage in pi-ai has no replace field, and a search of the shipped JavaScript for the replay logic found no code that reads one. This book’s rule is that the declaration wins, so the interface above omits it, and nothing in this book relies on it. If you need to know whether a full reset is possible on your release, check the declaration and test it, rather than the prose. The mismatch was reported upstream as earendil-works/pi#10462, framed as a question (intended API missing from the type, or stale documentation) and not as a bug.
The practical consequence: you cannot reliably reconstruct the current prompt from one arbitrary system message. Replay the applicable system messages in order along the active session history, respecting any complete system checkpoint at a compaction boundary. 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;
thinkingLevel?: ModelThinkingLevel;
diagnostics?: AssistantMessageDiagnostic[];
usage: Usage;
stopReason: "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";
deferred?: DeferredHandle;
errorMessage?: string;
rawStopReason?: string;
endTurn?: boolean;
timestamp: number;
}
Several fields identify a response, and they answer different questions. api identifies the API format, provider names the provider, and model names the model recorded for the message. The optional responseModel records a concrete provider response model when it differs. For reporting by responding model, use responseModel ?? model rather than assuming responseModel is always populated; usage and cost still need to be attributed separately.
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; record them for diagnosis without assuming their values are portable across providers. endTurn is optional, so its absence does not establish whether the turn is complete. Interpret it together with the finalized stopReason and the agent’s lifecycle rather than treating it as an unconditional completion signal.
"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 = JsonValue> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: TDetails;
usage?: Usage;
nestedCalls?: NestedToolCalls;
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 agent’s model-call usage. Include this usage when calculating total model work, but do not add the same nested usage again if it has already been rolled up into a parent tool result. It also does not measure the token count of the main agent’s current context; nested model calls can have contexts of their own.
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 suppresses the custom message’s normal terminal display without excluding its content from model requests. That is the inverse of the custom session entry in chapter 19: a custom entry is not projected into model context but can be displayed by a registered entry renderer. Neither setting makes the underlying information secret from someone who can inspect the session file.
BranchSummaryMessage carries a summary and a fromId that is string | null. CompactionSummaryMessage carries a summary and a tokensBefore. Pi constructs these projected message shapes from stored branch_summary and compaction entries. Extensions can supply the text of a summary, as chapter 23 showed, but that does not make the summary message an ordinary user or tool message. The session projection converts branch_summary to branchSummary and places compactionSummary after the compaction’s system checkpoint.
Mapping roles to entries
The conversion is worth stating plainly, because it is where the four surfaces differ:
| Session entry | Contribution to the session’s projected conversation |
|---|---|
message |
A stored AgentMessage, subject to branch selection, compaction, and context edits |
compaction |
A system checkpoint and compactionSummary projection |
branch_summary |
A branchSummary projection |
custom_message |
A CustomMessage projection, normally converted to user-role content for the model |
context_edit |
Alters another entry’s projected contribution; adds no message of its own |
usage |
Usage accounting, not conversation content |
custom |
Extension state, not conversation content |
This table describes the projection before any request-local context transformations and provider conversion. It is not a promise that each entry becomes a provider message. context_edit modifies another entry’s contribution without adding its own message. usage entries matter when accounting for model work outside assistant messages; custom entries do not contribute to model usage totals merely because they are persisted. Neither is sent to the model as conversation content.
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.
Treat the exported types of the Pi version you are running as the contract; the declarations shown here are a reading guide, and the SystemMessage.replace mismatch illustrates why checking the release matters. The next chapter unwraps the session-entry layer around an AgentMessage: the entry’s metadata, the active branch, compaction, and context edits all affect what is reconstructed and eventually sent to the model.