`Bash` tool description embeds the per-session URL, so every `/resume` invalidates the whole prompt cache
The Bash tool's description contains a session-specific console URL. Tool
definitions are serialised ahead of the system prompt and the message array,
so when that URL changes the cached prefix is invalidated from its first
bytes — every resumed session pays a full re-read.
Why it is a clean case. Across a resume, tools[] carries the same 13
tools for the same model, and exactly one differs: Bash. Its name is
identical and its input_schema is byte-identical. The description is
the same length on both sides — 1496 characters — and diverges at a single
point, the session id inside the URL. Nothing about the tool's contract
changed; only an identifier embedded in prose describing it.
Cost. The first request after the resume reported cache_read: 0 withcache_creation of roughly 600k tokens — a total miss, not a partial one.
The resume happened about a minute after the previous request, on the 1-hour
cache tier, so neither expiry nor eviction explains it: an evicted entry
re-reads nothing but would not also change the bytes.
Reproduction, with no special tooling beyond something that can see the
request body:
- Run a session long enough to build a substantial cached prefix.
- Exit, and resume within the cache TTL.
- Diff
tools[]between the last pre-exit request and the first resumed one.
Bash.description differs at the session URL; name and input_schema do
not. The divergence is present in the client's raw outgoing bytes, before
any proxy or middleware.
Why we are reporting rather than working around it. The identifier
appears to be there for provenance in commit trailers, which is useful — but
the cached tool block seems the wrong carrier for a value that changes every
session. Anything session-scoped that must reach the model would cost far
less in the system prompt or a message, both of which sit behind the tool
definitions in the cache. We can absorb this locally, and the absorption is
generic rather than aimed at this string, so a fix on your side simply makes
our handling a no-op. It seemed worth reporting because it affects everyone
who resumes a session, not just people running a proxy.
Happy to provide the tool-block diff shape or re-measure on a current
version if useful.
— reported by a Claude Code user running a local measurement proxy (agent-assisted)
3 Comments
Corroborating this at corpus scale, and with a natural experiment that isolates the session id from the other per-session prompt content.
Method. 454 session transcripts from one project directory (
~/.claude/projects/<project>/*.jsonl), readingmessage.usageon each session's first assistant turn. Opus 5, 7 MCP servers connected, CC 2.1.234/235. No proxy — this is all in the local transcripts, so it should be reproducible by anyone without request interception.Aggregate. Median startup prefix ~56k tokens. 62% of the 454 start fully cold (
cache_read_input_tokens == 0). 100% of sampled writes usedephemeral_1h, so expiry is not in play.The natural experiment.
/clearmints a new session id — and therefore a newBashdescription — but stays in the same process, so the tool block is otherwise at steady state. It writes a new transcript file headed by the/clearmarker, so its first turn is directly comparable to a fresh process's first turn:| start type | n | fully cold | median write | median read | median total |
|---|---|---|---|---|---|
| via
/clear| 79 | 32% | 38,645 | 21,560 | 57,543 || fresh process | 376 | 69% | 54,207 | 0 | 55,744 |
Controlling for recency, since a
/cleartrivially follows its predecessor closely:| | n | fully cold | median read |
|---|---|---|---|
| fresh, gap ≤10 min | 148 | 59% | 0 |
| fresh, gap 10–60 min | 139 | 73% | 0 |
| fresh, gap >60 min | 88 | 80% | 0 |
|
/clear, gap ≤10 min | 33 | 18% | 21,653 |A fresh process started two minutes after another still reads 0. A
/clearat the same gap reliably reads ~21.6k — and then stops. That ceiling is stable across 79 samples and is consistent in size with the three tool descriptions that sort ahead ofBash(Agent, Artifact, AskUserQuestion). So under otherwise-identical in-process conditions, changing only the session id caps prefix recovery at roughly whereBashsits in the tools array, losing ~62% of a ~57.5k prefix.Caveat: I did not inspect cache-breakpoint placement, so I can't fully exclude that a breakpoint simply lands near that offset for unrelated reasons.
**One thing this data does not support.** The fresh-process median read of 0 is not explained by the
BashURL alone — if it were, fresh starts should also recover the ~21.6k pre-Bashhead, and they don't. Something upstream ofBashadditionally differs across processes. My untested hypothesis is that the first request goes out before all MCP servers have finished connecting, so a fresh process's tool block differs from the steady-state one every in-process request shares. That looks like a separate issue from this one and I'd rather not conflate them; flagging it in case it's already known.Cost. At Opus 1h-write rates (2× base input), a 54.2k cold write is ~$1.63 API-rate-equivalent per fresh session start, before any work happens. On subscription plans the same tokens consume quota rather than dollars, which is probably why this stays invisible — the cost lands on users as rate-limit pressure rather than on any billing dashboard.
Housekeeping. #82229 (2026-07-29) reports the same root cause from the cross-session angle and is still unlabeled — likely worth closing as a duplicate of this one, or vice versa. Also, #82229 describes the Agent SDK sibling #27786 as "closed/fixed"; #27786 was actually closed
NOT_PLANNEDby the stale bot on 2026-03-23 without a fix. So this is the third report of the bug class across two surfaces, and the first two both lapsed without a substantive response.This is a much better instrument than the one in the original report, and the
/clearnatural experiment is the part that makes it conclusive. My filingcompared across processes, which leaves every other per-process difference in
play; holding the process fixed and changing only the session id is what turns a
correlation into a controlled result. Thank you for doing it at n=454.
The ~21.6k ceiling landing where
Bashsits in the tools array is the detail Iwould point a triager at. It is not "the cache did less well", it is a recovery
that stops at a specific offset, and that offset is exactly where the mutated
description begins.
Your housekeeping points are both right, and one of them is my error.
#82229 as a duplicate of this one — this is the one carrying the evidence and
the labels, so consolidating onto the older, unlabeled filing would have
thrown away your measurement.
Checked: #27786 was closed
NOT_PLANNEDon 2026-03-23 by the stale bot, withno fix. I have corrected it in the closing note rather than leave the wrong
statement standing in a closed issue.
On the part you flagged as probably separate — you are right, and it is not a
hypothesis. It is measured.
Your fresh-process median read of 0 is not explained by the session id, since a
fresh start should still recover the pre-
Bashhead and does not. The cause isobservable at the wire: in our capture corpus, capture
s-dc3f8071, requestsn=372–397, Claude Code sends one new tool batch per request at session boot
while the real conversation never grows past a single message.
So a fresh process's tool block is not the steady-state one — it *grows across
the first several requests*, exactly as you guessed, and each growth is a
tools[]change at the head of the cache prefix. That is not one cold start; itis a run of consecutive requests each invalidating from
tools[]onward. A/clearcannot do that, because the process has already settled — which isprecisely why
/clearrecovers to theBashoffset and a fresh processrecovers nothing.
Two implications worth separating, since they are different bugs:
process, changes between them. Recovery ceiling at the
Bashoffset.tools[]membership itself changing across aprocess's first requests. Recovery of nothing until it settles.
They compose. Even with the
BashURL fixed a fresh process would still pay thecascade, and vice versa — so neither fix makes the other unnecessary, and I would
not want this issue closed on the strength of the other.
For anyone reproducing the cascade: it is not visible in
~/.claude/projects/,because
message.usagerecords the outcome, not the outboundtools[]. It needsthe request bytes before anything mutates them. Any interception point works;
ours is simply the one we had.
On the wider point you make last — third report of the class across two
surfaces, first two lapsed without a substantive response. That is the part I
would most like a maintainer to see. The fix is not architectural: the session
URL is in a tool description, which is prose the model never needs to be
per-session, and moving it into the system prompt or a per-request field would
leave the cached prefix byte-stable without changing any behaviour.
---
🤖 Written with Claude Code
— Gunther's Claude Code session
Reproduced the mechanism on 2.1.233 by capturing raw outgoing request bodies. In claude.ai cloud and Remote Control sessions, the
Bashtool'sdescriptionembeds the session'shttps://claude.ai/code/session_…link in three places (the commit-trailer example, the PR-body example, and one more). Comparing two requests whose session identifier differs, theBashdescription is byte-identical except at those URL positions —nameandinput_schemaare unchanged — which matches the divergence shape you measured. Since tool definitions serialize ahead of the system prompt and messages, a changed identifier does invalidate the cached prefix from its first bytes, so the first request after resuming such a session pays a full cache re-write.One scoping note: in a plain local CLI session the
Bashdescription contains no session link at all, so this doesn't affect purely local resumes — the impact is specific to sessions that embed the session link for commit/PR attribution (claude.ai cloud sessions and Remote Control), where a resume can come up under a fresh session identifier.As an immediate mitigation, the
attribution.sessionUrlsetting can omit the session link from commits and PRs, which also removes the varying bytes from the tool description.Agreed that a value that changes per session is a poor fit for the cached tool block when it could ride in a later, cheaper position in the prompt — marking this as a bug for the team to move the session link out of the tool definitions.
🤖 Generated with Claude Code