The live cache autopsy: the cache, proven on your own machine

TL;DR. Every transcript Claude Code writes contains the API's own per-call usage block, which makes your disk a complete record of what the prompt cache did on every turn you ever ran. This chapter dissects one real 272-call working session from this repository: 97.5% cache hit rate, $110.68 spent where no-cache would have been $680.83 (6.2x), every write bought at the 1-hour TTL, a peak prompt of 960k tokens, and exactly 6 invalidation events, five of them TTL lapses from walking away, which together re-wrote 2.06M tokens at 2x and cost about $19.61 that staying warm would have made $1.03. Then the chapter turns each finding into a proof session: short, exact claude -p experiments that demonstrate cache existence, TTL expiry, invalidation-by-edit, and the /clear-versus-/compact difference on your own transcripts, so no cache claim in this book has to be taken on faith.

Contents

Chapter 43 gave the ideas and the people; this chapter is the evidence. Chapter 24 derived the cache rules from constructed prompts and Chapter 25 built the whole-machine ledger; what has been missing is the middle scale: one session, call by call, watching the cache warm up, serve, and break. That is an autopsy, and the body is already on your disk.

The instrument you already have

Claude Code logs every session to ~/.claude/projects/<project>/<session-id>.jsonl, and every assistant line carries the message.usage block the API returned: input_tokens (full price), cache_creation_input_tokens (split by TTL under cache_creation), cache_read_input_tokens (0.1x), output_tokens. Two parsing facts matter and the script handles both: a message id repeats once per content block with identical usage (deduplicate on id), and subagent sidechain lines are marked so they can be excluded. The result is a per-API- call cache history nobody had to instrument for: the transcript is the instrument.

The autopsy

"""The cache autopsy: one real session's cache lifecycle, from the transcript.

Chapter 25 built a ledger over every transcript on the machine; this script
goes the other way and dissects ONE session, API call by API call, to show the
prompt cache doing its job and occasionally losing it:

  cold start   the first call writes the whole prompt to cache
  extend       the normal turn: the old prefix is read at 0.1x, only the new
               chunk is written
  invalidation cache_read DROPS while cache_creation spikes: the prefix
               changed upstream (tool list, CLAUDE.md, system), the TTL
               lapsed while you were away, or the context was compacted

It also settles which TTL Claude Code buys (the usage block splits writes into
ephemeral_5m and ephemeral_1h) and prices the session against a no-cache
counterfactual.

Run:  python3 cache_autopsy.py [transcript.jsonl]
Default: the largest transcript for this repo's project directory.
Standard library only. The format is internal to Claude Code; the parser
reads only message.usage (the API's own response shape) and skips the rest.
"""

import json
import re
import sys
from datetime import datetime
from pathlib import Path

PRICES = {"claude-opus-4-8": (5.00, 25.00), "claude-opus-4-7": (5.00, 25.00),
          "claude-sonnet-5": (3.00, 15.00), "claude-sonnet-4-6": (3.00, 15.00),
          "claude-haiku-4-5": (1.00, 5.00), "claude-fable-5": (10.00, 50.00)}

def pick_default():
    """Claude Code munges the project cwd into a directory name; find the
    current repo's transcripts and take the biggest session.

    Walks up from the cwd, because you usually run this from a subdirectory
    (this file lives in code/) while the transcripts are keyed on the project
    root you launched Claude Code from.
    """
    for d in [Path.cwd(), *Path.cwd().parents]:
        root = Path.home() / ".claude/projects" / re.sub(r"[/.]", "-", str(d))
        sessions = list(root.glob("*.jsonl"))
        if sessions:
            return max(sessions, key=lambda p: p.stat().st_size)
    sys.exit(
        "no Claude Code transcripts found for this directory or any parent.\n"
        "pass one explicitly:\n"
        f"    python3 {Path(__file__).name} ~/.claude/projects/<project>/<session>.jsonl"
    )

def load_calls(path):
    """One entry per API call: transcripts repeat a message id once per
    content block, with identical usage, so dedup on id keeping the first."""
    calls, seen = [], set()
    for line in open(path, errors="replace"):
        try:
            d = json.loads(line)
        except json.JSONDecodeError:
            continue
        if d.get("type") != "assistant" or d.get("isSidechain"):
            continue
        m = d.get("message") or {}
        u, mid = m.get("usage"), m.get("id")
        if not u or mid in seen:
            continue
        seen.add(mid)
        w = u.get("cache_creation") or {}
        calls.append(dict(
            ts=datetime.fromisoformat(d["timestamp"].replace("Z", "+00:00")),
            model=m.get("model", "?"),
            inp=u.get("input_tokens", 0),
            w5=w.get("ephemeral_5m_input_tokens", 0),
            w1=w.get("ephemeral_1h_input_tokens", 0),
            cc=u.get("cache_creation_input_tokens", 0),
            cr=u.get("cache_read_input_tokens", 0),
            out=u.get("output_tokens", 0)))
    return calls

def classify(i, c, prev, ttl_s):
    if i == 0:
        return "cold start"
    gap = (c["ts"] - prev["ts"]).total_seconds()
    prompt, prev_prompt = c["inp"] + c["cc"] + c["cr"], prev["inp"] + prev["cc"] + prev["cr"]
    if c["cr"] < prev["cr"] and c["cc"] > 1000:
        if gap > ttl_s:
            return f"INVALIDATION: TTL lapsed ({gap/60:.0f} min gap)"
        if prompt < prev_prompt * 0.6:
            return "INVALIDATION: context shrank (compact/clear)"
        return "INVALIDATION: prefix changed upstream"
    return "extend"

path = Path(sys.argv[1]) if len(sys.argv) > 1 else pick_default()
calls = load_calls(path)
model = calls[0]["model"]
w5, w1 = sum(c["w5"] for c in calls), sum(c["w1"] for c in calls)
ttl_s = 3600 if w1 >= w5 else 300

print(f"=== Cache autopsy: {path.name[:23]}... ===")
print(f"{len(calls)} API calls, {model}, "
      f"{calls[0]['ts']:%Y-%m-%d %H:%M} -> {calls[-1]['ts']:%Y-%m-%d %H:%M} UTC")
print(f"cache writes bought: 5m TTL {w5:,} tok, 1h TTL {w1:,} tok "
      f"-> this session runs on the {'1-hour' if ttl_s == 3600 else '5-minute'} cache\n")

print(f"{'call':>4} {'gap':>6} {'input':>7} {'write':>8} {'read':>9} {'out':>6}  event")
print("-" * 78)
events, shown = [], 0
for i, c in enumerate(calls):
    ev = classify(i, c, calls[i - 1] if i else None, ttl_s)
    if "INVALID" in ev or i == 0:
        events.append((i, ev))
    if i < 8 or "INVALID" in ev:
        shown += 1
        gap = f"{(c['ts'] - calls[i-1]['ts']).total_seconds():>5.0f}s" if i else "     -"
        print(f"{i:>4} {gap} {c['inp']:>7,} {c['cc']:>8,} {c['cr']:>9,} {c['out']:>6,}  {ev}")
print(f"{'...':>4}  ({len(calls) - shown} extend calls not shown)")
print("-" * 78)

prompt = sum(c["inp"] + c["cc"] + c["cr"] for c in calls)
read = sum(c["cr"] for c in calls)
inp_price, out_price = PRICES.get(model, (5.0, 25.0))
actual = sum(c["inp"] * inp_price + c["w5"] * inp_price * 1.25
             + c["w1"] * inp_price * 2.00 + c["cr"] * inp_price * 0.10
             + c["out"] * out_price for c in calls) / 1e6
uncached = sum((c["inp"] + c["cc"] + c["cr"]) * inp_price
               + c["out"] * out_price for c in calls) / 1e6
peak = max(c["inp"] + c["cc"] + c["cr"] for c in calls)

print(f"\nPrompt tokens processed: {prompt:,}  (peak single prompt: {peak:,})")
print(f"Served from cache:       {read:,}  ({read / prompt * 100:.1f}% hit rate)")
print(f"Invalidation events:     {len(events) - 1} in {len(calls)} calls")
print(f"Session cost:            ${actual:,.2f}")
print(f"Same session, no cache:  ${uncached:,.2f}  "
      f"(caching saved {uncached / actual:.1f}x)")
print(f"""
Reading it: the steady state is 'extend' (old prefix read at 0.1x, new chunk
written once). Every INVALIDATION line is money: the next call re-writes what
was already cached, at {'2.0x' if ttl_s == 3600 else '1.25x'} instead of reading it at 0.1x. Match each one
to what you did at that moment (edited CLAUDE.md? changed MCP servers? walked
away past the TTL? compacted?) and you have the chapter's rules, proven on
your own bill.""")

Run against the largest completed session in this repository's project directory (the two-day session that wrote an earlier part of this book), verified output:

=== Cache autopsy: c778f680-f439-41db-b0d8... ===
272 API calls, claude-opus-4-8, 2026-06-29 01:29 -> 2026-06-30 18:43 UTC
cache writes bought: 5m TTL 0 tok, 1h TTL 3,198,587 tok -> this session runs on the 1-hour cache

call    gap   input    write      read    out  event
------------------------------------------------------------------------------
   0      -   6,199    6,052    15,853    318  cold start
   1     9s       2    7,250    21,905    478  extend
   2     7s       2    4,991    29,155    449  extend
   3    12s       2    5,219    34,146    810  extend
   4    23s     124    9,089    39,365  1,675  extend
   5    38s       2   35,206    15,946  1,889  INVALIDATION: prefix changed upstream
   6    33s       2    7,068    51,152  3,194  extend
   7    23s     124    5,448    58,220    195  extend
  80 82496s      46  210,309    15,946  4,047  INVALIDATION: TTL lapsed (1375 min gap)
  96  4759s       2  248,554    15,946  9,989  INVALIDATION: TTL lapsed (79 min gap)
 157 31625s     124  751,426    15,946  3,525  INVALIDATION: TTL lapsed (527 min gap)
 209  4147s   1,086  391,599    21,169  4,814  INVALIDATION: TTL lapsed (69 min gap)
 227  7206s       2  462,398    21,169  4,790  INVALIDATION: TTL lapsed (120 min gap)
 ...  (259 extend calls not shown)
------------------------------------------------------------------------------

Prompt tokens processed: 133,558,463  (peak single prompt: 960,535)
Served from cache:       130,253,349  (97.5% hit rate)
Invalidation events:     6 in 272 calls
Session cost:            $110.68
Same session, no cache:  $680.83  (caching saved 6.2x)

Reading it: the steady state is 'extend' (old prefix read at 0.1x, new chunk
written once). Every INVALIDATION line is money: the next call re-writes what
was already cached, at 2.0x instead of reading it at 0.1x. Match each one
to what you did at that moment (edited CLAUDE.md? changed MCP servers? walked
away past the TTL? compacted?) and you have the chapter's rules, proven on
your own bill.

Reading the lifecycle

Walk the table top to bottom and every rule from the caching chapters appears in the wild:

  • Call 0, the cold start, is not fully cold. The session's first call already reads 15,853 tokens. That is the shared spine (system prompt and tool schemas) still warm from earlier traffic on the same account, the fleet effect Chapter 43's radix lab predicted: your sessions are branches of one tree, and the trunk was already paid for.
  • The steady state is beautiful and boring. Calls 1 to 4: input is nearly zero, the read column climbs by exactly the previous turn's write (21,905, then 29,155, then 34,146...), and the write column is just each turn's new chunk. This is incremental prefix caching working as designed: the conversation only ever pays full attention cost for what is new.
  • Call 5 is a textbook upstream invalidation. The read column crashes from 39,365 back to 15,946 (the shared spine) and the write column spikes to 35,206: everything above the spine was re-written. Something in the early prompt changed, 38 seconds after the previous call, which no TTL explains; this is the shape of a prefix edit (Chapter 24's silent-invalidator audit, observed in production).
  • Claude Code buys the 1-hour cache. All 3.2M tokens of writes in this session are ephemeral_1h: the tool pays 2x on writes (instead of 1.25x for the 5-minute tier) to keep a working session warm through pauses, tool runs, and thinking time. For an interactive agent whose turns can be many minutes apart, that trade is obviously right, and the five TTL lapses below show what happens at the boundary where even an hour is not enough.
  • The peak prompt is 960k tokens. This session lived deep in the 1M window, which is exactly where caching stops being an optimization and becomes the only thing making the session affordable: at $5/M, one uncached 960k prompt is $4.80, per turn.

Don't be confused. The input column being ~2 tokens does not mean the model read nothing; it means everything else arrived through the cache columns. Billed prompt = input + cache_creation + cache_read, always. What the columns change is the price per token (1x, 2x, 0.1x), not what the model attends to. A 97.5% hit rate session still processes every token of every prompt.

The price of walking away

Five of the six invalidations are TTL lapses: gaps of 69 minutes to 23 hours where the 1-hour cache expired and the next call had to re-write the whole conversation. Summed from the table: 2,064,286 tokens re-written at 2x, about $20.64, where the same tokens read from a warm cache would have cost $1.03. Call 157 alone, returning after a 527-minute break to a 751k-token conversation, cost $7.51 to re-warm.

That is roughly 18% of this session's total bill spent on five moments of stepping away, and it prices a habit: returning to a huge stale session is a purchase. The alternatives all beat it. Finish the thought before leaving; or come back inside the hour; or, if the return is tomorrow, /clear and reopen with a summary (a fresh 20k-token start re-warms for pennies) rather than re-warming a 751k relic; or let the session end and start the next one from memory instead of transcript. The autopsy turns that advice from folklore into a line item.

One more honest observation: through every lapse, the read column never drops to zero; a ~16k-token base stays warm. The account's other traffic (parallel sessions, subagents) was keeping the spine alive, which is the radix tree from Chapter 43 visible in a production bill: the trunk practically never expires because someone is always touching it.

The proof sessions

The autopsy is one session; these recipes let you regenerate every claim at will. Each is a small experiment: run it, then point cache_autopsy.py at the session file it created (its path prints in claude -p --output-format json as session_id; the file appears under ~/.claude/projects/<project>/). Commands are current as of July 2026; treat the outputs as shapes to expect, not numbers to match.

Proof 1: the cache exists (two calls, one minute).

sid=$(uuidgen)
claude -p --session-id "$sid" "Reply with exactly: OK"
claude -p --resume "$sid"     "Again, reply with exactly: OK"
python3 cache_autopsy.py ~/.claude/projects/<project>/"$sid".jsonl

Expected shape: call 0 writes the prompt (write large, read maybe nonzero if the spine is warm), call 1 reads what call 0 wrote and writes only the tiny new turn. If you also compare the two total_cost_usd values from --output-format json, the second call is visibly cheaper per prompt token.

Proof 2: the TTL is real (the same experiment, plus a lunch break). Run proof 1, wait past the TTL (over an hour for a Claude Code session; over five minutes for a default API integration), then send a third turn with --resume. The autopsy shows the third call as an INVALIDATION: TTL lapsed line: the read column collapses, the write column re-buys the conversation. You have now measured the walking-away tax on a toy, which is exactly what call 157 above is at scale.

Proof 3: editing the prefix invalidates everything below it. In a working session, note the current turn's read tokens (statusline, /context, or the transcript), then edit CLAUDE.md (add one comment line) and send another turn. The autopsy shows the call after the edit as INVALIDATION: prefix changed upstream: read crashes to the spine, write re-buys the conversation. The same experiment with an MCP server toggle (claude mcp add / remove between turns) shows the same signature higher up: tool schemas sit above CLAUDE.md in the prompt, so the crash is deeper. This is why Chapter 40 charges churn against always-loaded files: you just watched the charge.

Proof 4: /clear and /compact have opposite cache signatures. In a long session, run /compact: the next call writes a medium prompt (the summary replaced the history; the old cache is useless, the new one is small). In a second long session, run /clear: the next call is nearly a cold start at spine size. Both show as "context shrank" invalidations in the autopsy; the difference is the size of what gets re-written afterward, and Chapter 42's Recipe 3 adds the other axis: what the summary kept.

Proof 5: the fleet shares the trunk. Start a fresh session and immediately check call 0's read column: on a machine with recent Claude Code activity it is already thousands of tokens (this autopsy's was 15,853). Then launch a subagent (any Task-style delegation) and autopsy its transcript: its first call also reads the spine it never paid to write. Sharing across requests is not a serving-engine exotic; it is on your bill.

Remember. The order of evidence is transcript first, gauges second, vendor claims last. Anything this book says about caching, and anything a tool README says about saving tokens, should be reproducible as a shape in cache_autopsy.py's output on your own machine within ten minutes. If it is not, the claim does not apply to your workload, no matter whose benchmark says otherwise (Chapter 27).

Further reading

  • Chapter 24: the constructed-prompt version of these rules (breakpoints, minimums, the TTL decision) with the invalidator audit this autopsy caught in production.
  • Chapter 25 and Chapter 23: the transcript format, the usage block field by field, and the whole-machine ledger this script is the microscope version of.
  • Anthropic prompt caching docs (platform.claude.com/docs/en/build-with-claude/prompt-caching): the authoritative TTL, pricing multiplier, and invalidation-hierarchy reference the autopsy's arithmetic uses.
  • ccusage (Chapter 26): the productized ledger; its per-session view pairs well with this script's per-call view.

Takeaways

  • Your transcripts are a complete, per-call cache record; the autopsy needs 130 lines of stdlib and no instrumentation you did not already have.
  • One real two-day session: 97.5% hit rate, 6.2x cost reduction, 1-hour TTL on every write, peak prompt 960k tokens. Caching is not an optimization at that depth; it is the precondition.
  • Six invalidations in 272 calls, five of them TTL lapses that re-bought 2.06M tokens for ~$19.61 versus $1.03 warm: walking away from a huge session is a purchase, and /clear plus a summary is usually the cheaper return.
  • Every cache rule has a ten-minute proof session: two-call existence, TTL lapse, prefix-edit invalidation, /clear versus /compact signatures, and the shared trunk in call 0's read column.
  • Evidence order: transcript, gauges, vendor claims. Reproduce the shape locally or treat the claim as unproven for your workload.

👉 The autopsy showed your fleet's tree from the bill side, and the lineage chapter showed who invented each layer of it. What remains is to compress everything this book has measured into the card you keep next to the keyboard: every lever, its rule, and the experiment that proves it. Continue to The practice card.