●PRICE — Sonnet 5's launch promotion of $2/$10 per Mtok ends August 31, with standard pricing of $3/$15 per Mtok taking over on September 1●TOKENS — Sonnet 5's revised tokenizer maps identical content to 1.0-1.35x more tokens, so the list-price change alone understates what your bill will actually do●BOOST — The temporary 50% weekly usage increase for Claude Code subscribers now runs through August 19. It is an extension, not a permanent change●OSS — Claude for Open Source gives maintainers and contributors six months of Claude Max 20x at no cost, worth roughly $1,200●OPUS5 — Claude Code v2.1.219 makes Opus 5 the default Opus model, bringing a 1M context window and a fast mode priced at $10/$50 per Mtok●NETLOCK — Two additions worth knowing: sandbox.network.strictAllowlist denies any host outside your allowlist, and a DirectoryAdded hook fires after /add-dir●PRICE — Sonnet 5's launch promotion of $2/$10 per Mtok ends August 31, with standard pricing of $3/$15 per Mtok taking over on September 1●TOKENS — Sonnet 5's revised tokenizer maps identical content to 1.0-1.35x more tokens, so the list-price change alone understates what your bill will actually do●BOOST — The temporary 50% weekly usage increase for Claude Code subscribers now runs through August 19. It is an extension, not a permanent change●OSS — Claude for Open Source gives maintainers and contributors six months of Claude Max 20x at no cost, worth roughly $1,200●OPUS5 — Claude Code v2.1.219 makes Opus 5 the default Opus model, bringing a 1M context window and a fast mode priced at $10/$50 per Mtok●NETLOCK — Two additions worth knowing: sandbox.network.strictAllowlist denies any host outside your allowlist, and a DirectoryAdded hook fires after /add-dir
Swapping Tools Mid-Conversation Without Losing Your Prompt Cache
Tools can now be added and removed between turns, but the tool block sits at the very front of the cache prefix. I measured 12 mutation patterns with fingerprints and built a stable-core plus volatile-tail registry with a guard that refuses unsafe changes.
I was trimming unused tools from a long-running session.
Twelve tools were still attached, several of which clearly would not be called again in the back half of the conversation. The beta for adding and removing tools between turns had landed, so I dropped four of them. Input tokens should have gone down.
They did not. They went up.
cache_read_input_tokens fell to zero, and the entire accumulated conversation history was being resent. What I thought was a savings had quietly invalidated the cache and made me pay for everything again.
The tool block sits at the very front
Prompt caching works on prefix matching. Everything from the start of the request up to the first divergence is eligible for a cache hit. Change one byte anywhere, and everything downstream misses.
The trouble is where tool definitions live in that ordering.
Position
Block
Expected churn
Blast radius
1
tools
Should be low
All of system and messages
2
system
Low
All of messages
3
messages (history)
Append-only
Later turns
4
messages (current)
Every turn
None
The tool array has the widest blast radius in the whole request. It does not matter whether a hundred turns have piled up behind it — swap one tool and all hundred turns go with it.
The beta unlocking mid-conversation tool changes and those changes being cache-safe are two separate things. The API accepting the mutation does not mean the prefix survives it.
I had conflated the two. Reading the beta note, I took "prompt cache is maintained" to mean maintained across any change at all.
Measuring 12 mutations by fingerprint
Rather than reason about it, I measured it. Build a rolling hash chain over the tool array — one hash per prefix length — then compare the chains before and after a change.
# toolprefix.py — measure prefix fingerprints over a tool arrayimport hashlibimport jsondef canon(obj): """Deterministic serialization: sorted keys, fixed separators.""" return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)def prefix_chain(tools): """Return the rolling hash after each successive tool.""" chain, h = [], hashlib.sha256() for t in tools: h = h.copy() # without copy(), later updates bleed backwards h.update(canon(t).encode()) chain.append(h.hexdigest()) return chaindef shared_prefix_len(a, b): """How many leading elements two hash chains agree on.""" n = 0 for x, y in zip(a, b): if x != y: break n += 1 return n
That h.copy() matters. Without it you keep mutating a single hasher and every entry ends up as the final digest. I spent an evening staring at a meaningless 100% match before spotting it.
The baseline is five tools shaped like a real setup.
Here is what twelve patterns produced on my machine.
Mutation
Shared prefix
Retention
Verdict
Append one tool at the tail
5/5
100%
Safe
Rebuild with identical content
5/5
100%
Safe
Remove the last tool
4/5
80%
Partial
Edit the last tool's description
4/5
80%
Partial
Add an optional property to the last tool
4/5
80%
Partial
Insert a tool in the middle
2/5
40%
Partial
Remove a middle tool
2/5
40%
Partial
Rename a middle tool
2/5
40%
Partial
Swap the second and third tools
1/5
20%
Partial
Prepend one tool at the head
0/5
0%
Total loss
Remove the first tool
0/5
0%
Total loss
Edit the first tool's description
0/5
0%
Total loss
Two of twelve preserved the prefix completely. Average retention was 48%.
The "Partial" rows are the trap. A 4/5 prefix match is worth nothing unless a cache_control breakpoint sits exactly at that boundary. Cache granularity is the breakpoint-delimited segment, not the individual tool.
If your only breakpoint is at the end of the tool array — the common setup — then only the two "Safe" rows actually survive. The other ten collapse to zero whether they scored 80% or 20%.
That was precisely my failure. When I trimmed unused tools, post_report had been sitting in the middle of the array.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦12 tool mutations measured by prefix fingerprint: only 2 of 12 preserved the prefix completely, average retention 48%
✦36 logically identical tool definitions produced 36 distinct fingerprints under plain JSON serialization — a 100% false-invalidation rate
✦Splitting into stable core and volatile tail moved core retention from 1/40 turns to 40/40, with the guard implementation included
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Two tool definitions can be logically identical and still fingerprint differently, purely because of the order in which they were assembled. Python dictionaries preserve insertion order, so the sequence in which you populate properties changes what json.dumps emits.
Condition
Assembly orderings
Plain json.dumps
Canonicalized
Field order and property order both vary
36
36 distinct fingerprints
1
Field order fixed, property order varies
6
6 distinct fingerprints
1
All 36 orderings hashed differently. The false-invalidation rate was 35/35 — 100%.
This is not a hypothetical. If you assemble tool definitions with conditionals — add this property for admins, include that field in debug mode — then branch ordering decides how the tool serializes. Your logs will insist the tools are unchanged while the cache quietly dies.
sort_keys=True pins key order, separators pins whitespace, and ensure_ascii=False keeps output stable for tools carrying non-ASCII descriptions. The important part is sending the canonicalized dict, not merely hashing it. Canonicalizing only the fingerprint while shipping the raw dict changes nothing.
Stable core, volatile tail
That settled the design. Stop treating the tool array as one slab and split it in two.
Stable core: never changes for the life of the session, with the cache_control breakpoint on its last element
Volatile tail: everything after the core, where adds, removes, and reorders are all fine
As long as the core is untouched, anything done in the tail leaves the prefix up to the breakpoint intact. Changing "remove the tool" into "move it to the tail, then remove it" contains the blast radius.
import hashlibimport jsonfrom dataclasses import dataclassclass ToolPrefixViolation(Exception): """Raised when the core would change mid-session."""def canon(o): return json.dumps(o, sort_keys=True, separators=(",", ":"), ensure_ascii=False)@dataclass(frozen=True)class ToolSegments: core: tuple # immutable for the session; breakpoint goes on its last element tail: tuple # free to mutate @property def core_fingerprint(self) -> str: h = hashlib.sha256() for t in self.core: h.update(canon(t).encode()) return h.hexdigest()[:16] def to_request_tools(self) -> list: # ship the canonicalized dicts; hashing them alone accomplishes nothing tools = [json.loads(canon(t)) for t in self.core] tools[-1]["cache_control"] = {"type": "ephemeral"} # breakpoint at core tail tools += [json.loads(canon(t)) for t in self.tail] return tools
Exactly one element in the returned array carries cache_control: the last tool of the core. That is the segment boundary. However many tools hang off the tail, nothing before that boundary moves.
A guard that refuses core edits
Discipline that can be broken will be broken, so I made the core hard to touch.
class ToolSetSession: """Tool configuration for one session, with the core fingerprint pinned.""" def __init__(self, segments: ToolSegments): self._pinned = segments.core_fingerprint self.segments = segments def mutate_tail(self, new_tail) -> ToolSegments: nxt = ToolSegments(core=self.segments.core, tail=tuple(new_tail)) if nxt.core_fingerprint != self._pinned: raise ToolPrefixViolation( f"core changed: pinned={self._pinned} got={nxt.core_fingerprint}" ) self.segments = nxt return nxt
Running it:
initial core fingerprint: daf5a27a5273fe97
cache_control position in request tools: [2]
core fingerprint after tail swap: daf5a27a5273fe97 -> match
core edit detected: core changed: pinned=daf5a27a5273fe97 got=8ac9bd8f8b1072d2
frozen=True and tuples are there so that segments.core.append(...) simply does not work. Left unlocked, a future version of me forgets the rule.
Raising is deliberate. Failing loudly is cheaper than degrading silently, because a cache miss is functionally correct behavior — tests will never catch it. You find out from the invoice.
Comparing across a 40-turn session
To check whether the split actually pays, I generated 40 turns of random tool operations and replayed the identical operation sequence against two strategies.
A: single segment. Each turn, collect the active tools, sort them by name, and rebuild the array — a very common pattern
B: six-tool stable core held fixed, mutations confined to the tail
The operation mix was 10 adds, 12 drops, 9 swaps, and 9 no-ops.
Strategy
Core fingerprint matches
Retention
Wasted core resend
A single segment
1/40 turns
2%
39 turns = 38,610 bytes
B stable core + volatile tail
40/40 turns
100%
0 bytes
The six-tool core serialized to 990 bytes. Strategy A resent all of it 39 times.
What drives the gap is not the split so much as A's tidy-looking "sort by name and rebuild" step. Add or drop a single tool and the sorted positions shift, moving the core. My intuition said sorting is deterministic and therefore stable. Determinism and stability under mutation are not the same property.
One caveat on the numbers: I am measuring serialized bytes of the tool block, not billed tokens. What you actually save depends on how much conversation history sits behind the core. The longer the session, the more those 990 bytes drag down with them.
What I watch in production
Running scheduled jobs as an indie developer means failures often stay invisible until the invoice arrives, and I could not reconstruct how long mine had been broken. A few habits have helped.
Have a rule for what enters the core. Anything that might be called even once belongs in the core; "probably unused" goes in the tail. When in doubt, tail. A smaller core breaks less often.
Do not assemble tool definitions inline. Keep declarations as module constants and let conditionals choose among them, never build them. The moment a branch composes the body, ordering drifts.
Log cache_read_input_tokens per turn alongside the tool fingerprint. When it drops to zero, diffing two adjacent fingerprints identifies the cause in minutes. Without it, you cannot even tell which turn broke.
Recycle the session when the tail grows. Past a certain size, rebuilding a fresh core and folding the conversation is cheaper. Tail tools are still tokens.
Prefer disabling over removing. Withholding a tool via tool_choice, or having the tool itself return "not available at this stage," is often safer than pulling it out of the array.
The third one has already earned its keep. Put the fingerprint from the broken turn next to the one before it, and the difference is obvious at a glance.
Here is how I decide per setup.
Setup
Recommendation
Why
Five tools or fewer, short conversations
Stay single-segment
Little history to protect; the bookkeeping costs more than it saves
Six or more tools, growing history
Stable core plus volatile tail
The setup in this article. This is where the effect is largest
Tools vary by permission
Put every candidate in the core, gate with tool_choice
Narrows the effective surface without moving the array
Tools generated dynamically
Concatenate generated tools into the tail in fixed order
Isolates generation-order drift from the core
Where to start
Serialize your current agent's tool array once, and hash it both with and without sort_keys=True. If those two differ, you may be losing the cache on turns where nothing changed at all.
If they match, look at breakpoint placement next. Printing which entries in the array carry cache_control shows exactly how far your protection actually extends.
Trimming tools to save context can cost more than it saves. It took me a while to see that, and the numbers were what finally made it click. If you have been stuck at the same spot, I hope this saves you the evening I lost.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.