Claude Code Adapter
How the Unified Conversation Model maps onto Claude Code. Every mapping below is stated with the evidence that supports it and, where a concept cannot be supplied, said to be unavailable rather than approximated.
Verified against Claude Code 2.1.220 – 2.1.251. No earlier version was available for measurement, so nothing here is confirmed below 2.1.220.
Measured on one development machine’s Claude Code history. It was live and grew while it was measured: about 2,920 files and 1.2 GB in the first pass, 2,970 files, 1.09 GB and 365,825 records when it was measured again, and 3,032 files in the last pass. A count is a snapshot. Where two passes disagree, or a count covers only part of the corpus, the page says which.
Collection
The adapter reads local files. It requires no configuration, no environment variables, and no cooperation from Claude Code — and because it reads what is already on disk, it works on conversation history that predates its installation.
~/.claude/projects/<slugified-cwd>/<session-id>.jsonl main stream
~/.claude/projects/<slugified-cwd>/<session-id>/subagents/agent-<agent-id>.jsonl
~/.claude/projects/<slugified-cwd>/<session-id>/subagents/agent-<agent-id>.meta.json
~/.claude/projects/<slugified-cwd>/<session-id>/subagents/workflows/<wf-id>/agent-<agent-id>.jsonl
~/.claude/projects/<slugified-cwd>/<session-id>/subagents/workflows/<wf-id>/agent-<agent-id>.meta.json
~/.claude/projects/<slugified-cwd>/<session-id>/subagents/workflows/<wf-id>/journal.jsonl
~/.claude/projects/<slugified-cwd>/<session-id>/workflows/wf_<run-id>.json
~/.claude/projects/<slugified-cwd>/<session-id>/workflows/scripts/<label>-wf_<run-id>.js
A workflow script carries its run id in the filename, which is what makes it collectable
despite living outside its session’s directory. Split on the last -wf_: a run id itself
contains hyphens (wf_afa31e47-f6c), so a greedy match starts at the first occurrence and
swallows any later one. On a real corpus, 172 of 172 script names parsed this way, and all 172 run
ids matched a manifest in their own session.
Transcripts are the primary channel because their content is complete. They store the person’s
prompt text, the assistant’s prose, tool inputs and tool results in full. Claude Code’s
OpenTelemetry events do not. By default they redact user_prompt and assistant_response, cut
content at about 60 KB, and omit tool inputs unless OTEL_LOG_TOOL_DETAILS is set. Those three
defaults were read from the 2.1.245 binary. Events also redact reasoning in every case. Transcripts
have two exceptions of their own. Reasoning text is usually empty, see
What this adapter cannot supply. And on the main stream, a long
tool result’s content block can be shorter than toolUseResult.stdout, see
Session Data mapping.
OpenTelemetry is an optional second channel that adds measurement only — see OTLP.
Discovery is session-first
A directory under projects/ is named after the working directory Claude Code was launched in, with
/, . and space all collapsed to -. That transformation is not reversible — the real path is
recoverable only from record content. The rule was checked across all 25 source directories that
held a transcript. Two did not fit a plain / rule, and they showed that . and space are replaced
too: /Users/…/Application Support/JetBrains/GoLand2026.1/… became
-Users-…-Application-Support-JetBrains-GoLand2026-1-…, and
/private/tmp/agentsessionizer-cc-v04.psixvf became -private-tmp-agentsessionizer-cc-v04-psixvf.
A session filter that starts with / is converted
forward the same way. Because the conversion loses information, /private/tmp/** matches the
directory itself and any name that begins with its converted form followed by -.
More importantly, a session’s files are not confined to one such directory. Claude Code files a workflow run’s script under whatever working directory the agent had at the time. Measured on a real corpus, 7 of 61 sessions span more than one directory for this reason, one of them across six. The conversation data itself - main transcript, child streams, journals, manifests - stays together; it is the script that travels.
Discovery therefore scans every directory for entries whose name is a session id — both .jsonl
files and directories — and groups by session id across directories. A directory-first walk
would attribute a session’s scripts to a directory that holds nothing else of it.
A directory is recorded as belonging to a session only once it has yielded a collectable source. Otherwise a directory containing nothing we collect could disqualify the whole session through an exclude pattern — which is why filtering keys on the session’s primary directory, the one holding its main transcript.
Child files are found at exactly two levels under a session directory: subagents/ and
subagents/workflows/<run-id>/. Nothing deeper is read, so a child file placed deeper would be
missed. The second level is required. In the 2,970-file pass, a listing of subagents/ alone found
107 child files where the full walk found 2,910, a 96.3% miss, because most children live under
subagents/workflows/<run-id>/.
Every pass discovers the sources again rather than keeping a list, so a file that appears after one
pass is collected by the next. A project directory that vanishes during a scan is skipped, because
the corpus is live. One that cannot be read for any other reason is an error for the pass, so a
pass that could not see every source does not report itself complete. That holds for project
directories only. Inside a session, a directory that cannot be read is skipped with no error: the
session directory itself, subagents/, subagents/workflows/, a run directory under it,
workflows/ and workflows/scripts/. The pass then reports itself complete without the files
under it.
Two further rules:
memory/is a sibling directory inside a project directory and is not a session. Match session directories by identifier shape, not by “is a directory”.- Pruning is real and not atomic. Claude Code deletes transcripts. Of 345 session ids in its
prompt history,
history.jsonl, 330 had no surviving transcript. In the first pass, 12 of 41 session directories had no main transcript while their wholesubagents/tree survived. That is a normal state, not corruption. When a source disappears, the files already landed stay, so the storage root outlives the source. A later pass sets its cursor tosource_gone, whether discovery still finds the session or not, except in the cases Pruned sources describes. Separately, 32 of 61 main transcripts had no subagent directory at all, which is a session that started no agent.
How each source is read
| Source | How it changes | How it is read |
|---|---|---|
<session-id>.jsonl, agent-<agent-id>.jsonl |
only grows | from a byte offset: each pass lands what was appended since the last one |
journal.jsonl |
only grows | from a byte offset |
wf_<run-id>.json |
rewritten as the run progresses | by content digest: a changed file lands again as a new version |
<label>-wf_<run-id>.js |
may be rewritten | by content digest |
agent-<agent-id>.meta.json |
written once, at spawn | by content digest, so in practice it lands once |
Reading by offset is safe because Claude Code only appends to a transcript. Resuming a session appends to the same file, see Conversation identity, and the block Claude Code re-writes before a context reset is also an append, see Reconstruction rules. Across 60 main and 2,851 subagent files in the first pass, no parent id dangled and none pointed into another file. Truncation was never observed, but that is not proven. A cursor’s size check and its tail digest catch it, and the collector then stops that source with a conflict rather than read from a position that no longer means what it did.
A session is never finished. A transcript quiet for weeks can grow again when the session is resumed, so the adapter never marks a session done, and every pass checks every source again. Collection polls, as collector describes. There is no file system watcher.
Each child lands flat by its agent id, beside main, as the storage root
describes. That is safe because no agent id appears in two files, see
Execution streams. Nesting children under their run or their parent would
write a derived join into a write-once path. A join that later proved wrong would then mean moving
files, and that matters most for the 31 depth-2 agents. Only a name of the shape
^a[0-9a-f]{16}$ counts as an agent id. If two source files would still land in one stream or run
directory, the collector refuses the second and counts a conflict, rather than mix two files behind
one cursor.
What is not read
The adapter reads the conversation records and nothing else. The project’s subject is conversation structure and provenance. The files below hold the runtime’s working state or output spilled from a tool, not conversation records, so they are not read:
- elsewhere under
~/.claude/or beside it: the prompt historyhistory.jsonl,~/.claude.json,file-history/, the telemetry spooltelemetry/1p_failed_events.*, the live process registrysessions/<pid>.json,shell-snapshots/,session-env/,backups/andpaste-cache/; - inside a project directory:
memory/,tool-results/andsessions-index.json.
Which files a tool call changed comes from the transcript’s own patch and from the plugin, see
Workspace changes, never from file-history/. The prompt history appears on
this page only as a measurement, under Discovery is session-first.
Object mapping
| Model object | Claude Code | Notes |
|---|---|---|
| Conversation | sessionId |
the only mapping implemented. See Conversation identity. |
| Segment | derived | an activity window chosen by the assembler, not a runtime concept |
| Session | sessionId |
1:1 with Conversation |
ExecutionStream main |
<session-id>.jsonl |
the only stream that compacts |
| ExecutionStream child | agent-<agent-id>.jsonl |
named by the agent id in its file name. It shares the parent sessionId. |
| Context epoch | split by system/compact_boundary |
main stream only |
| Talk | derived from the trigger of each prompt cycle | not a runtime concept. See Talks and runs. |
| Run | promptId, within one stream |
one run per distinct promptId in a stream |
Conversation identity
sessionId is the default conversation identity because nothing on disk reliably links two
sessions. bridgeSessionId spans two session ids in only 1 of 20 observed cases and can be the
empty string; the snake_case session_id field is entrypoint-gated and its apparent cross-session
links trace to a single stale value. No heuristic over cwd, gitBranch, slug or title is sound.
/clear starts a new sessionId and a new file, and is detectable at the head of the successor
file as a fixed three-record chain: a root user record with isMeta:true carrying
<local-command-caveat>, then a user record whose content is <command-name>/clear</command-name>,
then system/local_command. The successor carries no pointer back to its predecessor.
Resuming a session does not start a new one. Claude Code appends to the same file under the same
sessionId, so a resumed session continues the same session and the same conversation. One
transcript in the first pass held a 659-hour gap, across which version went from 2.1.220 to
2.1.251, and its parent chain still linked across the gap.
The session id is the only mapping implemented. asz parse always passes it as the conversation
id, and no flag, configuration key or API takes another one today. So an application that considers
several /clear-separated sessions one piece of work cannot yet say so. Supplying an explicit
conversation id is planned.
Execution streams
A child agent always shares the parent’s sessionId — measured across 2,697 subagent files with
zero exceptions and zero files carrying more than one session id. It never gets a session of its own.
The partition is clean: no sidechain records in main transcripts, no top-level agentId in main
transcripts.
Stream membership comes from the file a record was written in, never from a field. A child’s
stream id is the agent id in its file name, and the collector writes it into the landed header. The
in-record agentId is not used to name a stream. That is safe. In a full scan of 2,686 agent files
and 218,393 records in the first pass, the file name’s agent id equalled the in-record agentId in
2,686 of 2,686 files, no file carried more than one, and no two files shared one.
isSidechain looks like the discriminator and is not. It is missing on 28.7% of main-transcript
records in the 2,970-file pass, and inside a subagent file it is constant, so it says nothing about
any one record. asz does not read it.
| main | child | |
|---|---|---|
sessionId |
S | S — identical |
isSidechain |
false, or missing (28.7% of records) |
true |
top-level agentId |
absent | ^a[0-9a-f]{16}$ |
Nesting stops at depth 2. In the 2,970-file pass, 2,683 sidecars were at depth 1, 31 at depth 2,
and one had no depth field. No depth of 3 or more was seen. All 31 depth-2 agents come from one
session, so a method that samples a few sessions will likely see none and conclude that the maximum
is 1. The file layout carries no depth: every agent transcript sits flat in subagents/ or in
subagents/workflows/<run-id>/. Nesting is known only from a nested child’s sidecar
parentAgentId. asz does not read spawnDepth.
Child streams are structurally simpler than main: exactly one root per file, no duplicate record ids, no compaction at all (0 boundaries in 221,592 child records in the 2,970-file pass), and no rewind forks. Their model context accumulates without interruption for the life of the stream, so each child stream is one context epoch.
Context epochs
system/compact_boundary is an explicit, machine-readable epoch boundary — the model never has to
infer one. asz detects it by the system record’s subtype, never by the text compact_boundary,
which appears far more often in message content than in real boundary records. An epoch is cut only
at such a record, in landed order, and a stream with no boundary is one epoch.
The boundary carries compactMetadata plus logicalParentUuid, a pointer to the last
pre-compaction message. In the 2,970-file pass it was present on 27 of 27 boundaries and on no other
record type. The boundary starts a new parent chain. Its own parentUuid is null on 27 of 27,
so logicalParentUuid is the only link back, and a parent walk across a reset stops without it.
logicalParentUuidis never the line just before the boundary, so proximity cannot replace it.- The boundary’s
uuidis theparentUuidof the immediately followingisCompactSummary:truerecord, without exception. asz pairs the two through that parent. - The summary’s timestamp is always earlier than its own boundary’s, by 1 ms to 1,097 ms on 26 of 26 boundaries in the first pass. Never order an epoch boundary by timestamp.
- The summary carries a
promptIdand noorigin. When no other record of its cycle states a trigger, that cycle starts a Talk, under the rule in Talks and runs. - asz reads only
logicalParentUuid. When it names a record that is not in the landed data, the round carries an openepoch_continuationreference.compactMetadata.preservedMessagesis not read. One pointer is enough:logicalParentUuidequals the last entry ofpreservedMessages.allUuidson 26 of 26 boundaries in the first pass.allUuidscan name records that exist nowhere on disk, 5 in that pass, which is why the pointer is checked rather than trusted.
Talks and runs
Claude Code records neither a Talk nor a Run. Both are built from two of its fields, promptId and
origin, by an exact rule.
- A run is one prompt cycle: one per distinct
promptIdwithin a stream. ApromptIdis not unique across streams, because a child stream is written under a cycle id that also appears in its parent, so the key is the stream and thepromptIdtogether. A model response carries nopromptId, and reaches one through its parents, as the pipeline describes. On this machine on 2026-09-11, none of 192,743 assistant records carried apromptId. In a sample of 400 assistant records, whose pass was not recorded, all 400 reached apromptIdthroughparentUuid, in 1 to 7 hops, with a median of 2. - A cycle’s trigger is
origin.kind, read from the record and then fromattachment.origin. Reading only the record loses 27.6% of all trigger markers.humanis external andtask-notificationis a notification. The value is an open set: a third kind exists, so any other value maps tounknown, and a switch on exactly two values would meet something it does not expect. - On the main stream, a Talk starts when a cycle has an external trigger, or when no record of
the cycle states any trigger. A local slash command writes no
origin, and it is a person acting, so a rule that required an explicit marker would attach it to whatever came before. A cycle whose only stated triggers are notifications or an unknown kind continues the Talk in progress. A child stream is one Talk, created up front, and each of its cycles is a run in it.
The count behind the rule: distinct promptId values equal human cycles, plus notification
cycles, plus cycles with no origin. One sampled session had 65 cycles, 36 human and 29
notification. On one real 59,495-record session, the main stream assembles to 307 Talks from 336
cycles, which equals the number of origin.kind:"human" records counted independently on the raw
file.
The count holds only after duplicate records are removed. A re-emitted copy rewrites origin, so a
Talk splitter that runs before removal over-counts human triggers by up to 15% in the 2,970-file
pass.
Step mapping
| Model kind | Claude Code source | Quality |
|---|---|---|
message.external |
user record with origin.kind:"human" and without isMeta; a queued_command attachment with commandMode:"prompt" |
exact_unique |
message.assistant |
assistant text block |
exact_unique |
message.synthetic |
message.model == "<synthetic>" |
exact_unique |
context.injection |
every other attachment record; a user record with isMeta:true |
exact_unique |
llm.call |
assistant records sharing one message.id in the session |
exact_unique |
thinking |
assistant thinking or redacted_thinking block |
exact_unique. The text is usually unavailable. |
tool |
assistant tool_use block joined to its tool_result block; enriched by toolUseResult |
exact_unique |
agent.call |
a tool_use block that a child stream is joined to |
exact_unique |
agent.launch_ack |
toolUseResult.status:"async_launched" |
exact_unique |
agent.output |
the child stream’s last non-synthetic response, plus the run journal’s result record when there is one | exact_unique |
runtime.notification |
a record whose origin.kind is task-notification, on the record or on attachment.origin |
exact_unique |
epoch.boundary |
system/compact_boundary |
exact_unique |
epoch.summary |
following isCompactSummary:true record |
exact_unique |
error.api |
system/api_error |
exact_unique. The retry state is unavailable. |
control.interrupt |
[Request interrupted by user] |
unavailable: not emitted today |
control.permission |
denial strings, permission-mode records |
unavailable: not emitted today |
control.command |
system/local_command |
exact_unique |
turn.duration |
system/turn_duration |
exact_unique |
Every attachment is injected context by default. There is no list of types, because a list has
to be right about types that do not exist yet. An earlier list was wrong in both directions: 635
records across 13 real attachment types fell through it and were dropped, while 5 types on it
appear nowhere in the corpus. The first pass counted about 21,000 attachment records of 32 types. A
user record with isMeta:true is text the harness wrote in the user role, so it is injected
context too, not something a person said.
One attachment is a person’s input. A queued_command with commandMode:"prompt" is a message
typed while the agent was working. It becomes message.external, and any other mode stays
context.injection. These turns have no .message twin. In the first pass, 614 human-typed turns
existed only as queued_command records, and in the 2,970-file pass a third of all human
utterances lived only there, so a reader that follows messages alone loses them. The mode is needed,
not only the type: of 1,558 unique queued records in the first pass, only 960 were human input, and
reading the type alone would insert 598 machine-generated <task-notification> blobs as things the
person typed. asz.view takes such a turn’s text from the envelope’s
prompt[].text. Most of these turns do not start a Talk. In the 2,970-file pass, 886 of 918
joined a cycle that was already running. Such a record is placed in the enclosing Talk
through its parents, and asz invents no cycle id for it. The scenario
tests/scenarios/attachment-input.yaml checks both modes.
One tool use is one step. The tool_use block supplies name and input. For Bash, the input
is the command itself. The tool_result block supplies the result. The join key is the exact
tool-use id, looked up across every stream of the session, and nothing matches on a prefix. Once
duplicate records are removed the join is one-to-one. Across 2,924 files, 356,583 records and
207,984 tool blocks in the first pass, each of 103,503 keys had exactly one result, and no key was
shared across files. Several results for one id would make the step exact_ambiguous, with an open
tool_result reference. A provider call to its tools is one-to-many, up to 16.
The 2,970-file pass did find tool uses with no result, on child streams only: 7 of 79,418 tool uses
there, each on the last line of its file. On the main stream it was 0 of 25,892. Such a step
carries result: unavailable and an open tool_result reference.
A second join exists in the data and is not used. Every tool result carries a top-level
sourceToolAssistantUUID, and it matched the record holding the corresponding tool_use in 105,409
of 105,409 cases. asz does not read it and does not run that check.
A tool use becomes agent.call only when the spawn join finds a child stream that came out of it.
The kind comes from that join, not from the tool’s name. A tool use whose child never landed stays
tool, and the round carries an open reference, see Agent spawn. The tool that
starts an agent is named Agent. Task appears nowhere as a tool name in the corpus.
toolUseResult enriches the result with structure the content block lacks — stdout/stderr split,
structuredPatch, interruption flags — but almost only on the main stream: present on 100% of
main-transcript tool results and 2.1% of child-stream ones. Everything read from it, including the
spawn joins below, is therefore a main-stream mechanism. How it lands is in
Session Data mapping.
toolUseResult is not always an object. On roughly 7% of results it is a bare string. Decoding it
into a struct fails, and because the failure covers the whole record, every identifier on that record
is lost with it — the tool join, the prompt cycle, the parent link. It must be read as raw JSON and
shape-checked before any field access. asz reads it raw and uses it only when it is an object.
timing is unavailable on every tool step. Claude Code writes no separate execution record
locally, and inferring a duration from the gap between the request and result records would report
queueing and model latency as tool time. Three tools do report a duration the runtime measured,
WebFetch.durationMs, WebSearch.durationSeconds and Agent.totalDurationMs. asz does not read
them today, so those three are unavailable too. A configured timeout is not a measurement.
asz.view reports the time from the request record to the
result record where the join is exact. That is the interval between two records, not execution
time. OTLP’s claude_code.tool.execution span supplies the rest, which is Phase 2.
Absence of is_error does not mean success. It is present on 85.5% of result blocks. asz sets
failed only when the block carries it.
error.api comes from system/api_error alone. asz reads no retry field, so every such step
carries retry_state: unavailable, and no retries relation is written.
control.interrupt and control.permission are not emitted today. An interrupt does not break the
tool join, because the [Request interrupted by user] record is written after the tool result and
the join does not depend on it. The next turn can re-parent to an earlier record, and it is still
placed in its run through its own parents. No cancels relation is written, and the re-parent is
not recorded as a stream discontinuity. The generated glossary keeps the
runtime’s name for both kinds and for cancels, because the runtime has a word for each. Its note
says asz does not read it yet.
Provider calls
Group by message.id across the whole session. Never group by requestId. message.id was
present on 115,162 of 115,162 assistant records in the first pass, and it never appeared in more
than one file, so the fragments of one call all sit in one stream. requestId is absent on some genuine
calls, and a <synthetic> companion record can reuse a real call’s requestId, which would put
fabricated content inside a real provider call. A test on requestId is wrong in both directions:
in the first pass, 24 of 97 synthetic records carried a non-null requestId, and 2 genuine calls
carried none. The synthetic test is message.model == "<synthetic>". A synthetic record can never
join a real call: in the 2,970-file pass, all 122 synthetic records had a UUID-shaped message id,
and all 184,330 real ones start with msg_.
A single response is split across several JSONL lines — 2.09 fragments per call corpus-wide, ranging 1.59 to 2.51 per session. Line order is authoritative. Timestamps are not. Fragments are sorted by landed file and row, which reproduces source order. In the first pass, 372 provider calls had fragments with tied timestamps, and at file level 2.76% of timestamped records were out of order after duplicates were removed. A call’s fragments are not contiguous: on 24% of multi-fragment calls their own tool results sit between them, so grouping must scan by key rather than read a run of lines.
Usage: take the last fragment in LINE ORDER. Never sum, and never use stop_reason to find it.
stop_reason is not a terminal marker. A main transcript stamps the same value on every fragment of a
call, in 23,324 of 23,329 calls in the 2,970-file pass. A subagent transcript stamps it only on the
last. So a stop_reason test picks the first fragment on main and the last on a child. Only line
order works on both.
Summing is wrong for a different reason on each side. Main repeats the final usage on every fragment,
so adding them multiplies the real number by the fragment count — 2.18× pooled, 9× worst case.
Subagent partials climb 1, 2, 3 …, where the last value is the whole count, and summing them adds
only 2%.
About 10% of calls never report a stop_reason (0.02% main, 14.5% subagent, in the 2,970-file
pass). Two consequences:
- Their usage block is present and wrong, a streaming stub. 5,739 of 7,410 such calls have a last
output count of 5 or less, against 101 of 66,751 finished ones. Usage availability is gated on
stop_reason, never on the field being present. - They are not in flight. Only 72 of 7,410 sit at end-of-file; the rest are mid-file with the conversation continuing past them, and 7,311 of them had their tools run. Nothing waits for them, and nothing is marked open.
Every llm.call node in a round carries fragments and usage_from: last_fragment_in_line_order. A
finished call adds usage: observed_replayable and usage_at, the {seq, row} of the fragment the
usage is read from. An unfinished call has usage: unavailable and stop_reason: unavailable.
asz.view copies the token counts from the one record
usage_at names.
Agent spawn
No single field spans the mechanisms, and they do very unequal amounts of work. The share was measured in the 2,970-file pass.
| Mechanism | Parent → child join | Quality | Share |
|---|---|---|---|
| Workflow | the Workflow result’s runId, plus journal.jsonl {type:"started", agentId} naming each child |
strong_inference |
~96% |
| Agent tool | the parent’s toolUseResult.agentId |
exact_unique |
|
| Skill fork | the same field with status:"forked"; no separate pointer to the call, so the tool id comes from the result’s own content block |
exact_unique |
|
| Nesting | the child sidecar’s parentAgentId |
no starts relation, see below |
31 children |
| together | ~4% |
The quality is on the starts relation from the call to the child stream. A journal edge is
strong_inference because the journal names no call. The call comes from the launch result that
named the run: asz resolves the run to the tool use whose result names it, so the edge runs from
that call to the child, with no node standing for the run. An edge from a parent result or from a
completion notification is exact_unique.
Workflow membership comes from the journal, never from agentType. A workflow step can ask for a
named agent type, so agentType does not identify a workflow child, and asz does not read it.
Discovery finds a workflow child under subagents/workflows/<run-id>/, and the run’s journal names
it. In the first pass, each journal.jsonl {type:"started", agentId} matched a child file, 2,583 of
2,583.
agent-<id>.meta.json is not a general parent pointer. It is written once, at spawn: 200 of 200
sampled sidecars were older than their own transcript, none equal and none newer. Its content is
spawn-time only, agentType, description, spawnDepth, toolUseId, parentAgentId and model,
and never a status, a result or a duration. Most sidecars hold only agentType and spawnDepth.
On this machine on 2026-09-11 that was 2,534 of 2,748 sidecars, and 2,517 of them belonged to
workflow children. So most children carry no pointer to their parent in their own sidecar. There is
no registry file. Each agent owns its sidecar. Pairing was exact: 2,697 sidecars to 2,697
transcripts, with no orphan either way.
Beyond which child it belongs to, asz reads two fields from a sidecar. description becomes the
child stream’s label. parentAgentId names the agent that started a nested child, and is the only
evidence of nesting anywhere. asz reads no call id from a sidecar, so a sidecar never anchors a spawn
on a call.
A nested child named only by its sidecar is counted as joined and is not reported as an orphan. But
no starts relation is written for it, because that edge has no call to start from. Like every
child stream it sits under the session. A nested child whose completion notification names both the
call and the child’s agent id gets its starts relation through the notification.
The journal answers one question: which children belong to a run. It carries no timestamp, no name, no parent pointer and no tool id. The launch itself comes from the parent’s result, which names the run. 10.24% of workflow children never get a terminal journal record, so “started with no result” is normal.
toolUseResult.status has three values. async_launched is a launch acknowledgement and not a
result. forked is a skill fork. completed is a synchronous return that IS the result and
carries agentId.
The child owns its output. The child’s messages and tools stay in the child stream. agent.output
is a node in the child stream, with an ends_with relation from the stream to it. It points at the
child’s last non-synthetic response, and at the run journal’s result record when there is one, and
returned_value is then observed_replayable, otherwise unavailable. The journal’s result is what
the child returned, and it is not in the child’s transcript: 2,353 of 2,436 journal results appear
nowhere else, on a snapshot of the development corpus whose size was not recorded. asz does not drop
the parent’s copy. A synchronous return, status:"completed", still appears as the result of the
parent’s agent.call.
Completion arrives as a <task-notification>, and its two ids are not equally good. asz reads
them only from a record whose message content is a plain string. The notification step comes from
the record’s trigger, not from this text, see Step mapping.
<task-id> is a generic task handle. It is an agentId only about 8% of the time, and
otherwise a background-command id, a workflow task id or a monitor id. Joining on it
unconditionally matches the wrong thing most of the time. <tool-use-id> is exact, but it names
the call, not the child, so asz tries the task id first. A single notification can carry several
<task-id> elements. asz takes the first one that has an agent id’s shape, and
links the notification to that child with a reports relation, exact_unique. When that child’s
transcript has not landed, the round carries an open notified_child reference instead. With no
such task id, asz resolves through the call named by <tool-use-id>, whose spawn edge names the
child, and the reports relation is strong_inference.
Two cautions. The <tool-use-id> is not always resolvable in the file it lands in — a nested
child notifies the main session while the call that started it lives in an agent file — so resolution
searches the session’s whole file set. And <status> is absent entirely on a large class of
notifications, so asz does not read it.
The workflow journal leads the filesystem: a started, even a result, can be journalled before
the child transcript exists. That is normal for a live session, not corruption. Once the run is over
it is instead evidence of a pruned or lost file. Until the file appears, the round carries an open
child_stream reference, and a later round resolves it. A batch whose launch call was never landed
is carried as an open spawn_call reference.
A child with no parent at all exists. In the 2,970-file pass, 99.93% of children resolved and two
did not. An orphan stream stays under the session and in no parent’s Talk, and the round carries an
open spawn_of_child reference for it, because guessing a parent is worse than saying none was
found.
Reconstruction rules
Three rules, each required for correctness rather than efficiency.
1. Remove duplicate records by record id, across the whole session, before anything else. Record
ids are not unique within a file. In the 2,970-file pass, 1,533 ids repeated among 318,199, in 11 of
62 main transcripts and 0 of 2,908 subagent transcripts, always with exactly two copies, so one
pass is enough. Every repeat measured was inside one file. No measurement shows an id repeated across
two files, and keying on the id across every file of the session relies on that. A record with no id
is never treated as a copy. The same rule removes a record landed twice when a collector pass is
interrupted between landing a file and saving its cursor. The scenario tests/scenarios/replay.yaml
checks it.
Keep the first occurrence, and take nothing from the later one. The later copy is the worse one:
its promptId is rewritten on 538 of 1,533 pairs, its parentUuid differs on 40, and where both carry
toolUseResult the first is larger on 430 pairs and smaller on zero — 43.4% of captured tool
output would be discarded, with stdout blanked to "" on 314 pairs. The message object itself is
byte-identical on every pair, which is why this looks harmless and is not. The timestamps of the
two copies are identical on 1,533 of 1,533 pairs in the 2,970-file pass, so time cannot say which
copy came first. Nothing distinguishes an original from a copy except line order. The copies stay
in the index as evidence and are counted, and every lookup leaves them out.
Copies also make the tool join look ambiguous when it is not. In the first pass, before removal, 441 tool-use ids joined ambiguously, 882 of 64,722 tool results (1.4%), only because of the re-emitted copies.
These duplicate blocks are not noise. The first pass found 1,530 duplicated records in 10 main
transcripts, and measured all 10. Each block is a re-emission just before a context reset: all 10
end on the line immediately before a compact_boundary, and only 2 of 10 are a dense, contiguous
replay of a prefix. The block is the set of records lying off the following boundary’s ancestry. In
10 of 10 files no duplicate lay outside that set, and in 8 of 10 no record off the ancestry was left
out of the block. 0 of 1,530 duplicated records lie in the ancestry of the following boundary’s
logicalParentUuid. In the two exceptions, 330 and 37 records off the chain were not re-emitted,
and they cluster in whole abandoned runs. That establishes context = chain ancestry ∪ off-chain
siblings, and distinguishes parallel-tool siblings, which are re-emitted and in context, from
abandoned branches, which are dropped.
2. Group a provider call by message.id, in line order. Never rebuild a call or a message list
from parentUuid links. The parentUuid graph is a DAG, not a chain, and two walks over it fail
for two different reasons.
- Walking backward from a call to rebuild its message list misses the sibling branch at a fork.
Parallel tool dispatch forks the graph,
tool_use#1 → {tool_use#2 (same message.id), tool_result#1}, so an ancestor walk traverses one branch and drops the othertool_useand its result. In the first pass, 33,798 of 86,687 provider calls (39%) rebuilt this way carried atool_usewith no matchingtool_result, a body the Messages API would reject. Line-order reconstruction was balanced in 2,622 of 2,623 tool-bearing files. - Walking forward from a call’s first fragment to collect its fragments drops a tool use because
a call’s fragments are interleaved with their own tool results, see
Provider calls. It is not a fork: no fragment has two children sharing its
message id. In the 2,970-file pass, the forward walk dropped a
tool_useon 16,063 of 85,057 tool-bearing calls: 18.9% pooled, 2.1% on the main stream and 25.9% on child streams.
Forks are more common where most calls are. The first pass found 4,073 forks, and every fork had
exactly two children: a tool_use and a tool_result in 3,905, an assistant record and an
api_error in 160. The fork rate was 0.49% on main and 1.69% on child streams, and child streams
hold about 69% of provider calls, so a measurement on main transcripts alone understates the
problem. Message-id grouping does not depend on the parent links at all: fragments of one message id
never interleave with those of another, 0 of 86,716 calls.
The ban is on rebuilding, not on the edge. asz does walk parent links backward, at most 64 hops, to find the run a record belongs to. That walk is single-valued, because a record has exactly one containment parent.
3. Always iterate the full content array. A line usually carries one block but sometimes carries
several, mostly on user records. On this machine on 2026-09-11, 198 records in 2,832 transcript
files carried more than one block: 186 user records and 12 assistant records. 55 of them were
[image, text], where reading content[0] renders a base64 image as the user’s prompt. On 9 of the
12 assistant records a tool_use block came after the first block, so reading only the first would
break the tool join.
Session Data mapping
The adapter converts each record once, while it is landed, into Session Data. This section says what each Claude Code shape becomes. The roles a record plays are in the generated glossary.
Every content block in the 3,032-file pass, 1.1 GB:
| Claude Code block | Keys | Count | Part | State |
|---|---|---|---|---|
tool_result |
content is_error tool_use_id |
108,243 | result |
available |
tool_use |
id name input caller |
108,149 | call, the input in data |
available |
thinking |
thinking signature |
60,382 | reasoning |
available with text, unavailable when empty |
text |
text |
22,458 | text |
available |
image |
source{data, media_type} |
41 | media, the base64 inline in data |
available |
fallback |
from{model} to{model} |
1 | unknown, the bytes kept |
available |
Four of the six cover 99.99% of content blocks. Two more shapes are converted, though that pass
counted none: redacted_thinking becomes reasoning marked redacted, and server_tool_use becomes
a call keyed by its own id. Any other block type becomes unknown and keeps its bytes. Content given
as a plain string, 4,429 records, becomes one text part. An attachment becomes text when it has a
text field or a string content, and otherwise travels whole as a data part. This dialect never
marks a part truncated or omitted.
Where the bytes are, across 62 sessions and 1,100.1 MB of source records:
| What | MB | Share |
|---|---|---|
| record ids, usage blocks and the rest of the envelope | 370.0 | 33.6% |
| tool results, the content block | 334.8 | 30.4% |
| reasoning signatures | 146.4 | 13.3% |
tool results, the toolUseResult copy |
83.8 | 7.6% |
| tool inputs | 75.0 | 6.8% |
| injected context | 58.7 | 5.3% |
| message text | 18.4 | 1.7% |
| assistant text | 14.3 | 1.3% |
| reasoning text | 0.1 | 0.0% |
Tool results are 38.1% of the bytes, more than five times the tool inputs. Message text and assistant text, the text a person reads, are 3.0% together.
Reasoning signatures are not landed. A thinking block’s signature is listed in the record’s
dropped with its size and the reason, “a provider verifies it; a reader cannot read it”. A provider
checks the signature when a block is sent back to it, and no reader can use it. In the table above,
signatures are 13.3% of the bytes and wrap 0.1 MB of reasoning text.
A tool result’s content is not always a string. Over 20,000 tool_result records: string 19,707,
list of image 124, list of text 104, list of tool_reference 92. tool_reference is an undocumented
block, {"type":"tool_reference","tool_name":"Monitor"}, 194 in the corpus. A result takes its
text from the string, or from the text blocks joined. When there is no text, the part keeps the raw
content as data, because the source can be pruned and the landed copy may be the only one. A
reader that treats the content as a string loses the image and reference results.
A Claude Code result carries text and data together. When the record carries a
toolUseResult object, the whole object becomes the result part’s data, beside the text. Both are
kept on purpose. toolUseResult has a different shape for each tool, 49 distinct key sets across the
corpus, which flattening would lose, and it has detail the block lacks: stdout and stderr apart, a
patch, a status. It is also the only full copy of long output. Among 516 records whose output spilled
to a file, the content block was shorter than toolUseResult.stdout by about 28 KB where it had been
cut. The cost is the toolUseResult copy in the table above, 7.6% of the bytes. An Edit or
Write result gains a second data part, a changes/1 record, see
Workspace changes.
Four things are left out with no dropped entry, which breaks the
Dropped rule. They are known gaps:
- a
tool_useblock’scallerfield,{"type":"direct"}on all 108,149 tool uses counted; - a
toolUseResultthat is a plain string, 7.2% of results; - the raw content of a result with no text, when the record also carries a
toolUseResultobject, which takesdatain its place; - the other fields of a record that becomes parts, which is a
userorassistantmessage or an attachment.
Such a record lands with id, parent, call, run, continues, tool, child, batch,
started_by, label, time, trigger, model, usage, flags, from and its parts, and
nothing else. requestId, isSidechain, cwd, gitBranch, slug, version, userType and
entrypoint do not land, and neither does the rest of an attachment that has text. They are gone
once Claude Code prunes the source. The in-record sessionId and agentId do not land either, but
the landed header names the same session and stream, see Execution streams.
cwd is read only to make a changed file’s path relative. A record with no message content and no
attachment travels whole as one data part, so all its fields are kept. System records, sidecars,
journal lines and manifests are of this kind.
An MCP tool use is an ordinary call. Its server is only a prefix on the name,
mcp__<server>__<tool>. This was observed on 39 of 26,940 tool uses (0.1%), too few to state as a
rule.
Five record fields come from places the glossary does not list:
label:aiTitleon the runtime’s title record, elsedescriptionon a child’s sidecar, elseworkflowNameon a workflow manifest.started_by: a nested child’s sidecarparentAgentId.tool:toolUseResult.toolUseIdon a tool result, else the<tool-use-id>of a completion notification. The glossary’stoolrow is the step kind of the same name, so it does not list this role’s source.from: the record type.assistantisagent,userisexternal, andsystemandattachmentareruntime, as is every record of a sidecar, journal, manifest or script. Sofrom: externalalso covers every tool result and everyisMetarecord, which sit onuserrecords. The flags and the parts say which is which.usage:message.usage, the input, output, cache read and cache write tokens.
Names
Three runtime fields carry names, and the page adds a fourth source.
- The conversation’s title is
aiTitle, on the runtime’s title record. The runtime rewrites it as the work goes on, so the session node takes the last one written, and asz.view shows it assummary.title. - A child stream’s label is
descriptionin itsagent-<id>.meta.json. - A workflow’s name is
workflowNamein its manifest. It is shown on the call that launched the workflow. - A workflow child with no label is named from its run journal’s result row: its
surface, else itssummary, else itsverdictand the first refuted claim.asz.viewmarks such a streamnamed_by: journal. A workflow child’s own records never say what it was for. Over 178 children of one conversation, 177 opened with byte-identical injected context.
A title was present in 41 of 42 conversations, and system/turn_duration on 465 turns, in a sample
whose selection was not recorded. asz.view shows a turn’s duration_ms as the runtime measured it.
Workspace changes
Which files a tool call changed comes from two places, and lands in one shape, changes/1, so a
view shows one Changes tab whichever recorded it.
The runtime’s own patch. Every successful Edit and Write result on the main stream carries
toolUseResult.structuredPatch, filePath and originalFile, measured on 2,221 of 2,221 such
results in a 52-session corpus. A NotebookEdit result carries notebook_path, original_file
and updated_file and no patch, measured on a run of Claude Code 2.1.260. The adapter copies them
into a changes/1 record as a second data part beside the raw result, with the tool-use id as
its id, captured_by: claude-code, basis: runtime_reported, the path relative to the record’s
cwd, the hunks as the runtime wrote
them, or computed from the two versions when it wrote none, and the content before and after
hashed. The raw result stays beside it as the JSON Claude Code wrote, apart from whitespace between
tokens (see What data holds). A subagent’s transcript
carries no such patch: 0 of 453 in the same corpus.
In the 2026-09-12 source sample described on that page, all 32,887 tool result data parts from main transcripts, 164 from direct subagents and 166 from workflow subagents landed byte for byte. These counts include the structured result or content that is not text. The earlier encoding kept 21,569, 43 and 166 of them byte for byte, respectively.
The plugin’s observations. The asz Claude Code plugin
records shell commands, and edits inside subagents, into its own data directory beside Claude
Code’s files. The claude-code-changes adapter, on by default, finds them the way this adapter
finds transcripts, resolves plugins/data under the same Claude Code directory, and lands each
line as a record of kind changes under the stream the tool ran in, with the session’s own lock
and sequence. Session filters apply to the workspace the records name.
Neither is a step. Assembly leaves a changes record out of its stream, so a session folds to
the same nodes with and without them, and the view joins each record to its step by the
tool-use id. The scenario tests/scenarios/workspace-changes.yaml checks both paths in both
formats, and that the fold is unchanged.
What this adapter cannot supply
| Why | |
|---|---|
| Reasoning text | Claude Code asks the provider not to return it, so most thinking blocks carry only a signature. unavailable where the runtime wrote none. See below. |
| Serialized request | system prompt, tool schemas and cache annotations are absent from transcripts: 0 files contain "tools":[, and compactMetadata.preservedMessages.allUuids names 5 ids that exist nowhere on disk. asz produces no input manifest and no input_of relation, and reports model-context coverage as unavailable, as the model’s adapter contract requires. |
| Injected preamble | the instruction block prepended to the first user message has no transcript record. Its data survives as attachment records; its rendered form does not. |
| Per-call duration and cost | not written to transcripts. Available via OTLP. |
tool.execution |
no local record; the call and result are observable, the execution is not. |
| Retry attempt identity | derivable only for failures that received an HTTP response: in the first pass, 48 of 556 api_error records carried a real request id in error.requestId, and transport failures carry none. asz does not derive it yet. |
| What a shell command changed | not in the transcript, which holds the command and its output only. Available from the plugin. The same for an edit inside a subagent, whose transcript carries no patch. |
Reasoning text is kept wherever the runtime wrote it, and whether it did depends on the model. In
the first pass, 2,603 of 2,604 files with thinking blocks had empty text. The one exception was a
subagent on claude-haiku-4-5-20251001, with 31 blocks of 61 to 1,742 characters. In the same
session at 2.1.227, eleven sibling agents of the same type on claude-opus-5 kept none. A live
capture showed the cause: the request sends thinking.display:"omitted". Raw-body capture on 2.1.245
redacts the text again, and OTLP events always redact it, so it is not recoverable by any local
mechanism. An empty block is marked unavailable, and a redacted_thinking block is marked
redacted.
The injected preamble was measured on captured request bodies from one development machine. It is
one 8,338-character <system-reminder> block wrapping the instruction file, the user’s email and the
current date. A search of the transcripts’ fields finds it on no record, and subagents receive it
too. The attachment data behind it survives, 88.8% to 98.7% recoverable, but the rendered wrapper and
its ordering do not. The tool list also changes between adjacent calls through deferred loading, from
96 to 116 tools one turn apart, and only a deferred_tools_delta attachment records it.
The injected preamble is a fixed cost — roughly 8 KB without a project instruction file, up to ~40 KB with one — so its share of a conversation falls as the conversation grows. It is not a proportional loss. In a real session holding 12,027,973 characters of conversation content it is about 0.3%. A 26 KB probe session would suggest 99%, and is not representative.
The mapping from injected records to request blocks is beta-gated and varies between client
versions. On one machine on one day, two schemes were seen. Without the
mid-conversation-system-2026-04-07 beta, attachments become separate reminder-wrapped user text
blocks placed before the prompt. That is the reverse of the transcript, which writes the user record
first with its attachments as descendants. With the beta, they merge into one 13,953-character
role:"system" message placed after the user message. The active beta list is not recorded, so a
transcript-only reconstruction of the exact request message list is not possible. Verifying
model-context continuity against what was actually sent requires captured provider bodies.
OTLP (optional second channel)
Claude Code exports OpenTelemetry when CLAUDE_CODE_ENABLE_TELEMETRY=1 and the relevant exporter
variables are set. It adds measurement only:
- per-call
duration_msandcost_usd, tool durations and error types tool_decisionwith a compositesource(hook:/rule:/mode:)
It contributes no structure. Events carry no agent identity — query_source is normalised to
main | subagent | auxiliary, and the child-completion event carries no agent id at all. Agent
lineage exists only on spans, which require an additional beta flag; several span names present in
the binary are unreachable in shipped builds.
Its content defaults are restrictive, as Collection describes.
The same metrics from the transcripts
With metrics: true, this adapter derives a reconstructed subset of the runtime’s own metric
family from what it landed, under the exporter’s metric name, so a receiver holds one name
whichever produced the points. Phase one is claude_code.token.usage: the usage of a call is its
last fragment’s in line order, as the assembler reads it, and only a finished call counts;
query_source is main for the session’s own transcript and subagent for a child’s; model
is message.model; session.id is the session. The exporter’s account, organisation, terminal,
speed, effort and attribution labels are not on a transcript and are not added, and neither are
its auxiliary calls. Points are monotonic delta sums per minute whose windows never overlap, a
double for every type as the exporter writes them, and a call is counted once however many
landed files its records reach. A capture of the exporter’s own request is the parity fixture
the derivation is tested against.
What the transcripts do not carry is not derived: cost, per-request latency, tool execution time,
active time, lines of code, commits, pull requests, the session start type, and the tokens of the
runtime’s auxiliary calls, which never reach a transcript. Those come from the exporter alone.
The first derivation over a root with history reaches back metrics_lookback, 24 hours unless
set. See Metrics.
The runtime’s exporter, received
claude-code-otlp is the other adapter for this runtime: an OpenTelemetry receiver its
exporter is pointed at, over gRPC or HTTP with protobuf on one port. Phase one lands the metrics
requests it receives in the spool, bytes as received, and asz push sends them under asz’s
identity; logs and traces are accepted and dropped. It lands no transcript and reads none, so it
adds no structure; the two adapters meet only in the spool, and metrics is on for one of them.
The runtime’s events, with the latency and cost a transcript never carries, are the second phase,
and the question there is whether they land as Session Data joined to llm.call by the request
id or travel untouched.
The receiver speaks OTLP because Claude Code has exactly one outward protocol. Its exporter setting
accepts console, otlp or prometheus for metrics, and console or otlp for logs and traces.
Any other value throws. There is no webhook and no custom sink. This was read from the Claude Code
binary. The version was not recorded.
Local data hazards
Behaviours that will break a naive reader:
- Session ids are not reliably lowercase. 2 of 47 transcripts used an uppercase UUID as their
session id, for example
41090DAB-113C-…. Discovery accepts either case, and joins must be byte-exact and never case-folded. - Lines can be very large. The longest measured is 4,629,074 bytes, and 22 lines are over 1 MiB, on the 60-session storage root that Storage Root measured on 2026-09-11. Files reach 59 MB. A scanner with a 64 KB token limit fails silently.
cwdandgitBranchare per-record, not session constants; one session showed 52 distinctcwdvalues. OnlysessionId,entrypointanduserTypeare reliable per-file constants.- A large share of lines carry no record id at all. In the first pass, 38,340 of 133,887
main-transcript lines (28.6%) had no
uuid, across 14 metadata-only record types, none of them context-bearing. A parser assuming every line has one will fail. asz lands such a record at its position, and never treats two records without an id as copies of each other. - Some files contain no conversation records at all. In the first pass, 177 of 2,935 files held
no
user,assistantorsystemrecord, only metadata types. asz treats every<session-id>.jsonlas a session regardless: it lands and assembles the file, and the list shows the result as a conversation with no Talks. How many of the 177 were main transcripts was not measured. - Never match a tool-use id on its prefix. An id starting
srvtoolu_appears in the corpus, but the 2,970-file pass found it only inside aWebSearchresult payload, one level below the tool call, where nothing joins on it. The join key is the exact id. - The corpus is live. Files appear and grow during a scan; counts are snapshots.
toolUseResultis a main-stream enrichment. On workflow children only the error-string form survives, and the key is absent rather than null.