Read the Release Notes Before You Bump the Model

Here is a pull request that every team running Claude will be tempted to approve:
- MODEL = "claude-opus-5"
+ MODEL = "claude-opus-5-5"It's one line. The new model is cheaper ($4/$20 per million tokens instead of $5/$25), and it's newer. The version only went up by half, so it reads like a minor upgrade. Who would block that?
Suppose your agent finishes deterministically. When the work is done, it takes every tool away except submit_result and forces the model to call it. You get one well-formed exit, with no chatty final message and nothing to parse. This is what happens if the PR merges and nobody reads the release notes:
- The first request that reaches the exit fails. Opus 5.5 rejects forced tool choice with a 400. Every run that used to finish now crashes at the last step, after paying for all the work before it.
- The obvious fix fails too, but only for some accounts. You stop forcing the call and trim the tools array down to
submit_resultinstead. That works in a test organisation created before 31 August 2026. On a newer account, the same request is a 400. On the older one it still costs you, because every exit now misses the prompt cache. - Two changes never raise an error at all. Requests that don't set
effortnow think less, because the default dropped fromhightomedium. Any UI that streams the model's narration between tool calls goes quiet. Your users notice before your monitoring does.
Every one of these is in the Opus 5.5 migration guide. Ten minutes of reading turns a production incident into a checklist.
5.5 is not a minor version
Most developers learnt to read version numbers from semantic versioning, and semver says a change in the second number is safe. 5.0.0 to 5.5.0 means new features and no breakage. Only 6.0.0 is allowed to break you. With that instinct, "Opus 5 to Opus 5.5" looks like npm update, not a migration.
Model names aren't semver. claude-opus-5-5 has two numbers and no third. There will never be a 5.5.1 that fixes a regression and leaves everything else alone. The name identifies a generation of the model, and says nothing about compatibility. Judged by semver's own rule, this release is a 6.0.0: four request shapes that Opus 5 accepted now return a 400.
The version numbers that do follow the rules don't move either. Your SDK version stays the same. The anthropic-version: 2023-06-01 header you send on every request stays the same, because the API contract didn't change. The model behind it did. Which requests are accepted, what the defaults are, where the text comes back: all of that now belongs to the model ID, and the model ID has no compatibility promise attached.
So treat every model change as a major version, however small the number looks. The model is the largest dependency in an LLM application, and the only one you can't pin to a behaviour, just to a name.
Here is what changed between Opus 5 and Opus 5.5, checked against the migration guide on the day I wrote this:
The 400s are the easy part, because the first test run finds them. The quiet ones are worse. If your request omits effort, Opus 5.5 does less thinking than Opus 5 did. If your UI streams the model's narration between tool calls, it goes silent, because that text now arrives in thinking blocks whose text is empty unless you ask for it. Neither change raises an error. You only find them by reading the notes or by hearing from your users.
The pattern that breaks: forcing the exit
This is the exit from the scenario above. It removes every tool except one, then forces the model to call that one:
final = client.messages.create(
model="claude-opus-5",
max_tokens=16000,
tools=[SUBMIT_RESULT], # everything else removed
tool_choice={"type": "tool", "name": "submit_result"}, # and this one forced
messages=history,
)On Opus 5.5 that fails twice.
Forced tool choice is gone. tool_choice of {"type": "any"} or {"type": "tool", "name": ...} returns:
tool_choice: type "tool" and "any" are not supported for this model.The same applies on the Batches API and the token-counting endpoint. Only auto and none remain. disable_parallel_tool_use still works, but it now means at most one call. Combined with forced choice, it used to mean exactly one.
The tools array is frozen for the rest of the conversation. Opus 5.5 always thinks; thinking can no longer be turned off. Each thinking block carries a signature that binds it to the prefix that produced it: the system prompt, the set of tools and every message before the block. When you replay the conversation, the API checks that prefix. Add, remove, rename or edit a tool and every earlier thinking block is bound to a conversation that no longer exists. For accounts created on or after 31 August 2026, that is a 400:
messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation. ...Older accounts aren't rejected. They still pay for it, though: changing the tools array changes the very front of the cached prefix, so every request after the edit misses the prompt cache.
You'll hear that you can no longer remove tools on Opus 5.5. That is half right. You can't remove a tool by editing the array. You can still withdraw one, as long as the conversation stays append-only.
Getting determinism back
auto never guarantees a tool call, so no single setting replaces what forced tool choice gave you. You get the guarantee back by combining several steps, each covering what the previous one can't promise.
First: did you need a tool at all?
Many forced tool calls existed only to get JSON back. If nothing runs when submit_result is "called", and you just read its input, you don't need a tool. Use structured outputs. The response is constrained to your schema, so it always parses:
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
output_config={
"effort": "medium",
"format": {"type": "json_schema", "schema": RESULT_SCHEMA},
},
messages=history,
)That is the easy case. If the tool is your agent's exit, and the model still has other tools available mid-loop, you need the next steps.
Step 1 and 2: withdraw the other tools, and say which one you want
Opus 5.5 supports mid-conversation tool changes. Instead of editing tools, you append a role: "system" message carrying tool_removal blocks. The array is still sent exactly as it was on the first request, so the prefix is intact, the thinking blocks stay valid and the cache still hits. The model just can't see the withdrawn tools from that point on.
Put the instruction in the same message. A system message has operator authority, which a user turn doesn't, and it sits exactly where the decision is made:
FINISH = "submit_result"
def withdraw_all_but_finish(history, tools):
# Must come straight after a user turn (a tool_result turn counts)
# and be the last message before Claude's next turn.
removals = [
{"type": "tool_removal", "tool": {"type": "tool_reference", "name": t["name"]}}
for t in tools if t["name"] != FINISH
]
history.append({"role": "system", "content": [
*removals,
{"type": "text", "text": (
f"The work is complete. Call {FINISH} now with the final result. "
"It is the only tool available for the rest of this conversation."
)},
]})Mark submit_result as strict: true in the original tool definition, with additionalProperties: false on every object in its schema. That gives back the half of the old guarantee you can keep: if the model makes the call, the arguments validate. On Amazon Bedrock, strict tool use isn't available for Opus 5.5, so validate the input in your own code there.
The beta header is inline-tools-2026-09-15 on the Claude API. The older mid-conversation-tool-changes-2026-07-01 still works for reference-only changes like these, and it is the one to use on Bedrock and Google Cloud.
Step 3 and 4: check, and retry a bounded number of times
auto lets the model decide. With only one tool left and an explicit instruction it should call that tool, but auto makes no promise, and an exit needs one. So check:
def finish(history, tools, attempts=3):
withdraw_all_but_finish(history, tools)
for _ in range(attempts):
response = client.beta.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
betas=["inline-tools-2026-09-15"],
output_config={"effort": "medium"},
tools=tools, # identical to the first request, every time
messages=history,
)
history.append({"role": "assistant", "content": response.content})
if response.stop_reason == "refusal":
break
call = next(
(b for b in response.content if b.type == "tool_use" and b.name == FINISH),
None,
)
if call:
return call.input
history.append({"role": "user", "content": f"Call {FINISH} now. Do not reply in text."})
return extract_result(history) # step 5Note what the loop doesn't do. It never removes the failed reply or the reminder, and it never touches tools. Each retry only appends. Deleting the reply that missed would be another edit to the prefix.
Step 5: the exit that always works
If the model still hasn't called the tool, end the conversation with a fresh request. Send no tools, set a structured-output format and give it the transcript (or a summary of it) as input:
def extract_result(history):
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
output_config={"format": {"type": "json_schema", "schema": RESULT_SCHEMA}},
messages=[{"role": "user", "content": render_transcript(history)}],
)
return json.loads(next(b.text for b in response.content if b.type == "text"))A new conversation has its own prefix, so preserved thinking has nothing to check against. You lose the reasoning in the old thinking blocks, but the transcript's text and tool results carry most of what matters for a final answer. This branch should be rare. Log it when it runs, because frequent use means steps 1–4 need work.
One principle covers all five steps: the guarantee now lives in your code, not in a request parameter. That was always the more honest place for it. A forced tool call only guaranteed that the API would return a tool_use block. Whether the result was good was always your code's problem.
Doing the due diligence
None of these changes is hard to fix once you know about it. What matters is whether you learn about it from the release notes or from production. This is the checklist I run before changing a model string:
- Read three documents, not one. The model's migration guide lists the breaking changes. The what's new page explains them. The API release notes cover platform changes that shipped alongside the model, such as the new beta header for tool changes.
- Grep for every rejected parameter. For Opus 5.5 that means
tool_choice,"disabled",budget_tokens,temperature,top_p,top_k, a trailing assistant prefill andcomputer_20251124. Each one is a 400 waiting to happen. - Find every place you build
messagesyourself. Anything that edits the system prompt, rewrites the tools array, deletes an old reminder or summarises old turns in place breaks preserved thinking. If your account is older than 31 August 2026 you won't see the error. In your test environment, setprefix_mismatch_behaviorto"error"insidethinking.block_binding(betathinking-binding-controls-2026-08-01). Any value opts that request into enforcement, so you get the errors a newer account would. - Set every default you rely on explicitly. Opus 5.5 defaults to
mediumeffort where Opus 5 defaulted tohigh. If you never seteffort, your quality and latency both changed without a diff. Set it explicitly and re-run your evals at the level you choose. - Look for quiet changes in what users see. Narration between tool calls moved into
thinkingblocks. If you stream it to users, opt intodisplay: "updates"(betathinking-display-updates-2026-08-18) and render those blocks. - Check your fallbacks. On the Claude API, only Fable 5.1 and Mythos 5.1 can read Opus 5.5 thinking blocks. A router or refusal fallback to Opus 5 continues without the reasoning. The request still succeeds, but you should know it happens.
- Make the model bump its own pull request. Don't bundle it with a feature. Run your evals before and after, and ship it behind a flag or to a slice of traffic first.
Anthropic now ships a /claude-api migrate skill in Claude Code that applies the mechanical changes for you: the model ID, rejected parameters, prefill replacement and effort calibration. Use it, because it is good at the grep. It doesn't know that your agent's exit depended on forced tool choice, or that your support team reads the narration between tool calls. You only learn those things by reading the notes and then reading your own code.
The model ID is the most important dependency in an LLM application, and it is the one dependency you can't lock. Read its release notes the way you would read the changelog for a major version, because that is what a model change is.