`Bash` tool description embeds the per-session URL, so every `/resume` invalidates the whole prompt cache

Status Open
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Aug 16, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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 with
cache_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:

  1. Run a session long enough to build a substantial cached prefix.
  2. Exit, and resume within the cache TTL.
  3. 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)

View original on GitHub ↗

3 Comments

adarshp · 12 days ago

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), reading message.usage on 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 used ephemeral_1h, so expiry is not in play.

The natural experiment. /clear mints a new session id — and therefore a new Bash description — but stays in the same process, so the tool block is otherwise at steady state. It writes a new transcript file headed by the /clear marker, 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 /clear trivially 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 /clear at 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 of Bash (Agent, Artifact, AskUserQuestion). So under otherwise-identical in-process conditions, changing only the session id caps prefix recovery at roughly where Bash sits 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 Bash URL alone — if it were, fresh starts should also recover the ~21.6k pre-Bash head, and they don't. Something upstream of Bash additionally 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_PLANNED by 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.

Gunther-Schulz · 10 days ago

This is a much better instrument than the one in the original report, and the
/clear natural experiment is the part that makes it conclusive. My filing
compared 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 Bash sits in the tools array is the detail I
would 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 and this issue are the same root cause from two angles. I have closed

#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.

  • #82229 describes #27786 as closed/fixed. That is wrong and it was my mistake.

Checked: #27786 was closed NOT_PLANNED on 2026-03-23 by the stale bot, with
no 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-Bash head and does not. The cause is
observable at the wire: in our capture corpus, capture s-dc3f8071, requests
n=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; it
is a run of consecutive requests each invalidating from tools[] onward. A
/clear cannot do that, because the process has already settled — which is
precisely why /clear recovers to the Bash offset and a fresh process
recovers nothing.

Two implications worth separating, since they are different bugs:

  • This issue — a per-session URL inside a tool description. Stable within a

process, changes between them. Recovery ceiling at the Bash offset.

  • #81967 / the cascadetools[] membership itself changing across a

process's first requests. Recovery of nothing until it settles.

They compose. Even with the Bash URL fixed a fresh process would still pay the
cascade, 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.usage records the outcome, not the outbound tools[]. It needs
the 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

bcherny collaborator · 6 days ago

Reproduced the mechanism on 2.1.233 by capturing raw outgoing request bodies. In claude.ai cloud and Remote Control sessions, the Bash tool's description embeds the session's https://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, the Bash description is byte-identical except at those URL positions — name and input_schema are 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 Bash description 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.sessionUrl setting 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