| # Claude Code Adapter |
| |
| How the [Unified Conversation Model](../concepts-and-designs/unified-conversation-model.md) 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. |
| |
| ```text |
| ~/.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](#otlp-optional-second-channel). |
| |
| ### 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 `uuid` is the `parentUuid` of the immediately following `isCompactSummary:true` |
| record, 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.allUuids` may contain ids that exist nowhere on disk — records held in context |
| but never written. Both those entries and `logicalParentUuid` must 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_use` with no matching |
| > `tool_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. |
| |
| ## 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 byte for byte. A subagent's transcript carries no such patch: 0 of |
| 453 in the same corpus. |
| |
| **The plugin's observations.** The [asz Claude Code plugin](../setup/claude-code-plugin.md) |
| 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 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. | |
| | **What a shell command changed** | not in the transcript, which holds the command and its output only. Available from the [plugin](../setup/claude-code-plugin.md). The same for an edit inside a subagent, whose transcript carries no patch. | |
| |
| 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_ms` and `cost_usd`, tool durations and error types |
| - `tool_decision` with a composite `source` (`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. |
| |
| ### 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](../setup/export-otlp.md#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. |
| |
| ## 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. |
| - **`cwd` and `gitBranch` are per-record**, not session constants; one session showed 52 distinct |
| `cwd` values. Only `sessionId`, `entrypoint` and `userType` are 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 |
| `.jsonl` as a session produces empty conversations. |
| - **`tool_use_id` is not always prefixed `toolu_`** — server-side tools use a different prefix. |
| - **The corpus is live.** Files appear and grow during a scan; counts are snapshots. |
| - **`toolUseResult` is a main-stream enrichment.** On workflow children only the error-string form |
| survives, and the key is *absent* rather than null. |