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
  }
}
FieldTypeDescription
idstringFirst 12 chars of conversation UUID (+ _s1, _s2 if split)
modelsstring[]All model IDs used in conversation
block_sizeintToken block size (64 recommended)
tool_tokensintToken count for tools section (shared prefix)
system_tokensintToken count for system prompt (shared prefix)
requestsarrayOrdered list of request records
_analysisobjectBuild 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"
}
FieldTypeDescription
tfloatSeconds from conversation start
typestring"s" = streaming, "n" = non-streaming, "subagent" = nested. Older proxy data produces "n" (non-streaming has complete metadata); fixed proxy data produces "s"
modelstringModel ID for this request
inintInput token count
outintOutput token count
hash_idsint[]Ordered block hash IDs for prefix-based cache matching
input_typesstring[]What the client added this turn
output_typesstring[]Content types in Claude's response
stopstringStop reason: "", "tool_use", "end_turn"
api_timefloat?Total response time in seconds (from proxy responseTime)
ttftfloat?Time to first token in seconds (from Server-Timing header). Captures server-side latency before the first token
think_timefloat?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):

Conditioninput_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

  1. Extract content sections in order: tools → system → messages
  2. Normalize — recursively remove cache_control and signature fields
  3. Serializejson.dumps(obj, separators=(',', ':'))
  4. Tokenize — using tiktoken gpt-4 encoding
  5. Block — split into block_size token blocks (full blocks only, partial discarded)
  6. 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:

PatternFrequencyDescription
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_tokens from its first request
  • Each segment's t values 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

FieldTypeDescription
typestringAlways "subagent"
tfloatWhen spawned (seconds from parent conversation start)
agent_idstring7-8 char identifier
subagent_typestring"Explore", "general-purpose", "Plan", etc.
duration_msintTotal execution time in milliseconds
total_tokensintTotal tokens consumed
tool_use_countintNumber of tool calls made
statusstring"completed", "failed", or "timeout"
modelsstring[]Models used (often different from parent)
tool_tokensintSub-agent's tool definition tokens
system_tokensintSub-agent's system prompt tokens
requestsarrayNested 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'])