Message Types in Claude Code Transcripts¶
See application_model.md for the system overview.
This document describes all message types found in Claude Code JSONL transcript files and their corresponding output representations. The goal is to define an intermediate representation that captures the logical message structure independent of HTML rendering.
Overview¶
Claude Code transcripts contain messages in JSONL format. Each line represents an input message that gets transformed through:
- Input Layer (JSONL): Raw Claude Code transcript data
- Intermediate Layer (TemplateMessage): Format-neutral logical representation
- Output Layer (HTML): Rendered visual output
This document maps input types to their intermediate and output representations.
Data Flow: From Transcript Entries to Rendered Messages¶
JSONL Parsing (parser.py)
│
├── UserTranscriptEntry
│ ├── TextContent → User message variants:
│ │ ├── UserSlashCommandMessage (isMeta) or SlashCommandMessage (<command-name> tags)
│ │ ├── CommandOutputMessage (<local-command-stdout> tags)
│ │ ├── BashInputMessage (<bash-input> tags)
│ │ ├── CompactedSummaryMessage (compacted conversation)
│ │ ├── UserSteeringMessage (queue-operation "remove")
│ │ └── Plain user text
│ ├── ToolResultContent → ToolResultMessage with output:
│ │ ├── ReadOutput (cat-n formatted file content)
│ │ ├── WriteOutput / DeleteOutput (status acknowledgment)
│ │ ├── ToolExecutionOutput (status + labelled opaque emissions)
│ │ ├── EditOutput (cat-n formatted edit result)
│ │ └── ToolResultContent (generic fallback)
│ └── ImageContent → Image messages
│
├── AssistantTranscriptEntry
│ ├── TextContent → AssistantTextMessage
│ ├── ThinkingContent → ThinkingMessage
│ └── ToolUseContent → ToolUseMessage with parsed inputs:
│ ├── ReadInput, WriteInput, DeleteInput, EditInput, MultiEditInput
│ ├── BashInput, GlobInput, GrepInput
│ ├── TaskInput, TodoWriteInput, AskUserQuestionInput
│ └── ExitPlanModeInput, ToolExecutionInput
│
├── SystemTranscriptEntry
│ ├── SystemMessage (level: info/warning/error)
│ ├── HookSummaryMessage (subtype: stop_hook_summary)
│ └── AwaySummaryMessage (subtype: away_summary)
│
├── SummaryTranscriptEntry → Session metadata (not rendered)
│
└── QueueOperationTranscriptEntry
└── "remove" operation → UserSteeringMessage (rendered as user)
Intermediate Representation: TemplateMessage¶
The intermediate representation is TemplateMessage, a Python class (in renderer.py) that captures all fields needed for rendering.
Key Fields¶
class TemplateMessage:
# Identity
type: str # Base type: "user", "assistant", "tool_use", etc.
message_id: str # Unique ID within session (e.g., "msg-0", "tool-1")
uuid: str # Original JSONL uuid
# Content (format-neutral)
content: Optional[MessageContent] # Structured content model
# Note: HTML is generated during template rendering, not stored in the message
# Display
message_title: str # Display title (e.g., "User", "Assistant")
is_sidechain: bool # Sub-agent message flag (via content.meta)
# Note: has_markdown is accessed via content.has_markdown
# Note: CSS classes are derived from content type via CSS_CLASS_REGISTRY
# Metadata
raw_timestamp: str # ISO 8601 timestamp
session_id: str # Session UUID
# Hierarchy
children: List[TemplateMessage] # Child messages (tree mode)
ancestry: List[str] # Parent message IDs for fold/unfold
# Pairing
is_paired: bool # True if part of a pair
pair_role: Optional[str] # "pair_first", "pair_last", "pair_middle"
# Tool-specific
tool_use_id: Optional[str] # ID linking tool_use to tool_result
Content Type → CSS Classes¶
CSS classes are derived from the content type using CSS_CLASS_REGISTRY (in html/utils.py). This ensures the content type is the single source of truth for display styling.
| css_class | Content Type | Dynamic Modifier |
|---|---|---|
"user" |
UserTextMessage |
— |
"user compacted" |
CompactedSummaryMessage |
— |
"user slash-command" |
SlashCommandMessage, UserSlashCommandMessage |
— |
"user command-output" |
CommandOutputMessage |
— |
"user steering" |
UserSteeringMessage |
— |
"assistant" |
AssistantTextMessage |
— |
"tool_use" |
ToolUseMessage |
— |
"tool_result" |
ToolResultMessage |
— |
"tool_result error" |
ToolResultMessage |
is_error=True |
"thinking" |
ThinkingMessage |
— |
"bash-input" |
BashInputMessage |
— |
"bash-output" |
BashOutputMessage |
— |
"system system-info" |
SystemMessage |
level="info" |
"system system-warning" |
SystemMessage |
level="warning" |
"system system-error" |
SystemMessage |
level="error" |
"system system-hook" |
HookSummaryMessage |
— |
"system system-away-summary" |
AwaySummaryMessage |
— |
The sidechain modifier is added when msg.is_sidechain=True (a cross-cutting concern that applies to any message type).
Note: See css-classes.md for complete CSS support status.
Part 1: User Messages (UserTranscriptEntry)¶
User transcript entries (type: "user") contain human input, tool results, and images.
1.1 Content Types in User Messages¶
User messages contain ContentItem instances that are either:
- TextContent: User-typed text (with various semantic variants)
- ToolResultContent: Results from tool execution
- ImageContent: User-attached images
1.2 User Text Variants¶
Based on flags and tag patterns in TextContent, user text messages are classified into specialized content types defined in models.py.
Regular User Prompt¶
- Condition: No special flags or tags
- Content Model: Plain
TextContent - CSS Class:
user - Files: user.json | user.jsonl
{
"type": "user",
"message": {
"role": "user",
"content": [{ "type": "text", "text": "Help me fix this bug..." }]
},
"isSidechain": false
}
Slash Command (isMeta)¶
- Condition:
isMeta: trueflag - Content Model:
UserSlashCommandMessage(models.py) - CSS Class:
user slash-command - Files: user_slash_command.json
{
"type": "user",
"message": { "content": "Caveat: The messages below were generated..." },
"isMeta": true
}
@dataclass
class UserSlashCommandMessage(MessageContent):
text: str # LLM-generated markdown instruction text
Note: These are LLM-generated instruction prompts from slash commands. The text is markdown formatted and rendered as collapsible markdown.
Slash Command (Tags)¶
- Condition: Contains
<command-name>tags - Content Model:
SlashCommandMessagewith parsed name/args/contents - CSS Class:
user slash-command - Files: user_command.json
@dataclass
class SlashCommandMessage(MessageContent):
command_name: str # e.g., "/model", "/context"
command_args: str # Arguments after command
command_contents: str # Content inside command
Note: Both built-in commands (e.g.,
/init,/model,/context) and user-defined commands (e.g.,/my-commandfrom~/.claude/commands/my-command.md) use the same<command-name>tag format. There is no field in the JSONL to differentiate between them.
Command Output¶
- Condition: Contains
<local-command-stdout>tags - Content Model:
CommandOutputMessage - CSS Class:
user command-output - Files: command_output.json
@dataclass
class CommandOutputMessage(MessageContent):
stdout: str # Command output text
is_markdown: bool # True if content appears to be markdown
Bash Input¶
- Condition: Contains
<bash-input>tags - Content Model:
BashInputMessage - CSS Class:
bash-input(filtered by User) - Files: bash_input.json
@dataclass
class BashInputMessage(MessageContent):
command: str # The bash command that was executed
Bash Output¶
The corresponding output uses <bash-stdout> and optionally <bash-stderr> tags:
- Condition: Contains
<bash-stdout>tags - Content Model:
BashOutputMessage - CSS Class:
bash-output(filtered by User) - Files: bash_output.json
Compacted Conversation¶
- Condition: Contains "(compacted conversation)" marker
- Content Model:
CompactedSummaryMessage - CSS Class:
user compacted
@dataclass
class CompactedSummaryMessage(MessageContent):
summary_text: str # The compacted conversation summary
User Steering (Queue Remove / queued_command)¶
- Condition (two sources, by harness era):
- Modern (Claude Code ≳2.1.101): a
type: "attachment"/attachment.type: "queued_command"entry — the preferred record, as it is in-DAG and carriesuuid/parentUuid(a debug line the legacyremovelacks). Promoted infactories/attachment_factory.py. - Legacy (≤~2.1.29): a
QueueOperationTranscriptEntrywithoperation: "remove"— chain-less, uuid-less. - Content Model:
UserSteeringMessage(extendsUserTextMessage) - CSS Class:
user steering - Title: "User (steering)"
@dataclass
class UserSteeringMessage(UserTextMessage):
"""Message for user steering prompts (queue-operation 'remove')."""
pass # Inherits items from UserTextMessage
Steering messages represent user input injected mid-turn to steer an ongoing assistant response.
One delivery, two records, one card. A modern steering delivery writes
both a queued_command attachment and a legacy remove op, normally 1:1.
The renderer emits the attachment and suppresses the paired remove, so a
reader sees one card rather than a duplicate. Suppression is content-keyed and
budgeted per (session, version, prompt-text); the mechanics, and why each
part of that key is needed, are commented at the pre-pass in renderer.py.
What the reader sees when pairing does not work out is the part worth stating
here: nothing is ever dropped. A remove with no matching card renders as
legacy steering, and a card that renders but cannot be paired leaves its
remove visible rather than suppressing it on a match that cannot be shown.
A non-pairable card always warns; an unmatched remove warns only where that
session and version did produce a counted card — with no card there is nothing
for it to be imbalanced against, so a legacy-only transcript stays silent — and
then only once per (session, version).
Prompt shapes. attachment.prompt is normally a plain string, but a
steering delivery carrying an image is written as a list of content blocks
([{"type": "text", …}, {"type": "image", …}]). Both shapes normalize through
attachment_factory.queued_command_prompt_items, which the renderer's pre-pass
shares, and any other shape renders from its str() rather than vanishing.
Which shapes can be paired — and why an image-only list or a text-bearing
dict cannot — is documented on that function.
Transformer pass. A queued_command prompt goes through the same
create_user_message classification and plugin-transformer pass as an
idle-delivered prompt, so a [monitor] … steering injection renders as the
same demoted marker as its non-steering siblings, and only a plain
UserTextMessage is wrapped as UserSteeringMessage.
Null-content era (≤2.1.202). Older harnesses wrote the paired remove with
content: null — the steering text lived only in the queued_command
attachment. Rendering steering from the attachment is therefore not just a
de-duplication nicety but the only path that surfaces steering text at all on
such transcripts. Null-content removes are dropped before the suppression pass,
so they neither consume budget nor trip its warning.
Measured over one machine's full local corpus on 2026-07-28 (5606 files, 4658 removes): every version ≤ 2.1.202 wrote null content (3836 removes) and every version ≥ 2.1.205 wrote text (822 removes), with no version producing both. Two limits on that, because it is still a sample:
- The changeover is located to an interval, not a point. It lies in
(2.1.202, 2.1.205], and the versions in between cannot narrow it here:
2.1.203 does not appear in this corpus at all, and 2.1.204 appears (268
entries) but produced no
removeops. "Sharp" describes the observed versions, not a verified single release. - The floor is a sampling floor. 2.0.55 is the oldest version present and
2.0.73 the oldest carrying a
remove— oldest available, not oldest existing. Nothing here shows what pre-2.0 harnesses wrote.
Within those limits: on current harnesses the remove does carry its text and
the content-keyed pairing is meaningful, and null-content is the legacy shape
rather than the modern one. (An earlier revision of this page put the
null-content era at "≥~2.1.187" — backwards, from a sample that happened to
start there. Stating the bound as a dated interval is the guard against this
correction hardening the same way.)
User Memory¶
- Condition: Contains
<user-memory-input>tags - Content Model:
UserMemoryMessage - CSS Class:
user
@dataclass
class UserMemoryMessage(MessageContent):
memory_text: str # The memory content from the tag
Sidechain User (Sub-agent)¶
- Condition:
isSidechain: true - CSS Class:
user sidechain - Note: Typically skipped during rendering (duplicates Task prompt)
- Files: user_sidechain.json
Async Task Notification (<task-notification>)¶
When an async-spawned Task (with run_in_background=True) completes,
Claude Code injects a synthetic User entry whose message.content is
a raw <task-notification>…</task-notification> block.
task_notification_factory.create_task_notification_message parses
the embedded fields into a structured content model.
- Condition:
message.contentis a string containing<task-notification>…</task-notification> - Content Model:
TaskNotificationMessage(aMessageContentsubclass — not aUserTextMessagevariant) - CSS Classes:
user task_notification(theuserclass is what keeps the runtime "User" filter toggle showing the card)
@dataclass
class TaskNotificationMessage(MessageContent):
task_id: str
status: str # "completed", "failed", …
summary: str # Agent "<description>" completed
result_text: str # the agent's final answer body
usage: Optional[TaskNotificationUsage]
transcript_path: Optional[str] # "Full transcript available at: …"
raw_text: Optional[str]
# Phase 3 dedup markers
result_is_duplicate: bool = False
spawning_task_message_index: Optional[int] = None
The result_text is the canonical source for the spawn-fold —
_link_async_notifications copies it onto
TaskOutput.async_final_answer of the spawning Task tool_result so
the answer renders in place. See
agents.md § 2 (Async task agents)
for the end-to-end flow and depth matrix.
IDE Notifications¶
User messages may contain IDE notification tags that are parsed into structured content:
- Condition: Contains
<ide_opened_file>,<ide_selection>, or<ide_diagnostics>tags - Content Model:
IdeNotificationContentcontaining lists of: IdeOpenedFile: File open notificationsIdeSelection: Code selection notificationsIdeDiagnostic: Diagnostic messages (parsed JSON or raw text fallback)- CSS Class: Notifications rendered as inline elements within user message
@dataclass
class IdeOpenedFile:
content: str # Raw content from the tag
@dataclass
class IdeSelection:
content: str # Raw selection content
@dataclass
class IdeDiagnostic:
diagnostics: Optional[List[Dict[str, Any]]] # Parsed JSON
raw_content: Optional[str] # Fallback if parsing failed
@dataclass
class IdeNotificationContent: # NOT a MessageContent subclass
"""Embedded within UserTextMessage.items alongside TextContent/ImageContent."""
opened_files: List[IdeOpenedFile]
selections: List[IdeSelection]
diagnostics: List[IdeDiagnostic]
remaining_text: str # Text after notifications extracted
1.3 Tool Results (ToolResultContent)¶
Tool results appear as ToolResultContent items in user messages, linked to their corresponding ToolUseContent via tool_use_id.
Tool Result Output Models¶
| Tool | Output Model | Key Fields | Files |
|---|---|---|---|
| Read | ReadOutput |
file_path, content, start_line, num_lines, is_truncated | tool_result |
| Edit | EditOutput |
file_path, success, diffs, message, start_line | tool_result |
| Write | WriteOutput |
file_path, success, message | tool_result |
| Bash | BashOutput |
content, has_ansi | tool_result |
| Task | TaskOutput (see note) |
result, metadata, async_final_answer | tool_result |
| TaskOutput | TaskOutputResult (see note) |
retrieval_status, task_id, task_type, status, output_truncated, output_file | (async-agent polling tool — issue #90) |
| TaskStop | TaskStopOutput |
stopped, message | (kills a background task by id — PR #158 follow-up) |
| AskUserQuestion | AskUserQuestionOutput |
answers, raw_message | tool_result |
| ExitPlanMode | ExitPlanModeOutput |
message, approved | tool_result |
| Glob | GlobOutput (TODO) |
pattern, files, truncated | tool_result |
| Grep | GrepOutput (TODO) |
pattern, matches, output_mode, truncated | tool_result |
| (error) | — | is_error: true | Bash error |
(TODO): Glob and Grep output models defined in models.py but not yet used.
Note on TaskOutput vs TaskOutputResult: two unrelated dataclasses with overlapping names.
TaskOutput is the parsed output of the Task tool_result (carries the launch stub or final
answer text, agent metadata, and the Phase 3 async_final_answer field).
TaskOutputResult is the parsed output of the TaskOutput polling tool's tool_result (the
<retrieval_status>/<task_id>/<status>/<output>[Truncated…] body that the assistant explicitly
polls between an async-Task launch and its completion notification). The fold writes into
TaskOutput.async_final_answer — i.e. the Task tool_result, not the polling tool's result. See
agents.md § 2.2.
Generic Tool Result¶
- CSS Class:
tool_result - Content: Raw string or structured content
{
"type": "user",
"message": {
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_xxx",
"is_error": false,
"content": "..."
}]
}
}
Tool Result Error¶
- Condition:
is_error: true - CSS Class:
tool_result error - Files: Bash-tool_result_error.json
Read Tool Result → ReadOutput¶
Read tool results in cat-n format are parsed into structured ReadOutput:
- Files: Read-tool_result.json
@dataclass
class ReadOutput(MessageContent):
file_path: str
content: str # File content (may be truncated)
start_line: int # 1-based starting line number
num_lines: int # Number of lines in content
total_lines: int # Total lines in file
is_truncated: bool
system_reminder: Optional[str] # Embedded system reminder
Edit Tool Result → EditOutput¶
Edit tool results with cat-n snippets are parsed into structured EditOutput:
- Files: Edit-tool_result.json
@dataclass
class EditOutput(MessageContent):
file_path: str
success: bool
diffs: List[EditDiff] # Changes made
message: str # Result message or code snippet
start_line: int # Starting line for display
Tool Result Rendering Wrapper¶
Tool results are wrapped in ToolResultMessage for rendering, which provides additional context and typed output:
@dataclass
class ToolResultMessage(MessageContent):
tool_use_id: str
output: ToolOutput # Specialized output or ToolResultContent fallback
is_error: bool = False
tool_name: Optional[str] = None # Name of the tool
file_path: Optional[str] = None # File path for Read/Write/Delete/Edit
# ToolOutput is a union type for tool results
ToolOutput = Union[
ReadOutput,
WriteOutput,
DeleteOutput,
ToolExecutionOutput,
EditOutput,
BashOutput,
TaskOutput,
AskUserQuestionOutput,
ExitPlanModeOutput,
ToolResultContent, # Generic fallback for unparsed results
]
1.4 Images (ImageContent)¶
- CSS Class:
image - Files: image.json
{
"type": "user",
"message": {
"content": [{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo..."
}
}]
}
}
Image data is structured using ImageSource:
class ImageSource(BaseModel):
type: Literal["base64"]
media_type: str # e.g., "image/png"
data: str # Base64-encoded image data
class ImageContent(BaseModel, MessageContent):
type: Literal["image"]
source: ImageSource
[Image #N] placeholders and imagePasteIds¶
A pasted image leaves an [Image #N] placeholder in the surrounding text and
its bytes in a separate content block. The association between the two is
recorded on the entry, as a sibling of message:
{
"type": "user",
"imagePasteIds": [4, 6],
"message": {"content": [
{"type": "image", "source": {"...": "the block for [Image #4]"}},
{"type": "image", "source": {"...": "the block for [Image #6]"}},
{"type": "text", "text": "compare [Image #6] with [Image #4]"}
]}
}
The list runs parallel to the image blocks, so [Image #N] is the block at
imagePasteIds.index(N).
N is a paste counter, not a position. It resets when the CLI restarts
inside a session that outlives it, and it increments on delete-and-repaste —
hence non-contiguous runs like [4, 6, 8], numbers far above the block count,
and the same N naming different images at different points in one session.
Nothing may therefore be keyed at session scope: the per-message list is both
necessary and sufficient.
Reading N as a 1-based index into the blocks — what the renderer did before this was modelled — fails three ways, in rising order of how hard it is to spot: the placeholder stays literal and the image appends detached (N above the block count); a block is dropped; or a different image renders where the placeholder sat, with nothing in the output to mark it wrong.
Resolution lives in factories/user_factory.py
(_image_reference_mapping), and both halves of the rendering share one
mapping: the inliner takes blocks by number, and whatever it did not take is
appended in place. A block can therefore never be inlined and appended, nor
dropped by a pass that never rendered it — which is what a placeholder buried
in a <system-reminder> or an IDE-notification prefix used to cause.
An up-front scan of the raw item text does survive, but only to decide whether an absent-field message's numbering is contiguous, and the asymmetry is deliberate: a raw scan can only widen the set of numbers, a wider set can only make contiguity harder to satisfy, so it can over-fail-close and never over-substitute. Using post-split text there would be the unsafe choice — a hidden placeholder would be invisible to the contiguity check while still being a real reference. Consumption deliberately does not use that scan, and that asymmetry is the fix.
When the association cannot be established the placeholder renders literally, the block stays detached, and a warning names the condition — an unknown paste id, lists that are not parallel, a repeated id, a malformed field. A visible gap beats a confident substitution. The model field is deliberately untyped so a malformed value reaches that report rather than failing validation and costing the whole entry.
Old transcripts carry no imagePasteIds, so position is the only evidence
available there. It is used only when the numbering is 1..k with no gaps and
no more numbers than blocks.
Why that condition, stated correctly. Not "a reset counter cannot produce
1..k" — one that has just reset produces exactly that. The condition is
chosen because on that shape the two readings coincide, so the renderer
does not have to know which regime produced the numbering. Paste ids are
distinct, >= 1, and recorded in ascending block order; a list containing
1..k must therefore hold them in its first k slots, which makes
ids.index(N) and N-1 the same answer. A gap, or a number above the block
count, breaks the coincidence — and those fail closed.
The ascending premise is measured, not guaranteed by the format: 180/180 distinct messages carrying the field have strictly ascending ids, no duplicates, none below 1 (2026-07-28 archive). Scored the other way — pretend the field were absent and run the fallback against the recorded ids as ground truth — it fires on 24 of the 110 placeholder-bearing messages, refuses the other 86, and is correct on all 24. That is evidence from the era that has the field; the era where the fallback actually applies has no ground truth by construction, which is the limit of the argument.
What this does NOT cover: a chunk-split record.
chunk_message_content gives tool_use / tool_result / thinking blocks
their own chunks, so a record whose image block and referencing text are
separated by one of those yields a text segment that sees no image blocks.
The placeholder then stays literal and the block is appended from its own
chunk — on this renderer and on every renderer before it, because both refuse
for the same underlying reason: you cannot reference a block that is not in
the list you were handed. Two independent mechanisms produce that refusal (an
early return on the empty list, and the block-count bounds), which is why no
test pins it — each shields the other from mutation. Zero records in a
5,606-file archive have image blocks and a special block, so this is a
property to preserve rather than a behaviour anyone relies on.
How old is "old" — measured, with its limits. In one real archive scanned
on 2026-07-28 (5,606 transcript files), every image-bearing message that
contained an [Image #N] placeholder carried the field, across versions
2.1.84–2.1.220; the oldest records lacking it are twenty at 2.0.73–2.0.74,
none of which contains a placeholder. That does not pin an introduction
version: both shapes occur within 2.0.74, and the archive's sampling floor
for image-bearing records is 2.0.73. The repo's own test/test_data fixtures
at 1.0.31, 1.0.43 and 1.0.128 do carry placeholders with no field, which is
what keeps the positional path live rather than historical.
Part 2: Assistant Messages (AssistantTranscriptEntry)¶
Assistant transcript entries (type: "assistant") contain Claude's responses.
2.1 Content Types in Assistant Messages¶
Assistant messages contain ContentItem instances that are:
- TextContent: Claude's text response
- ThinkingContent: Extended thinking blocks
- ToolUseContent: Tool invocations
2.2 Assistant Text → AssistantTextMessage¶
- Content Model:
AssistantTextMessage(models.py) - CSS Class:
assistant(orassistant sidechain) - Files: assistant.json
@dataclass
class AssistantTextMessage(MessageContent):
items: list[TextContent | ImageContent] # Interleaved text and images
token_usage: Optional[str] # Formatted token usage string
Sidechain Assistant¶
- Condition:
isSidechain: true - CSS Class:
assistant sidechain - Title: "Sub-assistant"
- Files: assistant_sidechain.json
2.3 Thinking Content → ThinkingMessage¶
- Content Model:
ThinkingMessage(models.py) - CSS Class:
thinking - Files: thinking.json
@dataclass
class ThinkingMessage(MessageContent):
thinking: str # The thinking text
signature: Optional[str] # Thinking block signature
token_usage: Optional[str] # Formatted token usage string
{
"type": "assistant",
"message": {
"content": [{ "type": "thinking", "thinking": "Let me analyze..." }]
}
}
2.4 Tool Use → ToolUseMessage with Typed Inputs¶
Tool invocations are parsed from ToolUseContent (JSONL) and wrapped in ToolUseMessage for rendering:
@dataclass
class ToolUseMessage(MessageContent):
input: ToolInput # Specialized (BashInput, etc.) or ToolUseContent fallback
tool_use_id: str # From ToolUseContent.id
tool_name: str # From ToolUseContent.name
# ToolInput is a union of typed input models
ToolInput = Union[
BashInput, ReadInput, WriteInput, DeleteInput, EditInput, MultiEditInput,
GlobInput, GrepInput, TaskInput, TodoWriteInput,
AskUserQuestionInput, ExitPlanModeInput, ToolExecutionInput,
ToolUseContent, # Generic fallback when no specialized parser
]
The original ToolUseContent (Pydantic model) provides:
- name: The tool name (e.g., "Read", "Bash", "Task")
- id: Unique ID for pairing with results
- input: Raw input dictionary
- parsed_input property: Returns typed input model via parse_tool_input()
Tool Input Models (models.py)¶
| Tool | Input Model | Key Fields |
|---|---|---|
| Read | ReadInput |
file_path, offset, limit |
| Write | WriteInput |
file_path, content |
| Edit | EditInput |
file_path, old_string, new_string, replace_all |
| MultiEdit | MultiEditInput |
file_path, edits[] |
| Bash | BashInput |
command, description, timeout, run_in_background |
| Glob | GlobInput |
pattern, path |
| Grep | GrepInput |
pattern, path, glob, type, output_mode, multiline, head_limit, offset |
| Task | TaskInput |
prompt, subagent_type, description, model, run_in_background, resume |
| TodoWrite | TodoWriteInput |
todos[] |
| AskUserQuestion | AskUserQuestionInput |
questions[], question |
| ExitPlanMode | ExitPlanModeInput |
plan, launchSwarm, teammateCount |
Tool Input Helper Models¶
Some tool inputs contain nested structures with their own models:
# MultiEdit tool uses EditItem for individual edits
class EditItem(BaseModel):
old_string: str
new_string: str
# TodoWrite tool uses TodoWriteItem for individual todos
class TodoWriteItem(BaseModel):
content: str = ""
status: str = "pending"
activeForm: str = ""
id: Optional[str] = None
priority: Optional[str] = None
# AskUserQuestion tool uses nested models for questions/options
class AskUserQuestionOption(BaseModel):
label: str = ""
description: Optional[str] = None
class AskUserQuestionItem(BaseModel):
question: str = ""
header: Optional[str] = None
options: List[AskUserQuestionOption] = []
multiSelect: bool = False
Tool Use Message Structure¶
- CSS Class:
tool_use(ortool_use sidechain) - Files: See messages/claude-code/tools/ (e.g.,
Read-tool_use.json)
{
"type": "assistant",
"message": {
"content": [{
"type": "tool_use",
"id": "toolu_xxx",
"name": "Read",
"input": { "file_path": "/path/to/file" }
}]
}
}
Part 3: System Messages (SystemTranscriptEntry)¶
System transcript entries (type: "system") convey notifications and hook summaries.
3.1 Content Types for System Messages¶
System messages are parsed into structured content models in models.py:
- SystemMessage: For info/warning/error messages
- HookSummaryMessage: For hook execution summaries
- AwaySummaryMessage: For away_summary recap entries
3.2 System Info/Warning/Error → SystemMessage¶
- Content Model:
SystemMessage(models.py) - CSS Class:
system system-info,system system-warning,system system-error - Files: system_info.json
@dataclass
class SystemMessage(MessageContent):
level: str # "info", "warning", "error"
text: str # Raw text content (may contain ANSI codes)
3.3 Hook Summary → HookSummaryMessage¶
- Content Model:
HookSummaryMessage(models.py) - Condition:
subtype: "stop_hook_summary" - CSS Class:
system system-hook
@dataclass
class HookInfo:
command: str
@dataclass
class HookSummaryMessage(MessageContent):
has_output: bool
hook_errors: List[str]
hook_infos: List[HookInfo]
3.4 Away Summary (Recap) → AwaySummaryMessage¶
- Content Model:
AwaySummaryMessage(models.py) - Condition:
subtype: "away_summary" - CSS Class:
system system-away-summary - Header:
📝 Recap(icon fromget_message_emoji, title fromtitle_AwaySummaryMessage) - Detail levels: visible at EVERY level (
depth_visibility = USER) — a recap is itself a high-level summary of activity (#179). Suppress with--no-recaps(handled in_ghost_template_by_depth), which drops them at all levels including HOOK.
Claude Code emits these system entries when a session resumes after a break — narrative prose summarising recent activity. The factory strips a trailing " (disable recaps in /config)" UI hint when present (suffix-match, not global) so all renderers inherit the polished form.
{
"type": "system",
"subtype": "away_summary",
"content": "We're adding a project-level layout to validate projectId in the prepare route tree …"
}
Part 4: Metadata Entries¶
These entry types primarily contain metadata, with some rendered conditionally.
4.1 Summary (SummaryTranscriptEntry)¶
- Purpose: Session summary for navigation
- Files: summary.json
{
"type": "summary",
"summary": "Claude Code warmup for deep-manifest project",
"leafUuid": "b83b0f5f-8bfc-4b98-8368-16162a6e9320"
}
The leafUuid links the summary to the last message of the session.
4.2 Queue Operation (QueueOperationTranscriptEntry)¶
- Purpose: User interrupts and steering during assistant responses
- Rendered: Only
removeoperations (asUserSteeringMessage). On modern transcripts (≳2.1.101) aremoveis suppressed when a pairedqueued_commandattachment covers its steering text (rendered instead); aremovewith no matching card — legacy transcripts, an unpairedremove, or one whose card could not be paired (e.g. an image-only prompt) — still renders. See "User Steering (Queue Remove / queued_command)" above. - CSS Class:
user steering - Files: queue_operation.json
4.3 File History Snapshot¶
- Purpose: File state snapshots for undo/redo
- Not Rendered
- Files: file_history_snapshot.json
Part 5: Renderer Content Models¶
These models are created during rendering to represent synthesized content not directly from JSONL entries.
5.1 SessionHeaderMessage¶
Session headers are rendered at the start of each session:
@dataclass
class SessionHeaderMessage(MessageContent):
title: str # e.g., "Session 2025-12-13 10:30"
session_id: str # Session UUID
summary: Optional[str] = None # Session summary if available
5.2 Workflow Run Nodes (#174)¶
Two synthetic node types materialise a dynamic-workflow run's structure
when the splice pass attaches it at the Workflow tool_use site (see
workflows.md). They are built from the parsed run, not
from JSONL entries, and use MessageMeta.empty():
@dataclass
class WorkflowPhaseMessage(MessageContent): # message_type = "workflow_phase"
title: str = "" # heading renders as "Phase: <title>" (🧩)
detail: str = ""
agent_count: int = 0
@dataclass
class WorkflowAgentMessage(MessageContent): # message_type = "workflow_agent"
label: str = "" # heading renders as "Agent <label>" (🤖)
model: str = ""
state: str = ""
tokens: Optional[int] = None
tool_calls: Optional[int] = None
result: Any = None # dict (StructuredOutput) | str | None
result_preview: str = ""
A phase's .children are its agent nodes; an agent's .children are
its grafted side-channel transcript messages.
Part 6: Infrastructure Models¶
6.1 CSS Class Registry¶
Display styling is derived from content types using CSS_CLASS_REGISTRY in html/utils.py. This registry maps MessageContent subclasses to their CSS classes:
CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = {
# System message types
SystemMessage: ["system"], # level added dynamically
HookSummaryMessage: ["system", "system-hook"],
HookAttachmentMessage: ["system", "system-hook-attachment"],
AwaySummaryMessage: ["system", "system-away-summary"],
# User message types
UserTextMessage: ["user"],
UserSteeringMessage: ["user", "steering"],
SlashCommandMessage: ["user", "slash-command"],
UserSlashCommandMessage: ["user", "slash-command"],
UserMemoryMessage: ["user"],
CompactedSummaryMessage: ["user", "compacted"],
CommandOutputMessage: ["user", "command-output"],
TeammateMessage: ["user", "teammate"],
TaskNotificationMessage: ["user", "task-notification"],
# Assistant message types
AssistantTextMessage: ["assistant"],
# Tool message types
ToolUseMessage: ["tool_use"],
ToolResultMessage: ["tool_result"], # error added dynamically
# Other message types
ThinkingMessage: ["thinking"],
SessionHeaderMessage: ["session_header"],
BashInputMessage: ["bash-input"],
BashOutputMessage: ["bash-output"],
UnknownMessage: ["unknown"],
# Dynamic-workflow synthetic nodes (#174): tool_use keeps them under
# the runtime "Tool Use" filter toggle; the modifier drives styling
# and the timeline lanes.
WorkflowPhaseMessage: ["tool_use", "workflow_phase"],
WorkflowAgentMessage: ["tool_use", "workflow_agent"],
}
The _get_css_classes_from_content() function walks the content type's MRO to find the matching registry entry, then adds dynamic modifiers (e.g., system-{level} for SystemMessage).
The only cross-cutting modifier is is_sidechain, which is stored directly on TemplateMessage and appended to CSS classes when true.
6.2 UsageInfo¶
Token usage tracking for assistant messages:
class UsageInfo(BaseModel):
input_tokens: Optional[int] = None
cache_creation_input_tokens: Optional[int] = None
cache_read_input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
service_tier: Optional[str] = None
server_tool_use: Optional[Dict[str, Any]] = None
6.3 BaseTranscriptEntry¶
Base class for all transcript entries, providing common fields:
class BaseTranscriptEntry(BaseModel):
parentUuid: Optional[str] # UUID of parent message
isSidechain: bool # Whether this is a sub-agent message
userType: str # User type identifier
cwd: str # Working directory
sessionId: str # Session UUID
version: str # Transcript format version
uuid: str # Unique message ID
timestamp: str # ISO 8601 timestamp
isMeta: Optional[bool] = None # Slash command marker
agentId: Optional[str] = None # Sub-agent ID
gitBranch: Optional[str] = None # Git branch name when available
Part 7: Message Relationships¶
7.1 Hierarchy (Parent/Child)¶
The message hierarchy is determined by sequence and message type, not by parentUuid:
- Session headers are topmost (Level 0)
- User messages follow at Level 1
- Assistant responses and system messages nest under user messages (Level 2)
- Tool use/result pairs nest under assistant responses (Level 3)
- Sidechain messages nest under their Task result (Level 4+)
Session header (Level 0)
└── User message (Level 1)
├── System message (Level 2)
└── Assistant response (Level 2)
└── Tool use/result pair (Level 3)
└── Sidechain messages (Level 4+)
Note: parentUuid links messages temporally (which message preceded this one) but is not used for rendering hierarchy.
7.2 Tool Pairing¶
tool_use and tool_result messages are paired by tool_use_id:
| First | Last | Link |
|---|---|---|
tool_use |
tool_result |
tool_use.id = tool_result.tool_use_id |
Other Pairings¶
| First | Last | Link |
|---|---|---|
bash-input |
bash-output |
Sequential |
thinking |
assistant |
Sequential |
slash-command |
command-output |
Sequential |
7.3 Sidechain Linking¶
Sub-agent messages (from Task tool):
- Have isSidechain: true
- Have agentId linking to the Task
- Appear nested under their Task result
Part 8: Tool Reference¶
Available Tools by Category¶
File Operations¶
| Tool | Use Sample | Result Sample | Input Model | Output Model |
|---|---|---|---|---|
| Read | tool_use | tool_result | ReadInput |
ReadOutput |
| Write | tool_use | tool_result | WriteInput |
WriteOutput (TODO) |
| Edit | tool_use | tool_result | EditInput |
EditOutput |
| MultiEdit | tool_use | tool_result | MultiEditInput |
— |
| Glob | tool_use | tool_result | GlobInput |
GlobOutput (TODO) |
| Grep | tool_use | tool_result | GrepInput |
GrepOutput (TODO) |
Shell Operations¶
| Tool | Use Sample | Result Sample | Input Model | Output Model |
|---|---|---|---|---|
| Bash | tool_use | tool_result | BashInput |
BashOutput (TODO) |
| BashOutput | tool_use | tool_result | — | — |
| KillShell | tool_use | tool_result | — | — |
Agent Operations¶
| Tool | Use Sample | Result Sample | Input Model | Output Model |
|---|---|---|---|---|
| Task | tool_use | tool_result | TaskInput |
TaskOutput |
| TaskOutput (async-agent polling) | — | — | TaskOutputInput |
TaskOutputResult |
| TaskStop (kill background task) | — | — | TaskStopInput |
TaskStopOutput |
| TodoWrite | tool_use | tool_result | TodoWriteInput |
— |
| AskUserQuestion | tool_use | tool_result | AskUserQuestionInput |
— |
| ExitPlanMode | tool_use | tool_result | ExitPlanModeInput |
— |
Web Operations¶
| Tool | Use Sample | Result Sample | Input Model | Output Model |
|---|---|---|---|---|
| WebFetch | tool_use | tool_result | — | — |
| WebSearch | tool_use | tool_result | — | — |
| Artifact (deploy HTML/MD page, CC 2.1.172+) | tool_use | tool_result | ArtifactInput |
ArtifactOutput |
References¶
- css-classes.md - Complete CSS class reference with support status
- models.py - Pydantic models for transcript data
- renderer.py - Main rendering module
- html/ - HTML-specific formatters (formatting only, content models in models.py)
- system_formatters.py - SystemMessage, HookSummaryMessage, AwaySummaryMessage formatting
- user_formatters.py - User message formatting
- assistant_formatters.py - AssistantTextMessage, ThinkingMessage, ImageContent formatting
- tool_formatters.py - Tool use/result formatting
- parser.py - JSONL parsing and text extraction
- factories/ - Content creation from parsed data
- user_factory.py -
create_user_message(),create_*_message()functions - assistant_factory.py -
create_assistant_message(),create_thinking_message() - tool_factory.py -
create_tool_use_message(),create_tool_result_message() - system_factory.py -
create_system_message() - meta_factory.py -
create_meta() - rendering-architecture.md - Rendering pipeline and Renderer class hierarchy
- ../work/rendering-next.md - Future rendering improvements