Trace Format Specification
April 14, 2026 · View on GitHub
Traces are compact JSON files that capture the cache block structure of real Claude Code conversations. They contain everything needed to simulate cache behavior without storing actual message content.
Top-Level Schema
{
"id": "8712b46f-04e",
"models": ["claude-sonnet-4-20250514"],
"block_size": 64,
"tool_tokens": 11880,
"system_tokens": 3427,
"requests": [...],
"_analysis": {
"indexed": 1341,
"total": 2363,
"unique_blocks": 9879
}
}
| Field | Type | Description |
|---|---|---|
id | string | First 12 chars of conversation UUID (+ _s1, _s2 if split) |
models | string[] | All model IDs used in conversation |
block_size | int | Token block size (64 recommended) |
tool_tokens | int | Token count for tools section (shared prefix) |
system_tokens | int | Token count for system prompt (shared prefix) |
requests | array | Ordered list of request records |
_analysis | object | Build metadata (indexed count, total requests, unique blocks) |
Shared Prefix Fields
tool_tokens and system_tokens represent the shared prefix (~15K tokens) that stays warm in the API's global cache across all Claude Code sessions. For replay, optionally pre-populate cache with the first N blocks:
warm_prefix_blocks = (trace['tool_tokens'] + trace['system_tokens']) // trace['block_size']
Request Schema
{
"t": 0.0,
"type": "s",
"model": "claude-sonnet-4-20250514",
"in": 19105,
"out": 297,
"hash_ids": [1, 2, 3, "...", 298],
"input_types": ["text"],
"output_types": ["thinking", "text", "tool_use"],
"stop": "tool_use"
}
| Field | Type | Description |
|---|---|---|
t | float | Seconds from conversation start |
type | string | "s" = streaming, "n" = non-streaming, "subagent" = nested. Older proxy data produces "n" (non-streaming has complete metadata); fixed proxy data produces "s" |
model | string | Model ID for this request |
in | int | Input token count |
out | int | Output token count |
hash_ids | int[] | Ordered block hash IDs for prefix-based cache matching |
input_types | string[] | What the client added this turn |
output_types | string[] | Content types in Claude's response |
stop | string | Stop reason: "", "tool_use", "end_turn" |
api_time | float? | Total response time in seconds (from proxy responseTime) |
ttft | float? | Time to first token in seconds (from Server-Timing header). Captures server-side latency before the first token |
think_time | float? | Client delay before this request in seconds (gap since previous response completed) |
input_types Values
Shows what the client added for this specific request (not cumulative):
| Condition | input_types |
|---|---|
| First request | ["text"] |
After tool_use stop | ["tool_result"] |
After end_turn stop | ["text"] |
| Major context reset (<50% hash preserved) | Full content types from messages |
output_types Values
Content types Claude returned:
"text"— Text content"thinking"— Extended thinking block"tool_use"— Tool call
Hash ID Generation Algorithm
Steps
- Extract content sections in order: tools → system → messages
- Normalize — recursively remove
cache_controlandsignaturefields - Serialize —
json.dumps(obj, separators=(',', ':')) - Tokenize — using tiktoken
gpt-4encoding - Block — split into
block_sizetoken blocks (full blocks only, partial discarded) - Chain hash — each block depends on the previous block's hash, salted with a random value generated once per trace-building run
def create_chained_hash(token_ids, prev_hash, seq_num, salt):
content = f"{salt}:{prev_hash}:{seq_num}:" + " ".join(map(str, token_ids))
return hashlib.sha256(content.encode()).hexdigest()[:16]
Salt
A random salt (secrets.token_hex(16)) is generated once per trace-building run and used in every hash computation. The salt is never written to the output trace files — it exists only during generation. This prevents confirmation attacks where someone could tokenize known content and check whether its hash appears in an anonymized trace. The replay tool is unaffected because it only compares pre-computed hash_ids, never recomputes them.
Hash ID Assignment
Each unique hash string gets a sequential integer ID (starting at 1). Currently, IDs are assigned per-conversation — the same content in different conversations gets different numeric IDs.
Why Full Blocks Only
Partial blocks (< block_size tokens) at the end are discarded because the last partial chunk changes every turn as content grows. This would cause false cache misses. Full blocks provide stable, reproducible hashes.
Why Chained Hashing
Each block's hash includes the previous block's hash, which encodes position. This means identical content at different positions produces different hashes — matching real cache behavior where prefix position matters.
Hash Evolution Patterns
From real conversation data:
| Pattern | Frequency | Description |
|---|---|---|
| Suffix growth (90-99% prefix match) | ~98% | Normal turn growth — new messages appended |
| Major reset (<50%) | 1.7% | Context restructure or conversation restart |
The "Last Chunk Different" Pattern
When cache_control moves to a new last block between requests, the previous last block's hash changes because normalization removes cache_control from the content being hashed. This causes exactly one block to differ at the boundary — this is expected behavior.
Conversation Splitting
When generated with --split-at-gap, long conversations are split when the time gap between requests exceeds a threshold:
- Trace IDs get
_s1,_s2, etc. suffix (e.g.,f99f3001-351_s1) - Each segment has its own
tool_tokens/system_tokensfrom its first request - Each segment's
tvalues restart from 0.0
Sub-Agent Format
When generated with --include-subagents, traces can contain nested sub-agent conversations:
{
"t": 82075.0,
"type": "subagent",
"agent_id": "96624242",
"subagent_type": "Explore",
"duration_ms": 120012,
"total_tokens": 83681,
"tool_use_count": 22,
"status": "completed",
"models": ["claude-haiku-4-5-20251001"],
"tool_tokens": 7810,
"system_tokens": 730,
"requests": [
{"t": 0.0, "type": "s", "in": 9969, "out": 247, "hash_ids": [...]},
{"t": 3.1, "type": "n", "in": 9969, "out": 247, "hash_ids": [...]}
]
}
Sub-Agent Fields
| Field | Type | Description |
|---|---|---|
type | string | Always "subagent" |
t | float | When spawned (seconds from parent conversation start) |
agent_id | string | 7-8 char identifier |
subagent_type | string | "Explore", "general-purpose", "Plan", etc. |
duration_ms | int | Total execution time in milliseconds |
total_tokens | int | Total tokens consumed |
tool_use_count | int | Number of tool calls made |
status | string | "completed", "failed", or "timeout" |
models | string[] | Models used (often different from parent) |
tool_tokens | int | Sub-agent's tool definition tokens |
system_tokens | int | Sub-agent's system prompt tokens |
requests | array | Nested request array (same schema as parent) |
Aggregate Totals
Traces with sub-agents include a totals field:
{
"totals": {
"parent_tokens": {"input": 48953966, "output": 127608},
"subagent_tokens": {"input": 1146338, "output": 10819},
"combined_tokens": {"input": 50100304, "output": 138427},
"subagent_count": 1
}
}
Sub-Agent Cache Behavior
Sub-agents run in isolated cache contexts:
- They start fresh — they do NOT inherit the parent's cache
- Only the global prefix (tools + system, ~8-12K tokens) may be warm
- Parent's cache remains warm during sub-agent execution
- Sub-agent's cache is discarded after completion
def simulate_cache(trace):
parent_cache = set()
for req in trace['requests']:
if req['type'] == 'subagent':
subagent_cache = set() # Fresh cache
for subreq in req['requests']:
hits = count_prefix_match(subreq['hash_ids'], subagent_cache)
subagent_cache.update(subreq['hash_ids'])
# subagent_cache discarded here
else:
hits = count_prefix_match(req['hash_ids'], parent_cache)
parent_cache.update(req['hash_ids'])
Backward Compatibility
Traces without sub-agents have no type: "subagent" requests and no totals field. To check:
has_subagents = any(r.get('type') == 'subagent' for r in trace['requests'])