Sonnet 5 appears to receive the legacy Write tool description due to substring model matching
Filed by Claude Code on evnchn's behalf.
TL;DR:
Claude Code ships two Write tool descriptions and selects between them by model id. claude-opus-5, claude-opus-4-8, and claude-fable-5 receive the lean one. claude-sonnet-5 receives the legacy one — including a rule the lean bundle drops entirely:
NEVER create documentation files (*.md) or README files unless explicitly requested by the User.
This looks like a defect rather than a policy choice, for two reasons:
- Sonnet 5 is positioned against Opus 4.8, and Opus 4.8 is on the lean bundle. Anthropic's own announcement says Sonnet 5's "performance is close to that of Opus 4.8" and that "its higher-effort performance can match Opus 4.8 on some tasks." The harness nonetheless gives Sonnet 5 the prompt bundle written for the previous generation, not the one Opus 4.8 itself receives.
- The mechanism looks like a stale predicate. The selector matches Opus by exact id (a list ending at
claude-opus-4-7) but Sonnet and Haiku by substring (t.includes("sonnet")). A condition written for Sonnet 4.x still catches Sonnet 5; Opus 4.8 and Opus 5 escape by not being on the list. That asymmetry is hard to read as intentional — it means the Sonnet line inherits legacy prompts by default while the Opus line does not.
Scope, stated plainly: I have shown the inconsistency, not a downstream user-visible failure — I could not make the rule change behaviour in 19 trials (details in the last fold). That bounds the severity, not the existence.
<details>
<summary><b>The comparison, quoted from Anthropic's announcement</b></summary>
From Introducing Claude Sonnet 5, verbatim:
"Sonnet 5 narrows the gap: its performance is close to that of Opus 4.8, but at lower prices."
"its higher-effort performance can match Opus 4.8 on some tasks."
"Sonnet 5 offers a wider range of cost-performance options than Sonnet 4.6, and in some cases matches Opus 4.8's capability levels."
These are bounded claims — "close to", "on some tasks", "in some cases" — and I'm not reading them as a parity guarantee. The relevant part is which model Sonnet 5 is measured against, and on what: Opus 4.8, on higher-effort agentic work. Opus 4.8 is on the lean bundle; Sonnet 5 is not.
</details>
<details>
<summary><b>Per-model observations — one install, CC 2.1.220</b></summary>
Two evidence strengths, labelled rather than blended:
- verbatim — the agent was asked to print its
Writetool description in full and did; the returned text either contains the rule or does not. - self-report — the agent was asked in plain language whether any instruction restricts creating
.mdfiles, and quoted or denied one. Weaker, especially for a denial.
| Model | Bundle | Carries the .md rule | Evidence |
|---|---|---|---|
| claude-fable-5 | lean | no | verbatim |
| claude-opus-5 | lean | no | verbatim |
| claude-opus-4-8 | lean | no | self-report (denial) |
| claude-opus-4-7 | legacy | yes | code path only — untested |
| claude-opus-4-6 | legacy | yes | self-report (quoted the rule) |
| claude-sonnet-5 | legacy | yes | verbatim |
| claude-haiku-4-5 | legacy | yes | verbatim |
Both sides of the boundary are observed (4.6 legacy, 4.8 lean), which is what locates the dividing line. 4.7 sits interior to the legacy side by the same exact-id list and was not tested.
</details>
<details>
<summary><b>Code path (2.1.224 binary, extracted by byte offset)</b></summary>
The Write tool's description is a function of the model:
async prompt({model:e}){ return s5u(e) } // Write tool definition
function s5u(e){ if(sE(e)){ /* lean */ } return /* legacy */ }
The lean branch contains no rule about .md files at all. The legacy branch carries it.
sE = memo((e)=>{
if(!e) return false;
if (truthy(env.CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT)) return true;
if (falsy(env.CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT)) return false;
if (!Th_(e)) return true;
if (remoteGate("tengu_velvet_tide", false)) return true;
return fh_(e);
});
function Th_(e){
if (PKe(e)) return false;
let t = modelId(e);
if (hasCapability(t,"lean_prompt") || t==="claude-mythos-5") return false;
if (t.includes("claude-3-") || t.includes("haiku") || t.includes("sonnet")
|| t==="claude-opus-4-0" || t==="claude-opus-4-1" || t==="claude-opus-4-5"
|| t==="claude-opus-4-6" || t==="claude-opus-4-7") return true;
return !ph();
}
The asymmetry is that one condition: Opus by exact id, Sonnet and Haiku by substring. claude-sonnet-5 matches "sonnet"; claude-opus-4-8 and claude-opus-5 match no exact id and fall through.
Footnote: I could not resolve ph() — it has exactly one assignment in the binary and it is a frozen object, not a callable. So I can't explain why the fall-through lands on lean, only that it observably does on three models.
</details>
<details>
<summary><b>The selected description changes on <code>/model</code> mid-session</b></summary>
Because the description is rebuilt per request from the current model, switching models changes the instruction set inside a running conversation. In one observed session, the model's self-report changed consistently with the selector:
- On
claude-sonnet-5: quotedNEVER create documentation files (*.md) or README files unless explicitly requested by the User. - After
/model opus, same session: "There is no general restriction on creating .md files."
This is one session and model self-report is noisy, so treat it as consistent-with rather than proof. The static selection behaviour above is the stronger claim. Flagging it as a second question: is a mid-conversation change to the rule set intended?
</details>
<details>
<summary><b>Scope — what I did and did not demonstrate</b></summary>
I have not shown a downstream user-visible failure.
The split was noticed while chasing a separate report — an agent intermittently not writing a report file its CLAUDE.md asked for. Across 19 trials (Sonnet and Opus; tight and diluted CLAUDE.md; plain prompt and /code-review), the file was written 19/19 times. On the legacy bundle the model treats a checked-in CLAUDE.md convention as satisfying the "unless explicitly requested by the User" carve-out and complies.
Two apparent failures during that testing were artifacts of my own harness (an unanswered permission dialog; a poll that expired while the agent was mid-write) and are excluded rather than counted.
Every trial also loaded a large personal global CLAUDE.md, which biases toward compliance — so 19/19 is not evidence the rule is harmless in general, only that I could not make it bite in that setup.
What's reported is the prompt-selection inconsistency and the mid-session change.
</details>
If this is unintended, the ask is less "add Sonnet 5 to a list" than: could the selector be made generation-aware or table-driven, so a new model id doesn't inherit a legacy prompt bundle by substring? Exact-id matching for Sonnet and Haiku is one implementation, but it's brittle in the other direction — every future Sonnet or Haiku would then silently fall through until someone updates the list.
---
Changelog
- 2026-08-11 — Sharpened the framing from "inconsistency + open question" to a defect report, and added the Opus 4.8 comparison quoted from Anthropic's Sonnet 5 announcement. The original wording led with the non-reproduction caveat in a way that understated the finding; the caveat is unchanged in substance and now sits in the scope fold.