Sidechain Subagent Session Modeling#
Claude Code can write a spawned subagent's conversation turns directly into the parent session's transcript file — tagged with "isSidechain": true — rather than always creating a separate file under subagents/. Decant must correctly classify, attribute tokens to, and roll up costs for these inlined records across three places in the pipeline: the parser, the enricher, and the context-window materializer.
What a sidechain record is#
Each JSON line in a Claude JSONL transcript may carry an isSidechain boolean. When true, that line represents a turn from a child agent that Claude Code chose to embed in the parent's file rather than emit to a standalone agent-<id>.jsonl file. The same isSidechain flag also appears in standalone subagent files — in that context it marks those lines as the session's primary thread.
Subagent detection: parser level#
parseClaudeSession (src/sources/claude.ts) maintains two flags as it iterates over every JSONL line:
sawSidechainRecord— set totruethe first time a record hasisSidechain === truesawMainRecord— set totrueon anyuser,assistant, orsystemrecord without that flag
The isSubagent determination at the end of the parse pass is :
isSubagent = pathIndicatesSubagent || (sawSidechainRecord && !sawMainRecord)
A file that contains sidechain records but no main-conversation records is treated as a standalone subagent session. A file that mixes both is treated as a parent session that happens to contain embedded child-agent turns.
A second check occurs during resolveSubagentParents in src/ingest.ts. After all sessions are parsed and stored, inferSubagent re-evaluates each session row using stored metadata, including checking isSidechain on the session's first message as a cross-validation signal .
Path-based detection provides a complementary signal: any file whose path includes a subagents/ directory segment is automatically treated as a subagent, regardless of record content .
Token attribution and turn counting#
Sidechain rows embedded in a parent transcript are not counted as turns of the parent session. In facets (src/enrich.ts), turn counting is gated on:
(!sidechain || session.isSubagent)
This means a sidechain user message increments turnCount only when the session itself is a subagent (i.e., the sidechain rows are the primary thread). In a parent session they are counted separately in sidechainMessageCount but do not affect the parent's turn total.
Token usage is read from individual assistant message usage fields; since sidechain records in a parent file belong to the child's API calls, they carry the child's usage numbers. Decant ingests those numbers into the child's session row — not the parent's — provided the child has its own agent-<id>.jsonl file. If there is no separate file (the child exists only as embedded sidechain rows in the parent), those tokens are accounted for in the parent file's raw totals; the billAssistant deduplication pass ensures only the highest-output requestId window is billed once per streaming turn.
Usage deduplication: Claude journal records can split one API message across content blocks (thinking, text, tool-use) and repeat the full usage on each block. Billing is grouped by requestId (supporting both requestId and request_id field names). When requestId is missing, message.id is used as the fallback identifier. This prevents counting duplicate usage when Claude splits one API response into several assistant records. Without this deduplication, a single Claude response with multiple content blocks could multiply token totals and estimated costs incorrectly. All transcript blocks are preserved regardless of deduplication. The same grouping logic applies to inferred reasoning estimates: the usage snapshot with the largest output count is retained per requestId or message.id, and records with neither identifier are counted separately.
Context-window curve exclusion#
materializeContextWindow (src/context-window.ts) explicitly skips sidechain rows when building the parent's context-window occupancy curve:
"Main Claude transcripts can contain copied sidechain rows that belong to a child agent, so exclude those from the parent's curve. In a standalone subagent archive those same isSidechain rows are the session's primary thread and must remain visible."
The guard is if (meta.isSidechain && session.is_subagent === 0) { continue; } . Without this, sidechain turns would inflate the parent's apparent peak context-window size.
Cost rollup#
Because subagent sessions are stored as separate rows (with is_subagent = 1 and a parent_session_id FK), cost aggregation in the analytics layer is straightforward: each session row carries its own estimated_cost_usd; subagent_estimated_cost_usd on a parent row sums direct children only, and the UI uses sessionThreadCost for a full recursive total . The "sidechain rows with no separate subagent file" edge case means some child-agent spend may be embedded in the parent's own token totals rather than a child row; the sidechainMessageCount facet on the parent session is the observable marker for this condition .
Key files#
| File | Role |
|---|---|
src/sources/claude.ts | Parser: sawSidechainRecord/sawMainRecord flags, isSubagent derivation |
src/ingest.ts | Post-parse: resolveSubagentParents, inferSubagent, path-based detection |
src/enrich.ts | Facets: turn-count gating, sidechainMessageCount |
src/context-window.ts | Excludes sidechain rows from parent context-window curves |
docs/analytics-methodology.md | Canonical definitions for session/subagent metric semantics |