Agents¶
See application_model.md for the system overview.
claude-code-log renders four flavors of spawned agents:
| Flavor | Trigger | Reference |
|---|---|---|
| Sync sub-agent | Task tool_use, default behavior |
This doc § 1 (#79, stub) |
| Async task agent | Task with run_in_background=True |
This doc § 2 (#90) |
| Teammates | Agent (or Task with team_name/name); requires CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 |
teammates.md (#91) |
| Workflow sub-agents | Workflow tool_use (JS orchestrator fan-out) |
workflows.md (#174); summary in § 4 |
Any of the Task/Agent flavors may itself spawn sub-agents (Claude Code 2.1.172+) — § 5 covers how the nesting is discovered and rendered.
The first three share the same metadata-tail shape on the spawn's tool_result
(agentId: line + optional <usage> block) — parse_agent_result_metadata
populates TaskOutput.metadata for every variant. Workflow sub-agents are
different: their metadata comes from the run's journal.jsonl +
<runId>.json snapshot, not from the tool_result tail.
1. Sync sub-agents (#79)¶
The default Task flavor: synchronous, sidechain entries from
subagents/agent-<id>.jsonl are spliced under the trunk tool_result by
_relocate_subagent_blocks, the agent's final answer surfaces at the
spawn position via _cleanup_sidechain_duplicates (drops the duplicate
last sub-assistant from the sidechain so the answer doesn't appear
twice).
Issue #79
captures the remaining work — the prompt-hash fallback in
converter.py is teammates-specific and would need a different
normalization for plain user-text first entries on sync sub-agent
files.
2. Async task agents (#90)¶
When the assistant emits Task with run_in_background=True, Claude
Code returns immediately with an "Async agent launched successfully…"
stub on the tool_result. The agent runs in a background subagent
session; the assistant can poll explicitly via the TaskOutput tool;
some time later, Claude Code injects a synthetic User entry whose
message.content is a raw <task-notification>…</task-notification>
block carrying the agent's final answer.
2.1 Pipeline shape¶
The same agent surfaces in the trunk transcript across four distinct entries — three rendered cards and one user-text entry that parses into a structured card:
Task tool_use ←── 🔧 Task: "Coverage analysis" [async]
└── input.run_in_background = True
Task tool_result ←── content.output is TaskOutput
├── result = "Async agent launched successfully…\n
│ agentId: <id>\noutput_file: …"
├── metadata.agent_id = <id>
└── async_final_answer = <fold target> (Phase 3)
…
TaskOutput tool_use ←── 🔍 TaskOutput #<id>
└── input is TaskOutputInput { task_id, block, timeout }
TaskOutput tool_result ←── content.output is TaskOutputResult
├── retrieval_status, task_id, task_type, status
└── output_truncated, output_file
(the truncated transcript body is parsed
for the marker and discarded — the agent's
full transcript already renders inline as a
sidechain, and the answer reaches the trunk
via the notification below)
…
<task-notification> user ←── 🔄 Async result · *summary*
└── content is TaskNotificationMessage
└── result_text = <fold source> (Phase 3)
2.2 The two TaskOutput names¶
There are two unrelated dataclasses with TaskOutput in the name. They
attach to different tool_results:
| Class | Attached to | Holds | Defined |
|---|---|---|---|
TaskOutput |
The Task tool_result |
result (launch stub or final answer text), metadata (agent_id, worktree_path, <usage>), and the new async_final_answer field |
models.py |
TaskOutputResult |
The TaskOutput polling tool's tool_result |
retrieval_status, task_id, task_type, status, output_truncated, output_file |
models.py |
The "fold" (Phase 3) writes into TaskOutput.async_final_answer —
i.e. the Task tool_result's parsed output, not the polling tool's
result. The polling tool's <output> body (a truncated snapshot of the
agent's transcript) is parsed only for its truncation marker and
deliberately discarded.
2.3 The fold (Phase 3)¶
_link_async_notifications in renderer.py runs after pair
identification and tree building. For every Task tool_result whose
metadata.agent_id matches a TaskNotificationMessage.task_id, it:
- Spawn-fold (HOOK/TOOL/AGENT): sets
tool_result.output.async_final_answer = notification.result_textand flagsnotification.result_is_duplicate = True. The formatter then renders a "Result (from async notification)" collapsible below the launch stub on the spawn card, and reduces the notification card to a backlink stub. Sourcing the fold from the notification (not from the sidechain assistant) is what makes the fold survive at AGENT where sidechain entries are stripped pre-render. The same pass records the notification'smessage_indexonTaskOutput.async_notification_index. The minimal theme shows the answer the other way round: on the notification card (the async branch's merge row, at the time the answer arrived — never ghosted, at any depth), while the spawn's result keeps only the launch line and aResult ↓link to it (.mn-async-jump; the Agent id row, repeated as the notification's Task ID, is dropped) — with JavaScript folded into the spawn's branch control (P7c). Classic and Markdown output are unchanged. See minimal-theme.md § 7 and § 9. - Sidechain dedup (HOOK/TOOL only): when the last sub-assistant
text matches the notification's
result_text, drops the duplicate from the sidechain tree. No-op at AGENT (sidechain already gone).
At RenderingDepth.AGENT the format-specific renderers honor the flag
by ghosting the duplicate notification — format_TaskNotificationMessage
and title_TaskNotificationMessage (in both HtmlRenderer and
MarkdownRenderer) return "" when self.detail == AGENT and
content.result_is_duplicate (in HtmlRenderer, classic theme only:
the minimal theme keeps both, as above). The rendering loop's existing
"skip empty messages" elision (HTML's
if title or html or msg.children: and Markdown's
_render_message returning "" for no-title-no-content) drops the
card from the visible output. The notification stays in
ctx.messages with its original message_index, so ancestry classes
(d-N), backlink fields (spawning_task_message_index,
SessionHeaderMessage.parent_message_index), and session navigation
anchors all stay valid — no index-remap cascade required.
2.4 Depth matrix¶
| level | spawn-fold visible | notification card | answer visible |
|---|---|---|---|
| full | yes | yes (collapsed body, full metadata) | yes (folded) |
| high | yes | yes (collapsed body, full metadata) | yes (folded) |
| low | yes | dropped | yes (folded) |
| minimal | no (Task tool_result filtered) | yes (body kept) | yes (notification body) |
| user-only | no (Task tool_result filtered) | yes (body kept) | yes (notification body) |
The answer is visible exactly once at every depth. At
ASSISTANT/USER the spawn-fold is skipped (the Task tool_result
itself is ghosted by _ghost_template_by_depth), so the
notification card retains its body as the surviving copy.
2.5 Key files¶
models.py—TaskOutputInput,TaskOutputResult,TaskNotificationMessage,TaskNotificationUsage. Theasync_final_answer: Optional[str]field onTaskOutput.factories/task_notification_factory.py— parses<task-notification>user content.factories/tool_factory.py—parse_taskoutput_outputfor the polling tool's body.factories/user_factory.py— dispatch hook before teammate detection.renderer.py—_link_async_notifications,_async_agent_id_from_tool_result,_last_sidechain_assistant.html/async_formatter.py— notification card HTML + TaskOutput poll card HTML.html/renderer.py::HtmlRenderer.format_TaskNotificationMessage/title_TaskNotificationMessage— return""at AGENT for duplicate-flagged notifications (ghost mechanism); in the minimal theme both keep the notification, title and body.html/tool_formatters.py::format_task_output— rendersasync_final_answeras a collapsible below the launch stub.markdown/renderer.py::MarkdownRenderer.format_TaskNotificationMessage/title_TaskNotificationMessage— same ghost-at-AGENT gate. Plusformat_TaskOutput,format_TaskOutputResult, and titles.html/utils.py::CSS_CLASS_REGISTRY—TaskNotificationMessage: ["user", "task-notification"]so the runtime "User" filter toggle keeps the card visible at HOOK/TOOL.
2.6 Test fixture¶
test/test_data/async_agents/eb000000-… — a 7-entry main session +
3-entry sidechain sliced from the canonical clmail-monk transcript.
Exercises all four shapes (Task with run_in_background,
async-launched tool_result, TaskOutput poll, <task-notification>).
The notification's <result> matches the last sub-assistant verbatim
so the Phase 3 fold + dedup fires.
Tests live in test/test_async_agents.py (parser unit tests, factory
dispatch, rendering pipeline assertions, depth invariants —
including the AGENT regression guard for the fold).
3. Teammates (#91)¶
A teammate is a Task (or Agent) spawned with team_name and a
human name, paired with a long-running team coordinator. The
teammates feature stacks specialized behavior on top of the sub-agent
machinery:
- A registry of six teammate-management tools (
TeamCreate,TaskCreate,TaskUpdate,TaskList,SendMessage,TeamDelete). <teammate-message>blocks in user content.- Per-session teammate color propagation through
RenderingContext. - Session-header team badge and project-index team aggregation.
See teammates.md for the full as-built reference.
4. Workflow sub-agents (#174)¶
A Workflow tool_use launches a JavaScript orchestrator that fans out
into many side-channel sub-agents grouped into phases. Unlike the Task
flavors above, these agents are not integrated through
_integrate_agent_entries / _relocate_subagent_blocks: the whole run
(phases → agents → each agent's side-channel transcript) is parsed from
<sid>/subagents/workflows/<runId>/ by workflow.py and spliced as a
self-contained sub-tree at the Workflow tool_use site by
_splice_workflow_runs — the last pass in
generate_template_messages. Each agent's transcript is re-rendered
through a nested generate_template_messages call and grafted under
its workflow_agent card.
See workflows.md for the full as-built reference (on-disk layout, parse model, taskId linkage, splice mechanics, depth behaviour). In the minimal theme each agent with a transcript is a branch lane of its own (§ 6).
5. Nested agent hierarchies (#213)¶
Claude Code 2.1.172+ lets sub-agents spawn their own sub-agents. The announced "up to 5 levels deep" cap is not enforced (a linear chain was observed at depth 79), so nothing in the pipeline assumes a bound. (The practical bound is Python's ~1000-frame recursion limit — the loader's child loading, the block relocation and the depth chase all recurse one frame per level; a pathological multi-hundred-depth chain would fail as a RecursionError at load.)
5.1 On-disk shape¶
The layout stays FLAT at every depth: each agent gets
<sid>/subagents/agent-<id>.jsonl + agent-<id>.meta.json next to its
siblings — nesting never creates subdirectories. Every entry of every
agent file carries the trunk sessionId, isSidechain: true and
agentId = the agent's own id; the root entry has parentUuid: null.
The cross-file links differ by direction:
- parent → child: the in-band metadata tail on the spawning
tool_result's content (
agentId: <id> (use SendMessage …)+<usage>). At trunk level the structuredtoolUseResult.agentIdadditionally exists — only there; nested tool_results carry notoolUseResult. - child → parent: the sidecar
meta.json'stoolUseIdnames the spawning tool_use. Crucially this survives spawns that never returned (an interrupted spawn's tool_result is a genericis_errorstub with no tail).
flowchart LR
subgraph trunk["<sid>.jsonl (trunk)"]
TU1["tool_use Agent #1"] --> TR1["tool_result<br/>+ toolUseResult.agentId<br/>+ 'agentId:' tail"]
end
subgraph A["subagents/agent-A.jsonl"]
TU2["tool_use Agent #2"] --> TR2["tool_result<br/>'agentId:' tail ONLY"]
end
subgraph B["subagents/agent-B.jsonl"]
BR["root (parentUuid: null)"]
end
MA["agent-A.meta.json<br/>toolUseId = #1"] -.-> TU1
MB["agent-B.meta.json<br/>toolUseId = #2"] -.-> TU2
TR1 ==> A
TR2 ==> B
5.2 Discovery and linking (spawnedAgentId)¶
load_transcript scans the sidecars once per load (memoized per
directory) into {toolUseId → agentId} and, for every spawning
tool_use found in the loaded entries, stamps the resolved id on the
spawning tool_result entry (or the tool_use's assistant entry when no
result exists) as the synthetic spawnedAgentId field — see
_subagent_meta_map / _apply_subagent_meta_links (converter.py).
The field exists because agentId can't carry both meanings: inside an
agent transcript agentId is membership (whose transcript the entry
belongs to), while the spawn anchor needs a reference (which agent
this entry spawned). The legacy trunk backpatch (copying
toolUseResult.agentId into the entry's agentId) only worked because
trunk entries have no membership.
Downstream, spawnedAgentId anchors take precedence everywhere the
legacy reference was used: _integrate_agent_entries (DAG re-parenting
+ {trunk}#agent-{id} stamping — flat synthetic sids stay
collision-free at any depth), the converter's insert-at-point-of-use
pass, and _relocate_subagent_blocks (renderer.py), which now emits
blocks recursively so a nested agent's block lands right after its
spawn entry inside its parent agent's block.
5.3 Hierarchy levels¶
_get_message_hierarchy_level's sidechain levels (assistant 4 / tools
5) are the depth-1 block; _build_message_hierarchy shifts a
depth-d transcript by 2 * (d - 1), chasing depth through the
spawned_agent_id links (agent lines without one default to depth 1 —
legacy data renders exactly as before). Each nesting level therefore
adds two DOM levels: the spawn pair, then the child transcript.
The sidechain dedup (_cleanup_sidechain_duplicates) applies per spawn
node at any depth: a transcript whose prompt and final answer both
round-trip verbatim through its spawn pair collapses entirely — a
trivial leaf shows nothing beyond its parent's tool_use/tool_result
pair, exactly like depth-1 sync agents.
5.4 Visual layer¶
_build_message_hierarchy stores the chased depth on every message as
TemplateMessage.agent_depth (0 = trunk). css_class_from_message
turns agent_depth >= 1 into three classes on the card:
agent-depth-{d} (exact, for the badge/tests), agent-ring-{1..5}
(the ((d-1) mod 5)+1 colour-cycle bucket), and agent-deep for
d >= 6. The CSS (message_styles.css) then keys off the inner
sidechain card's class so the group line framing a depth-d
transcript:
- takes the ring colour for that depth (depth 1 = tool-green, the
pre-existing look; 2 blue, 3 purple, 4 orange, 5 teal — see the
--agent-ring-*vars), and - compresses its indent step at
d >= 6(2em for depths 1-5, then 0.5em) so very deep chains (79 levels seen in the wild) stay on-screen — depth is carried by the badge + colour, not the indent.
Three card-level annotations complete the layer:
- Depth badge (
_agent_depth_badge,html/renderer.py): a "Depth N" pill on a spawn card showing the depth of the sub-agent it opens (agent_depth + 1), suppressed for top-level spawns (depth 1) to keep shallow transcripts clean; its ring colour matches the group line below. - Collapsed marker (
spawns_collapsed_transcript, set in_cleanup_sidechain_duplicateswhen a NESTED spawn's transcript dedups to nothing):title_ToolResultMessagetags the result "≡ full transcript" — the sub-agent answered directly, so what's shown is its whole transcript, distinct from a spawn that produced none. Nested-only (agent_depth >= 1); trunk-level direct sub-agents keep their pre-#213 rendering. - Model id (
TemplateMessage.display_model, set by_surface_agent_modelsinrenderer.py; issue #246): the model an agent ran on, surfaced once per agent context and on a node that stays visible when the agent's transcript folds — on the session header (the trunk/main model, from the first non-sidechain assistant entry) and on each spawn card (the Task/Agenttool_usethat opens a sub-agent). The sub-agent's model comes from its own first assistant entry, joined back to the spawn card viatool_use_id(spawned_agent_idlands on the tool_result; the two-pass helper maps agent→model, then stamps the paired tool_use — pairing indices aren't assigned yet, so it uses the id join, notpair_first). The spawn card sits at the parent's depth and stays on screen even when the sub-agent collapses, so the model shows where the reader looks — and co-locates with the depth badge. A mid-course/modelswitch surfaces as its own command message, so a single first-seen value per context suffices. The raw per-entry value isMessageMeta.model(only assistant entries carry it);display_modelis the render-once decision. HTML renders a muted.message-modelpill — in the spawn card title (_agent_model_badge, beside the depth badge) and inline on the session-header line; Markdown appends· `…`to the Task title and an inline— Model:on the session heading. Unlike the depth badge it is not suppressed at depth 1 (the top-level Explore/Task spawn is the common case a reader wants).
5.5 Fixture¶
test/test_data/nested_agents/ (generated by
scripts/gen_nested_agents_fixture.py): a 2×2 fan-out, a 3-deep chain,
and an interrupted spawn linkable only via its sidecar. Pinned by
test/test_nested_agents.py (structure + visual-layer logic) and
test/test_nested_agents_browser.py (runtime CSS: ramp colours,
indent, badge, marker). Note the fixture's agents answer directly with
no thinking blocks; real sub-agents usually think before spawning,
so an agent's own thinking→spawn nesting renders as an invisible
0-width passthrough group — only true agent boundaries draw a line.
6. Branch lanes (minimal theme)¶
The minimal HTML theme (--theme minimal) lays a transcript out as a
DAG: sub-agents and rewind forks become lanes beside the main line
(design: work/minimal-theme-dag.md
§ 1.6). The DOM keeps the nested tree described above; a format-neutral
pass, lanes.annotate_lanes(roots) in
lanes.py, only labels it. The HTML
renderer runs it after formatting, for the minimal theme only, and
html/minimal_theme.lane_attributes turns the result into data-*
attributes on the cards (classic output never carries them).
| Flavour | Lane | Spawn row | Merge row | Kind |
|---|---|---|---|---|
| Sync sub-agent | agent-<agentId> |
the Task/Agent tool_use |
its paired tool_result | agent |
| Async agent | agent-<agentId> |
the tool_use | the <task-notification> card (task_id = agent id, else spawning_task_message_index) |
async-agent |
| Nested agent (§ 5) | agent-<agentId>, parent_lane = the spawning agent's lane |
the tool_use inside the parent lane | its tool_result inside the parent lane | as above |
| Teammate (§ 3) | none — the thread stays in its spawner's lane | — | — | recorded as teammate so the spawn card can link to the thread's first card |
| Workflow agent (§ 4) | wfagent-<agentId> for an agent card whose side-channel transcript rendered (else none: a plain row); phase and agent cards stay in the Workflow call's lane |
the agent's phase card (no phases: the Workflow call's result) | the agent card, once it reports a result | workflow-agent |
Membership is the card's own session line: a sidechain card with a
{trunk}#agent-<id> session id is in agent-<id>. The spawn anchor
is found with renderer.spawned_agent_id_of — the same
spawnedAgentId-first rule _relocate_subagent_blocks uses — and the
spawn row is that tool_result's pair_first (an interrupted spawn with
no result uses the card the loader stamped). A lane exists only once it
has a rendered card: a transcript that deduplicated into its spawn pair
(§ 5.3) or was stripped at a reduced detail level leaves an ordinary
tool call. Each lane also records its user turn (the top-level card
under the session or branch header that holds the spawn row), its rank
in that turn by spawn time (the client's "interleave at most three per
turn" cap) and its stats: N steps (rendered cards minus each pair's
second half), tokens and duration from the notification's <usage>
(async) or the result tail (sync), else the span of the lane's
timestamps ("6 steps · 48.4k tokens · 2m 13s").
An agent lane without a merge row also carries a state
(data-lane-state): ended when the page proves the agent is over — a
synchronous agent's parent line (same lane, same session) has a later
model step or prompt, a TaskStop reported the agent stopped, or the lane
is nested in one that merged or ended — else open. The minimal theme
draws an open lane as running on a live-served page, with how long it
has been quiet (a session silent for over a week reads as stopped); see
minimal-theme.md § 8.
A workflow agent lane's membership is its agent card's subtree (the
grafted side-channel, in_workflow_sidechannel); its stats take the
snapshot's tokens and duration (WorkflowAgentMessage.tokens /
.duration_ms), its name the agent's label. It is ranked and capped in its
group — <runId>/<phase ordinal> (data-lane-group), the whole run
without phases — instead of the user turn, so a phase fanning out to many
agents gets its own +N more agents and leaves the turn's other branches
alone. Without a result it is ended once the run's snapshot exists or
its state is terminal, else open. See
minimal-theme.md § 1.1.
Rewind forks follow the same model; see
dag.md § Branch lanes. Teammate
exchanges are linked rather than laned: lanes.teammate_links pairs each
SendMessage with the <teammate-message> that delivered it (see
teammates.md § 6.1). Tests:
test/test_lanes.py.