How billing actually works, the ecosystem of options to spend less, and the mistakes that quietly cost you money — grounded in measurement, not folklore.
This guide teaches the cost model of (cache-tiers / what-invalidates section).
The two rows that force a full rebuild — touching every tier — are the expensive ones: tool-definition changes and model switches. Because both sit at or before byte 0 of what's cached, they re-key everything, and at the 2× write rate that rebuild is twice as painful as a read.
Gotchas — in Claude Code
Claude Code places the cache breakpoints for you — three of them, 1-hour TTL (proven in Claude Code's actual caching) — so you never write cache_control yourself. Your cache lever isn't placing breakpoints; it's not disturbing the prefix the client already cached. The ways a real session loses it:
Tool-set churn at byte 0. Adding or removing an MCP server — or a server that mutates its tools at runtime (dynamic toolsets), or async connectors that register after startup — changes the tool definitions at byte 0 and re-keys the entire prefix. Stabilize the tool surface first; full treatment in What busts the cache.
Switching models. The cache is model-scoped, so any model change is a full cold rebuild — the whole of Act II.
Long agentic tool bursts. A single turn that emits more than ~20 tool-call/result blocks overflows the API's 20-block lookback window, so the next turn can't re-link to the prior entry and cold-writes the gap. You can't insert your own breakpoints to repair this from inside Claude Code, so the mitigation is to keep individual tool bursts bounded — full treatment, with the exact re-link rule and a proxy-side fix, in What busts the cache.
Editing CLAUDE.md (or a memory file it imports) — free while a session runs; you pay a one-time re-key only on the next start or resume, and only if the file actually changed. "This" means the project-context layer Claude Code assembles for you: your CLAUDE.md files (enterprise, user, project), the rules and memory files they@import, and the auto-memory store. (It is not your system prompt —--append-system-prompt, output styles, and the like sit in the system tier at the front of the prefix, a separate and costlier case under Tool-set churn / What busts the cache — and not the message you type, which is the live turn.) Claude Code reads this layer from disk once, at session start, into a single<system-reminder>inmessages[0](the first user turn), placed after the system breakpoints — so editing it can never disturb the expensive tools+system prefix (it is never the worst class). The process then reuses that start-of-session snapshot for its whole life, so the only question is when the snapshot is rebuilt from disk:
While running → free. The live process never rewrites itsmessages[0]snapshot, so the warm prefix is safe no matter who edits the file. Headless-pignores the edit until restart. Interactive surfaces it cheaply at the tail: an out-of-band edit (you, a linter, another process) appends a one-shot "CLAUDE.md was modified…" reminder (~155 tokens — the same append-don't-mutate trick as the date); an edit Claude makes itself rides in as its Edit tool result, with no extra reminder. Either waymessages[0]stays byte-identical.
On--continueor a fresh start → the bill lands, but only if the file changed. A new process rebuildsmessages[0]by re-reading CLAUDE.md (and its@imports) from disk. If the content changed (whoever changed it),cache_readcollapses to the system tier and the whole message tier cold-writes — measuredcache_read30,216→27,975,cache_creation24→2,265 vs an unedited control; an@imported file re-keys identically (45→2,301). If it didn't change, the resume is fully warm —messages[0]comes back byte-identical and only the sliding tail re-keys (~17–21 tokens). So the re-key is the edit's cost, not resume's. (A fresh session pays the same cold write but has no warm tier to lose.)
Don't confuse this with system-prompt drift. Any cross-process resume can separately eat a bigger, occasional miss: the regenerated system prompt differs by ~6–17 chars and cold-writes the entire prefix. That's process nondeterminism, independent of any CLAUDE.md edit — tell them apart bysystem[2]'s hash (a CLAUDE.md re-key leavessystem[2]identical and flips onlymessages[0]). [measured]
Adding a skill vs. a plugin/MCP server — opposite ends of the cost scale. The split comes from where each lands in the prefix: a skill's name+description goes intomessages[0](same tier as CLAUDE.md, after the system breakpoints); a plugin/MCP server's tools go to byte 0, ahead of everything cached. That placement is the whole story:
Markdown skill → cheap, and--continue-invisible. Added mid-session, the interactive client shows it immediately as a one-shot tail append (the full skills list is prepended to the current turn, ~1,016 tokens once); headless-pignores it. It's baked into the canonicalmessages[0]only at the next fresh start. Notably,--continuedoes not re-scan skills — a resumed session never sees a newly-added skill and never re-keys for it. (This is the resume asymmetry from the bullet above:--continuere-reads CLAUDE.md but replays the stale skills list.)
Plugin/MCP server → the expensive one. Its tools sit at byte 0, so changing the connected tool set re-keys the entire prefix — system and message tiers (measured: one added tool collapsedcache_read29,424→0 and cold-wrote all 29,505 tokens). A connected server's own tool change (e.g. a dynamictools/list_changed) applies live; adding a brand-new server via config is restart-gated. [measured]
The full catalogue of what busts the prefix — silent invalidators, dynamic MCP tools, injected date/git/system-reminders — is the next section.
Note — extra gotchas if you use the Claude API directly. Everything above assumes the Claude Code client, which manages caching for you. If you assemble
/v1/messagesrequests yourself, you also own the things Claude Code quietly handles:
You must addcache_controlyourself. With no breakpoint, nothing is cached. There's a maximum of 4 per request; place them at stability boundaries —[tools + system](frozen) /[project context / CLAUDE.md](per task) /[conversation](the sliding tail) — and the API reads the longest matching prefix, reprocessing only what follows.
Minimum cacheable prefix ~1,024–4,096 tokens (model-dependent). Below it the marker silently no-ops:cache_creation_input_tokenscomes back0and nothing is cached, even though it looks enabled.
The default TTL is 5 minutes (write ≈ 1.25× input); you must explicitly request the 1-hour TTL (write ≈ 2×) that Claude Code always sends. Choose by how long you'll keep reusing the prefix.
Ordering is on you: emittools → system → messages, stable content first, volatile last.
Serialization drift busts the cache silently: re-serializing JSON with different key order, separators, or escaping — or interpolating a timestamp, UUID, or request-ID early in the prompt — changes the bytes even when the meaning doesn't, and the KV states no longer match. Keep the cached prefix byte-identical and push volatile tokens after the last breakpoint.
You slide the tail breakpoint yourself to the newest turn each request to get delta-only writes.
Verify any of it with the
usageblock: a non-zerocache_creation_input_tokenson the first call, then a non-zerocache_read_input_tokenson the next. If both stay near zero across requests that share a prefix, caching isn't engaging.
What busts the cache
This is where understanding turns directly into money. Everything below changes the prefix bytes, so the next request re-keys and rewrites at 2× instead of reading at 0.1×. These are the ways it happens in a real Claude Code session; a closing FYI covers hazards that only apply if you drive the raw API or bolt your own content onto Claude Code.
Dynamic MCP tools
MCP servers can change their advertised tools at runtime by sending notifications/tools/list_changed. When they do, the new tool definition lands at byte 0 (tools are first in the render order) → the entire prefix re-keys → a full cold rebuild, now at 2× the write rate.
It helps to keep three distinct things straight, because only the first one lives at byte 0:
| Thing | Lives in |
|---|---|
| Tool definitions | tools param (byte 0) — changing these is catastrophic |
Tool calls (tool_use) | assistant turn content (messages) — late, cheap |
Tool results (tool_result) | user turn content (messages) — late, cheap |
The async-connector blow-up, caught live [measured]
In one real session the tool count grew 30 → 55 → 85 across consecutive turns with no model switch, busting the cache on every turn. The cause: four claude.ai MCP connectors (Zoom, Atlassian Rovo, Microsoft 365 [unauthenticated], Slack) — remote servers connecting asynchronously at startup. Each request snapshots whatever tools have registered so far, so the byte-0 tool list kept growing as connectors came online mid-session. Pinning the config with --strict-mcp-config froze it at 30.
⚠️ Mistake — optimizing anything before the tool surface is stable. If tools are still registering across your first few turns, every "optimization" you measure is noise on top of a cold rebuild.
✅ Fix — Stabilize the tool/MCP surface first. Use
--strict-mcp-config(or a frozen, pinned server set) so byte 0 is constant from turn 1. At a 2× write rate, every cold turn is doubly expensive — this is the highest-leverage fix in the guide.
Injected context Claude Code handles for you: date, git, system-reminders
These are the things people expect to bust the cache — but Claude Code places them so they mostly don't:
Date — lives in a system-reminder after the system breakpoints, and a mid-session midnight rollover is effectively free: the harness doesn't rewrite the date inmessages[0], it appends a"The date has changed"reminder to the newest turn (measured:cache_readheld ~30K, only ~58 tokens written). It re-keys nothing but the sliding tail. [measured]
Git status — safe: Claude Code snapshots it once at session start. (Re-runninggit statusevery turn and injecting it early would bust constantly — a cautionary design lesson.)
<system-reminder>blocks — cheap when appended to the newest turn; only expensive if per-turn-varying content is placed early, re-stamped onto an already-cached turn, or stripped on replay (each changes a cached turn's bytes).
Case study: the GitHub MCP server's dynamic toolsets [measured against source]
This case is worth studying because the most popular official platform MCP server made exactly this mistake — and then deleted the feature.
Verified against github/github-mcp-server (Go, MIT) at tag v1.0.5 (1d17d33): the README's "Dynamic Tool Discovery," pkg/github/dynamic_tools.go, and the bundled MCP Go SDK (modelcontextprotocol/go-sdk v1.6.1).
| Mode | Tools | Mutates byte 0 at runtime? | Cache-safe? |
|---|---|---|---|
| default (no flags) | default toolset, fixed at startup (~50 tools) | No | ✅ |
--toolsets repos,issues,… | explicit groups, fixed at startup | No | ✅ |
--dynamic-toolsets | 3 meta-tools; starts ~empty, grows on demand | Yes ( enable_toolset → AddTool → tools/list_changed) | ❌ |
The default and explicit --toolsets paths resolve the tool set once at startup (ResolvedEnabledToolsets) and never touch it again — a frozen prefix, cache-safe. The --dynamic-toolsets mode (off by default; env GITHUB_DYNAMIC_TOOLSETS) instead starts nearly empty and exposes three meta-tools — list_available_toolsets, get_toolset_tools, enable_toolset — so the model turns groups on as it needs them.
That convenience is a cache-buster. When the model calls enable_toolset, the handler registers the group's tools against the live, already-connected server (EnableToolset → RegisterFunc → s.AddTool), and AddTool fires notifications/tools/list_changed. New tool definitions land at byte 0 → the whole prompt prefix re-keys → a full cold rebuild (2× write) on the next turn, paid again on every enable_toolset call.
An epilogue, read honestly: GitHub removed the dynamic-toolsets feature entirely in v1.1.0 (PR #2512, commit 0f0506d, 2026-05-20) — absent from every release since, including v1.3.0. But the maintainers do not cite caching or token cost. Their stated reasons are tech-debt cleanup — the dynamic path "carried real complexity: a separate config flag plumbed through stdio + http configs, a parallel registration path, four inventory methods … three meta-tools" — and that it was "a path no longer in active use," superseded by client-side progressive discovery (the removal author explicitly names "Anthropic's Tool Search Tool, OpenAI's equivalent, 'Code Mode' patterns"). So this is consistent with the frozen-prefix principle, not proof that cache cost drove the deletion. The cache-busting cost itself is documented first-party — by Anthropic, not GitHub: ). [removal + stated reasons verified via PR #2512; the cache motive is our analysis, not GitHub's]
Live companion — Docker MCP Gateway, the same pattern shipped default-on. [measured against source] Where GitHub removed its dynamic path, Docker MCP Gateway (docker/mcp-gateway, Go, MIT, ~1.5k★) ships it on by default. Verified at tag v0.43.0 (4833d8c): the dynamic-tools feature is default-enabled (cmd/docker-mcp/commands/feature.go → defaultEnabledFeatures{"dynamic-tools": true}; the launch blog confirms the meta-tools are "available to your agent by default"), exposing mcp-find / mcp-add / mcp-remove / mcp-config-set / code-mode that add and remove tools on the live, already-connected server mid-session. The mutation hits byte 0 through the identical sink as the GitHub case: pkg/gateway/reload.go (reloadConfiguration) calls mcpServer.RemoveTools(…) + AddTool(…), which in the MCP Go SDK (modelcontextprotocol/go-sdk v1.4.1) bottom out in changeAndNotify(notificationToolListChanged, …) → notifications/tools/list_changed — and Docker's own TestIntegrationToolListChangeNotifications asserts it fires on a live add. So every mcp-add / mcp-remove cold-rebuilds the whole prefix at the 2× write rate, on each call.
Motive, stated honestly: Docker argues the feature on raw token volume, never caching — its slide deck cites "335 tools => 209K tokens of tool description / request … \$1.25/1M tokens" (examples/tool_registrations/embeddings.md) and the ). So from inside the client the only lever is to keep bursts bounded — fewer parallel tools per turn.
Behind a proxy you could potentially fix it. A proxy sitting between Claude Code and the API sees the entire conversation on every request — the API is stateless, so the full history is resent each turn — which means the proxy can rewrite the cache_control markers before forwarding, with no memory of prior calls. And because Claude Code uses only 3 markers, a proxy has the whole budget of 4 to work with. Place them as a sliding grid — at the tail and every ~18 blocks back (tail, −18, −36, −54) — and whatever the burst size, one marker still lands within ~20 blocks of the last cached chunk, re-links the full prefix, and re-charges only the genuinely new blocks:
| turn | naive (Claude Code's 1 tail marker) | proxy grid (4 markers, stride 18) |
|---|---|---|
| +57-block burst | read 0 · write 16,879 ✗ | read 14,794 · write 2,085 ✓ |
| +31-block burst | read 0 · write 18,011 ✗ | read 16,888 · write 1,123 ✓ |
Two limits on the proxy fix: four markers at stride 18 rescue a single turn of up to ~74 new blocks — past that, even the grid can't reach the prior chunk. And the proxy must forward the prefix byte-for-byte; re-sorting the tools or re-serializing the JSON busts the cache on its own, markers or not.
⚠️ Mistake — letting an agent fan out 10+ parallel tool calls in one turn on a cached prefix. ~20 blocks overflows the lookback, and the next turn cold-rewrites the whole prefix at 2×.
✅ Fix — In Claude Code you can't place your own markers, so bound the burst (fewer parallel calls per turn) — that fixes the common case for free. The proxy grid (4 markers, stride ~18, rescues up to ~74 new blocks) is a last resort, not a default: a burst big enough to overflow the window is rare, and a request-rewriting proxy now owns your cache correctness — it must forward the prefix byte-for-byte and is still capped at the ~74-block ceiling. Don't stand one up — or buy a "cache-optimization" layer that does — without understanding every constraint above; for a problem this infrequent, the added complexity and failure surface rarely pay for themselves.
FYI — not Claude Code: silent invalidators in raw-API or custom setups
The classic prompt-cache killers below don't come from Claude Code — it keeps volatile content out of the cached prefix (the date sits after the system breakpoints, the per-request billing-header token is excluded from the cache key, git is snapshotted once). They bite only when you build the prefix yourself: calling /v1/messages directly, or bolting content onto Claude Code via --append-system-prompt / --system-prompt, a SessionStart hook or proxy, or a custom MCP server whose tool descriptions embed per-request data (those land at byte 0). They're silent because the meaning looks unchanged — only the bytes moved:
datetime.now(), UUIDs, or request-IDs interpolated early in the prompt.- Unsorted
json.dumps(key order drifts between requests). - Per-user data or conditional sections in the system prompt; per-user tool sets.
✅ Fix — Keep per-request tokens after the last breakpoint (the message tier), exactly where Claude Code puts them. Verify with
cache_read_input_tokens ≈ 0across requests that should share a prefix; if reads are zero, diff the rendered bytes of two consecutive requests.
Claude Code's actual caching, empirically
Now we ground the abstract rules in what Claude Code 2.1.150 actually sends on the wire. (This layout is an implementation detail and is version-specific — re-verify per release.)
The 3-breakpoint finding [measured]
Captured via raw request logging (OTEL_LOG_RAW_API_BODIES=1): 3 cache_control breakpoints, all with ttl:"1h" — the 4th available budget slot is unused.
TOOLS: 30 definitions ← byte 0, no marker of their own
SYSTEM:
system[0] len=85 billing/version header (per-request cch token)
system[1] len=62 "You are a Claude agent…" ◀ BREAKPOINT 1 (1h) — covers tools + identity
system[2] len=26887 "You are an interactive agent…" ◀ BREAKPOINT 2 (1h) — full system prompt
MESSAGES:
messages[0].content[0] <system-reminder> skills list
messages[0].content[1] <system-reminder> context + DATE
messages[0].content[2] user prompt (turn 1)
…turns…
last user message ◀ BREAKPOINT 3 (1h) — slides each turn
Note that tools fold into Breakpoint 1 — there is no dedicated tool breakpoint. A multi-turn (-c) capture shows the tail breakpoint (BP3) sliding forward each turn while the two system breakpoints stay put. This is the textbook sliding-window pattern from earlier: frozen system tier written once, sliding tail writing only the delta.
The billing-header gotcha [measured]
system[0] contains a cch= token that changes on every request — yet warm reads still hit. How? The whole of system[0] is special-cased out of the cache key, so it never busts Breakpoint 1. The block is the billing/version header — x-anthropic-billing-header: cc_version=…; cc_entrypoint=…; cch=…; — and the exclusion covers the entire block, not just the volatile cch= token: changing the version string or the entrypoint is ignored for caching too. (Measured: mutating a non-cch byte of system[0] across a warm turn still reads the full prefix; the same one-byte change in system[1] instead cold-rewrites — so the cache key is live, system[0] is simply outside it.) It's a deliberate exception — a per-request-varying span sitting inside the cached prefix that, by the prefix-match rule, should invalidate everything after it, but doesn't. Don't chase it as a phantom cache-buster.
Date placement [measured]
The date lives in a <system-reminder> in the first user message — after both system breakpoints. A mid-session midnight rollover is cheaper than you'd expect: it does not rewrite messages[0] at all. Verified by holding one session alive across Pacific midnight and capturing the wire — turn 1 (23:48, 06-16) and turn 2 (00:01, 06-17, same session):
| turn | messages[0] date | rollover notice | usage |
|---|---|---|---|
| 1 (before) | 2026-06-16 | — | read 21,812 · write 8,305 |
| 2 (after) | still 2026-06-16 | "The date has changed. Today's date is now 2026-06-17" appended to the new user turn (msg tail) | read 30,117 · write 58 |
So the harness leaves the original date in messages[0] untouched and appends a "date has changed" reminder to the sliding tail — re-keying only the cheapest tier (≈58 tokens written, the whole prefix still read warm). It doesn't touch the system tier or messages[0]. Git status is snapshotted once at session start (frozen). Transcripts don't persist the cache_control markers — capture the wire request, not the transcript. [measured]
⚠️ Mistake — trusting the displayed session cost as your true bill. Claude Code's
total_cost_usdprices its 1-hour writes at the 5-minute 1.25× rate, so it reads lower than your actual invoice.
✅ Fix — Use the displayed cost for relative comparisons within a session, but treat the Anthropic Console as authoritative for absolute billing. (Act IV quantifies the gap on a real 16-turn session: $1.77 displayed vs $1.97 actual.)
What to do (end of Act I): Keep the tool/MCP surface frozen from turn 1; keep volatile tokens (dates, IDs) after the last breakpoint; verify caching with the usage block, not faith; and don't trust the displayed cost as your invoice. With that, the steady-state cost of a single-model session is mostly cheap reads plus the output you generate. The expensive surprises come from the two things in Act II.
SOCIAL SHARE CARD GENERATOR