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.
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>/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.
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.
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.
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 — the great majority of session ids known to its prompt history have none. A session directory whose main transcript has been deleted while its child streams survive is a normal state, not corruption.
Object mapping
| Model object | Claude Code | Notes |
|---|---|---|
| Conversation | sessionId (default) |
overridable when an application supplies an explicit id |
| Segment | derived | an activity window chosen by the assembler; not a runtime concept |
| Session | sessionId |
1:1 with Conversation under the default mapping |
ExecutionStream main |
<session-id>.jsonl |
the only stream that compacts |
| ExecutionStream child | agent-<agent-id>.jsonl |
shares the parent sessionId |
| Context epoch | split by system/compact_boundary |
main stream only |
| Talk | derived from the input → output span | not a runtime concept |
| Run | derived | not a runtime concept |
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.
Applications that consider several /clear-separated sessions to be one piece of work must supply an
explicit conversation id.
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.
| main | child | |
|---|---|---|
sessionId |
S | S — identical |
isSidechain |
absent | true |
top-level agentId |
absent | ^a[0-9a-f]{16}$ |
Nesting stops at depth 2: of 2,686 agents, 2,654 are depth 1 and 31 are depth 2, each carrying
parentAgentId. No depth ≥ 3 exists.
Child streams are structurally simpler than main: exactly one root per file, no duplicate record ids, no compaction at all, and no rewind forks. Their model context accumulates without interruption for the life of the stream.
Context epochs
system/compact_boundary is an explicit, machine-readable epoch boundary — the model never has to
infer one. It carries compactMetadata plus logicalParentUuid, a pointer to the last
pre-compaction message that appears on no other record type anywhere.
- The boundary’s
uuidis theparentUuidof the immediately followingisCompactSummary:truerecord, without exception. - The summary’s timestamp is always earlier than its own boundary’s, by 1 ms to 1,097 ms. Never order an epoch boundary by timestamp.
preservedMessages.allUuidsmay contain ids that exist nowhere on disk — records held in context but never written. Both those entries andlogicalParentUuidmust be typed unresolved-capable.- The preserved segment does not necessarily abut the boundary; resolve by id, never by proximity.
Step mapping
| Model kind | Claude Code source | Quality |
|---|---|---|
message.external |
user record, origin.kind:"human" |
exact_unique |
message.assistant |
assistant text block |
exact_unique |
message.synthetic |
message.model == "<synthetic>" |
exact_unique |
context.injection |
attachment records, 32 types |
exact_unique |
llm.call |
(file, message.id) group |
exact_unique |
thinking |
assistant thinking block — signature only |
unavailable (content) |
tool |
assistant tool_use block joined to its tool_result block; enriched by toolUseResult |
exact_unique |
agent.call |
tool_use named Agent / Workflow / Skill fork |
exact_unique |
agent.launch_ack |
toolUseResult.status:"async_launched" |
exact_unique |
agent.output |
final assistant output of the child stream | exact_unique |
runtime.notification |
<task-notification> block |
exact_unique |
epoch.boundary |
system/compact_boundary |
exact_unique |
epoch.summary |
following isCompactSummary:true record |
exact_unique |
error.api |
system/api_error + retryAttempt |
exact_unique |
control.interrupt |
[Request interrupted by user] |
exact_unique |
control.permission |
denial strings, permission-mode records |
strong_inference |
control.command |
system/local_command |
exact_unique |
turn.duration |
system/turn_duration |
exact_unique |
One tool use is one step. The tool_use block supplies name and input — for Bash, the command
itself — and the tool_result block supplies the result. The join is exact and one-to-one: corpus-wide
every (file, tool_use_id) maps to exactly one result once duplicate records are removed. A provider
call to its tools is one-to-many, up to 16.
A second, fully redundant join exists: 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. It is a free integrity check on the id join.
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.
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.
timing is unavailable. 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 are the exception and do report a real duration — WebFetch.durationMs,
WebSearch.durationSeconds, Agent.totalDurationMs. A configured timeout is not a measurement.
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.
Provider calls
Group by (file, message.id) — never by requestId. message.id is present on every assistant
record; 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 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, and ties occur. A call’s fragments are not contiguous: on 24% of multi-fragment calls their own tool results sit between them, so grouping must scan the whole file by key rather than 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; 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.
About 10% of calls never report a stop_reason (0.02% main, 14.5% subagent). Two consequences:
- Their usage block is present and wrong — an output count of one to five, a streaming stub. 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 their tools did run. Nothing waits for them.
origin is not in one place: it sits on the record and also on attachment.origin, and reading only
the first loses 27.6% of all trigger markers. Its value is an open set — a third kind exists beyond
human and task-notification — so a switch on exactly two values will meet something unexpected.
Agent spawn
No single field spans the mechanisms, and they do very unequal amounts of work.
| Mechanism | Parent → child join | Share |
|---|---|---|
| Workflow | the Workflow result’s runId, plus journal.jsonl {type:"started", agentId} naming each child |
~96% |
| Agent tool | the parent’s toolUseResult.agentId |
|
| 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 |
|
| Nesting | the child sidecar’s parentAgentId |
31 children |
| together | ~4% |
agent-<id>.meta.json is not a general parent pointer. It normally holds only
{agentType, spawnDepth}. It names a parent for a nested child and nothing else, so it is a 3.3%
edge case rather than a primary join. Without parentAgentId a child of a child is attributed to the
main stream, flattening all 31 depth-2 spawns. spawnDepth is absent on one sidecar and must default
to 1.
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.
Completion arrives as a <task-notification>, and its two ids are not equally good.
<task-id> is a generic task handle — an agentId only about 8% of the time, 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 and is the primary key. A single notification
can carry several <task-id> elements.
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
must search the session’s whole file set. And <status> is absent entirely on a large class of
notifications; requiring it will fail.
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.
A child with no parent at all exists — two in the corpus. It is a state to record, not an assumption to assert.
Reconstruction rules
Three rules, each required for correctness rather than efficiency.
1. Drop duplicate records by (file, uuid) before anything else. Record ids are not unique within
a file: 1,533 repeated ids among 318,199, in 11 of 62 main transcripts and 0 of 2,908 subagent
transcripts, always with multiplicity exactly 2 — so one pass is enough.
Keep the first occurrence, and not only for position. 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. Nothing distinguishes an
original from a copy except line order.
These duplicate blocks are not noise. Each is a pre-compaction re-emission of exactly the set of records lying off the following boundary’s ancestry — which independently establishes that context = chain ancestry ∪ off-chain siblings, and distinguishes parallel-tool siblings (in context) from abandoned branches (dropped).
2. Assemble by (message.id, line order), never by walking parentUuid. The parentUuid graph is
a DAG, not a chain: parallel tool dispatch forks it, so an ancestor walk traverses one branch and
silently drops the other tool_use and its result.
Ancestor-walk reconstruction produces a message list containing a
tool_usewith no matchingtool_result— a body the Messages API would reject — on 39% of provider calls. Line-order reconstruction is balanced in 2,622 of 2,623 tool-bearing files.
Every fork has exactly two children. Treat a fork as a topological hint, not a branch to choose.
3. Always iterate the full content array. A line usually carries one block but sometimes carries
several, mostly on user records — including [image, text], where reading content[0] renders a
base64 image as the user’s prompt.
What this adapter cannot supply
| Why | |
|---|---|
| Reasoning text | Claude Code sends thinking.display:"omitted", so the provider returns only a signature. Raw-body capture redacts it again. unavailable — not recoverable by any local mechanism. |
| Serialized request | system prompt, tool schemas and cache annotations are absent from transcripts. input_manifest is truncated, never full, with inferred message membership. |
| 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; transport failures carry no request id. |
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.
Because the mapping from injected records to request blocks is beta-gated and varies between client versions, and the active beta list is not recorded, 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.
Content defaults are also restrictive: prompts and responses are redacted unless explicitly enabled, content is truncated, and reasoning is always redacted.
Local data hazards
Behaviours that will break a naive reader:
- Session ids are not reliably lowercase. Joins must be byte-exact and never case-folded.
- Lines can be very large — the longest measured is 979,632 bytes, and 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 — metadata-only types that are not context-bearing. A parser assuming every line has one will fail.
- Some files contain no conversation records at all, holding only metadata types. Treating every
.jsonlas a session produces empty conversations. tool_use_idis not always prefixedtoolu_— server-side tools use a different prefix.- 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.