Skip to content
Sonnet 5.5 Drops tool_choice: Build a Strict Tool Router — ContentBuffer guide

Sonnet 5.5 Drops tool_choice: Build a Strict Tool Router

K
Kodetra Technologies··9 min read Intermediate

Summary

Forced tool use now 400s on Claude Sonnet 5.5. Replace it with strict tools and a retry guard.

Claude Sonnet 5.5 shipped on September 28, 2026, and the first thing many existing agents will hit is a 400 that reads tool_choice: type "tool" and "any" are not supported for this model. Forced tool use is gone. If your pipeline relied on tool_choice={"type": "tool", "name": "..."} to make Claude always return structured data, it breaks the moment you change the model string.

This guide shows the replacement Anthropic documents: tool_choice: auto plus strict: true on your tools. You will build a support-ticket triage function that always ends in exactly one validated tool call, with a retry guard for the case where the model answers in prose. You will also set the new between_tools thinking mode so the call stays cheap and fast.

Sonnet 5.5 costs $2 per million input tokens and $10 per million output tokens, the same as Sonnet 5, with a 1M-token context window and 128K max output. Because pricing did not change, the migration cost is engineering time, and the forced-tool change is the one most likely to cost you an outage.

Prerequisites

  • Python 3.10+ and the anthropic SDK (pip install -U anthropic)
  • An ANTHROPIC_API_KEY in your environment
  • An existing app or script that uses tool_choice of type tool or any (optional, but it makes the examples concrete)

What actually changed on Sonnet 5.5

Anthropic's release notes list five breaking changes from Sonnet 5. Two of them affect almost every tool-using app, and one is a quiet trap.

ChangeOld behavior (Sonnet 5)Sonnet 5.5
Forced tool usetool_choice any / tool works400 error; use auto + strict: true
Turning thinking offthinking: {"type": "disabled"}thinking: {"type": "between_tools"}, only at effort low, medium or high
Sampling paramstemperature etc. acceptedNon-default temperature, top_p, top_k return 400
Assistant prefillAllowed on some models400: use structured outputs or tools instead
Thinking blocksPortableTied to the model and conversation; history must stay append-only

The rest of this guide focuses on the first two rows, because together they decide whether a structured-output call still works.

Step 1: Reproduce the break

Start with the code you probably have today. This is the classic pattern for guaranteeing a structured result:

import anthropic
client = anthropic.Anthropic()

client.messages.create(
    model="claude-sonnet-5-5",
    max_tokens=1024,
    tools=TOOLS,
    tool_choice={"type": "tool", "name": "route_ticket"},   # forced
    messages=[{"role": "user", "content": "I was charged twice."}],
)

Run it against claude-sonnet-5-5 and the API rejects the request before the model sees it:

anthropic.BadRequestError: Error code: 400 - tool_choice: type "tool" and "any" are not supported for this model.

The message text comes from Anthropic's migration guide. Your exact wrapper may format it slightly differently.

Step 2: Turn on strict tool use

Strict tool use uses grammar-constrained sampling, so the tool name is always one of the tools you passed and the input always matches your JSON Schema. You opt in per tool with "strict": true. Every object in the schema needs additionalProperties: false, the schema language is a subset of JSON Schema, and a request can carry at most 20 strict tools.

Here are the two tools for the triage example. route_ticket files a ticket. ask_customer is the escape hatch, so the model never has to invent a queue for a vague ticket.

TOOLS = [
    {
        "name": "route_ticket",
        "description": "File a support ticket into the right queue with a priority.",
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {
                "queue": {"type": "string", "enum": ["billing", "bug", "how_to", "security"]},
                "priority": {"type": "string", "enum": ["low", "normal", "urgent"]},
                "summary": {"type": "string", "description": "One sentence, under 20 words."},
            },
            "required": ["queue", "priority", "summary"],
            "additionalProperties": False,
        },
    },
    {
        "name": "ask_customer",
        "description": "Ask one clarifying question when the ticket is too vague to route.",
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {"question": {"type": "string"}},
            "required": ["question"],
            "additionalProperties": False,
        },
    },
]

Two design notes. First, giving the model a legitimate second tool matters more now: with forced use gone, the model can decline to call a tool at all, and a clear alternative reduces that. Second, keep enum lists short and mutually exclusive. Strict mode guarantees the value is in the enum, not that it is the right one.

Step 3: Tell the model what you used to enforce

Forced tool use was a guarantee at the API layer. Now it is a strong instruction plus a check. Put the requirement in the system prompt, in plain words:

SYSTEM = (
    "You triage support tickets. Respond ONLY by calling exactly one tool: "
    "route_ticket if you can place the ticket, otherwise ask_customer. "
    "Never answer in plain text."
)

The migration guide's own example does the same thing, appending "Use the get_weather tool." to the user message. Either place works. The system prompt is better when the rule applies to every call.

Step 4: Make the call with between_tools thinking

Sonnet 5.5 runs adaptive thinking by default at high effort. For a routing call you do not need a long up-front think. between_tools is the new lowest setting: it turns off up-front thinking and only lets the model write notes between tool calls, which come back as thinking blocks.

def call_once(messages):
    return client.messages.create(
        model=MODEL,
        max_tokens=2048,
        system=SYSTEM,
        tools=TOOLS,
        tool_choice={"type": "auto"},          # "any"/"tool" would 400 on 5.5
        thinking={"type": "between_tools"},    # cheapest setting; needs effort <= high
        output_config={"effort": "medium"},
        messages=messages,
    )

Constraints from the docs worth memorizing: between_tools returns a 400 at xhigh or max effort, it cannot be combined with display, budget_tokens or block_binding, and effort cannot change mid-conversation while it is on. Also note that max_tokens now covers thinking and text together, so do not set it tight.

Step 5: Read blocks by type and guard the result

Because the response can start with a thinking block, response.content[0] is no longer safe. Filter by type. Then add the piece that replaces the old API guarantee: a loop that checks you got exactly one tool call and nudges the model if not.

def tool_calls(response):
    return [b for b in response.content if b.type == "tool_use"]


def triage(ticket_text, max_attempts=3):
    messages = [{"role": "user", "content": ticket_text}]
    for attempt in range(1, max_attempts + 1):
        resp = call_once(messages)
        calls = tool_calls(resp)
        if len(calls) == 1:
            return {"tool": calls[0].name, "input": calls[0].input, "attempts": attempt}
        messages.append({"role": "assistant", "content": resp.content})  # thinking blocks unchanged
        messages.append({
            "role": "user",
            "content": "That was not exactly one tool call. Call route_ticket or ask_customer now.",
        })
    raise RuntimeError(f"no valid tool call after {max_attempts} attempts")

Passing resp.content back untouched keeps the thinking blocks intact, which the docs require in tool-use loops. Do not filter them out to save tokens, and do not edit earlier messages, since Sonnet 5.5 thinking blocks are bound to the conversation.

Full script

import json
import anthropic

MODEL = "claude-sonnet-5-5"
client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

TOOLS = [
    {
        "name": "route_ticket",
        "description": "File a support ticket into the right queue with a priority.",
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {
                "queue": {"type": "string", "enum": ["billing", "bug", "how_to", "security"]},
                "priority": {"type": "string", "enum": ["low", "normal", "urgent"]},
                "summary": {"type": "string", "description": "One sentence, under 20 words."},
            },
            "required": ["queue", "priority", "summary"],
            "additionalProperties": False,
        },
    },
    {
        "name": "ask_customer",
        "description": "Ask one clarifying question when the ticket is too vague to route.",
        "strict": True,
        "input_schema": {
            "type": "object",
            "properties": {"question": {"type": "string"}},
            "required": ["question"],
            "additionalProperties": False,
        },
    },
]

SYSTEM = (
    "You triage support tickets. Respond ONLY by calling exactly one tool: "
    "route_ticket if you can place the ticket, otherwise ask_customer. "
    "Never answer in plain text."
)


def call_once(messages):
    return client.messages.create(
        model=MODEL,
        max_tokens=2048,
        system=SYSTEM,
        tools=TOOLS,
        tool_choice={"type": "auto"},          # "any"/"tool" would 400 on 5.5
        thinking={"type": "between_tools"},    # cheapest setting; needs effort <= high
        output_config={"effort": "medium"},
        messages=messages,
    )


def tool_calls(response):
    # Read blocks by type: replies may start with a thinking block.
    return [b for b in response.content if b.type == "tool_use"]


def triage(ticket_text, max_attempts=3):
    messages = [{"role": "user", "content": ticket_text}]
    for attempt in range(1, max_attempts + 1):
        resp = call_once(messages)
        calls = tool_calls(resp)
        if len(calls) == 1:
            return {"tool": calls[0].name, "input": calls[0].input, "attempts": attempt}
        # Model answered in text (or called several tools). Send the turn back with a nudge.
        messages.append({"role": "assistant", "content": resp.content})  # thinking blocks unchanged
        messages.append({
            "role": "user",
            "content": "That was not exactly one tool call. Call route_ticket or ask_customer now.",
        })
    raise RuntimeError(f"no valid tool call after {max_attempts} attempts")


if __name__ == "__main__":
    for t in [
        "I was charged twice for my Pro plan this month, please fix ASAP.",
        "it doesn't work",
        "Someone logged into my account from Russia and changed my email!",
    ]:
        print(json.dumps(triage(t), indent=2))

Worked example: three tickets

Run the script with three tickets that exercise each path: a clear billing problem, a vague one, and an account takeover.

$ export ANTHROPIC_API_KEY=sk-ant-...
$ python triage.py

Expected output shape (the wording of summary and question will differ on your run, and I have not pasted a live transcript here because model text varies):

{
  "tool": "route_ticket",
  "input": {"queue": "billing", "priority": "urgent", "summary": "Customer charged twice for Pro plan this month."},
  "attempts": 1
}
{
  "tool": "ask_customer",
  "input": {"question": "What were you trying to do, and what happened instead?"},
  "attempts": 1
}
{
  "tool": "route_ticket",
  "input": {"queue": "security", "priority": "urgent", "summary": "Unauthorized login and email change reported."},
  "attempts": 1
}

What is guaranteed is the structure: tool is one of your two names, and queue and priority are always values from your enums. What is not guaranteed is that the model chooses the tool you would have chosen, or that it calls a tool at all. That is what the retry loop covers.

I also tested the guard logic offline with a fake client that first returns a prose reply and then a valid tool call. It returned on attempt 2 as intended, and a mocked HTTP transport confirmed the request body carries strict: true, tool_choice: auto and thinking: between_tools.

Common pitfalls

  • Missing additionalProperties: false. Strict tools need it on every object, including nested ones. Leave it off one nested object and the request is rejected.
  • Using strict on the wrong entries. MCP, computer use and browser use toolset entries do not accept strict. Put it only on your own function tools.
  • Bedrock has no strict tool use. On Amazon Bedrock the docs say to use auto without strict and validate tool input in your own code. A schema validator such as jsonschema or Pydantic on block.input is worth keeping regardless.
  • More than 20 strict tools. The per-request cap is 20. If you have a large tool catalog, load only the tools relevant to the current step.
  • Indexing content[0]. Thinking blocks can lead the response. Always filter on block.type.
  • Tight max_tokens. Thinking counts against it. A budget that worked on a non-thinking model can now truncate the tool call.
  • Assuming disabled thinking still works. thinking: {"type": "disabled"} also returns 400 on 5.5. Switch to between_tools, and remember that pairing it with xhigh effort fails.
  • Prefill as a workaround. Prefilling the assistant turn to force a tool call is not an escape hatch. The API returns "This model does not support assistant message prefill."
  • Fallback to Sonnet 5. The docs note that with fallback configured, between_tools maps to disabled on Sonnet 5, but forced tool use is the part you now cannot express, so test the fallback path separately.
  • Recalibrated effort. Effort levels changed on 5.5. Re-run your effort sweep instead of copying the value from Sonnet 5.

Quick reference

GoalSonnet 5 waySonnet 5.5 way
Always return structured datatool_choice: {type: tool, name}tool_choice: auto + strict: true + system prompt + guard loop
Skip up-front thinkingthinking: disabledthinking: {type: between_tools} (effort low to high)
Cap tool countn/a20 strict tools per request
Schema ruleLooseadditionalProperties: false on every object
Read the replycontent[0]Filter by block.type
Model IDclaude-sonnet-5claude-sonnet-5-5

Next steps

  • Swap the retry loop for a fallback: after two failed attempts, route to a human queue instead of raising.
  • Log attempts in production. A rising average means your system prompt or tool descriptions need work.
  • Read the full migration checklist for the computer use toolset change (computer_toolset_20260801) and the advisor tool pairing changes if you use either.
  • Add a Pydantic model on calls[0].input for business rules that JSON Schema cannot express, such as a priority that must be urgent whenever the queue is security.

Sources

  • Claude Sonnet 5.5 overview and migration guide, Claude Platform Docs (platform.claude.com/docs/en/models/sonnet-5-5)
  • Strict tool use, Claude Platform Docs (platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use)
  • Claude Platform release notes, September 28, 2026

Comments

Subscribe to join the conversation...

Be the first to comment

Found this useful?

Get new AI guides for builders by email. Free.

Join 3,592 builders reading daily.

Also get