Bedrock structured outputs now work on Claude Sonnet 5 — dropping forced tool use turned thinking on

Amazon Bedrock now accepts outputConfig.textFormat on Claude Sonnet 5. Replacing forced tool use with it fixed the JSON shape, but needed thinking disabled.

The Amazon Bedrock icon, a white line-drawn brain of hexagonal outlines with circuit traces ending in small circles, on a teal rounded square, beside the words Amazon Bedrock in dark navy on a white card
On this page

Introduction

Earlier this month, Amazon Bedrock refused structured outputs on Claude Sonnet 5, so a translation pipeline I work on kept its JSON in shape with a prompt rule and an error-feedback retry. On 2026-10-20, AWS announced structured outputs for all Claude models on Bedrock, and I wanted to know whether that workaround could go.

It could. With the prompt rule removed, forced tool use broke 5 of 20 calls on a real batch, and outputConfig.textFormat broke none. The switch only kept the old speed with thinking turned off, because forced tool use had been keeping Sonnet 5 from thinking at all, and without it one 143-segment batch went from 47 seconds to 731.

Bedrock now enforces the schema while the model generates, and the request no longer depends on forced tool use, which the 5.5 models refuse.

What Bedrock accepts on Claude Sonnet 5 now

Every Claude model I could call on Bedrock now takes a JSON Schema for its answer. Claude Code sent one small request per model to the global.anthropic.* inference profiles in ap-northeast-1. It went through both runtime APIs: Converse, Bedrock’s model-agnostic chat API, and InvokeModel, which carries Anthropic’s own request body.

ModelConverse outputConfig.textFormatInvokeModel output_config.format
Sonnet 4.5, 4.6, 5, 5.5AcceptedAccepted
Opus 4.5, 4.6, 4.7, 4.8, 5, 5.5AcceptedAccepted
Haiku 4.5, 5.5AcceptedAccepted
Fable 5, 5.1Not testableNot testable

The prompt asked for prose, an enum value outside the schema, and an extra key, and every accepted response still matched the schema. Fable could not be used because of an account setting, so I did not check whether it supports structured outputs. The Sonnet 5 model card now lists Structured outputs under Supported.

A deliberately broken schema, {"type": "bogus"}, was rejected, so Bedrock reads the field instead of dropping it:

ValidationException: The json schema definition at outputConfig.textFormat.structure.jsonSchema is invalid.

Converse takes the schema serialised to a JSON string with json.dumps(), while InvokeModel takes the Python dict itself, so the two request shapes are not interchangeable:

Converse request
client.converse(
modelId="global.anthropic.claude-sonnet-5",
messages=messages,
outputConfig={"textFormat": {
"type": "json_schema",
"structure": {"jsonSchema": {
"schema": json.dumps(schema), # a string; InvokeModel takes the dict
"name": "translations",
}},
}},
)
InvokeModel request
client.invoke_model(
modelId="global.anthropic.claude-sonnet-5",
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": max_tokens,
"messages": anthropic_messages,
"output_config": {"format": {
"type": "json_schema",
"schema": schema, # a dict; Converse takes a JSON string
}},
}),
)

toolChoice, strict, and textFormat each guarantee a different thing

toolChoice controls how the model picks from the tools list: let it decide (auto), force some tool (any), or force a named one (tool). strict: true on a tool definition makes that tool’s arguments match its schema when it is called. outputConfig.textFormat makes the reply itself match a schema, with no tool involved.

What toolChoice, strict, and textFormat each controlA grid of three settings against four questions. toolChoice controls whether the model calls a tool, needs a tool, forces a call when set to any or tool, and is not part of structured outputs. strict controls the structure of the tool input JSON, needs a tool, forces nothing, and is part of structured outputs. textFormat controls the structure of the text reply JSON, needs no tool, has no call to force, and is part of structured outputs.toolChoicestrict: trueoutputConfig.textFormatWhat it controlsNeeds a toolForces a callPart ofstructured outputsWhether the modelcalls a toolStructure of thetool input JSONStructure of thetext reply JSONYesYesNoYes, withany or toolNoNo tool to callNoYesYes
Only toolChoice can force a call. strict and textFormat make JSON match a schema: strict for a tool’s arguments, textFormat for the reply.

strict and textFormat are the two halves of structured outputs. Both use constrained decoding: Bedrock compiles the schema into a grammar and the model can only sample tokens the grammar allows. toolChoice is older and is not part of structured outputs.

How constrained decoding worksBedrock compiles a JSON Schema into a grammar. At one generation step, after the output {", the grammar allows only the key translations; candidates such as text, Sure and Here are masked. The tokens are illustrative.Bedrock compiles the schema into a grammar, and the grammar masks tokensJSON SchemaGrammarOne generation step{"translations": [{"id", "translated_text"}]}After {"the only key allowedis "translations"Output so far{"Candidates for the next tokentranslationstextSureHereallowedmasked
Bedrock compiles the schema into a grammar that masks every token it does not allow. The tokens are illustrative.

Are forced tool use and structured outputs mutually exclusive?

No. I had assumed structured outputs was implemented as a tool, so it only worked with forced tool use.

Structured outputs is not a tool. It restricts which tokens the model can pick, in two ways. strict is an attribute on a tool definition and constrains that tool’s arguments when the model calls it. textFormat sits on the request and constrains the whole reply.

That assumption describes the workaround the earlier article was built on. Define a tool whose input schema is the answer, force it with toolChoice: {"tool": {"name": ...}}, and read the arguments. Forcing guarantees the call and never checks its contents, which is how the keys came back as text.

strict fills the gap forced tool use leaves. Forcing guarantees the model calls the fake tool, and strict guarantees the input it fills in matches the tool’s schema. In other words, the result does not come from running the fake tool. It is the input the model put into it, already JSON that matches the schema.

A normal tool compared with a fake toolTwo rows of four steps. Normal tool: offer get_weather, the model fills in location "Tokyo", your code runs get_weather("Tokyo"), and the final result is the weather. Fake tool: offer submit_translations, the model fills in translations, nothing runs the tool, and the input the model filled in is itself the final result.Normal tool: the arguments are an inputOffer a toolget_weather( location)Model fills in argumentslocation: "Tokyo"Your code runs itget_weather("Tokyo")Final result18°C, cloudyFake tool: the arguments are the answerOffer a toolsubmit_translations( translations)Model fills in argumentstranslations: [ … ]Nothing runs itinput is read as-isFinal result{"translations": […]}
Both rows offer a tool and let the model fill in its arguments. A normal tool is then run. The fake tool is not, and the arguments themselves are the answer.

Sonnet 5 accepted a request carrying both strict and forced tool use, and every result matched the schema. textFormat is separate from tools altogether, so it needs no tool at all and nothing to force.

What auto, any, and tool mean

toolChoice decides how the model uses the tools list:

  1. {"auto": {}}: the model decides which tool to call, or answers in text with no tool at all. It is also what the API does when toolChoice is left out.

    tool_config = {
    "tools": [get_weather, get_time],
    "toolChoice": {"auto": {}}, # may call either tool, or answer in text only
    }
  2. {"any": {}}: the model must call at least one tool from the list, and generates no text.

    tool_config = {
    "tools": [get_weather, get_time],
    "toolChoice": {"any": {}}, # must call a tool, but the model picks which
    }
  3. {"tool": {"name": ...}}: the model must call the named tool.

    tool_config = {
    "tools": [get_weather, get_time],
    "toolChoice": {"tool": {"name": "get_weather"}}, # must call get_weather
    }

The last two are forced tool use: the model has to use a tool and cannot answer in text.

In the small probe, Opus 4.7, Opus 4.8, and Haiku 5.5 answered in text with stopReason: end_turn and made no tool call. With strict and auto, the schema holds only if a call happens.

Is strict the same thing as textFormat?

Same engine, different target. AWS describes them as two mechanisms that “can be used independently or together in the same request”.

Where strict and textFormat carry the schema, and where the answer comes backTwo rows. With strict: true the schema sits on a tool named submit_translations that nothing ever runs; the model answers with a tool_use block, the grammar covers its input, and the code reads that input as the answer. With outputConfig.textFormat the schema sits on the request, and the grammar covers the reply text itself.RequestResponsestrict: trueA tool your code never runsname: "submit_translations"strict: trueinputSchema: {…}tool_use block: the “call”name: "submit_translations"input: {"translations": [ … ]}Nothing runs the tool. The code reads input as the answer.textFormatA field on the requestoutputConfig.textFormat: {schema: {…}}Text block: the reply{"translations": [ … ]}Grammar applies here
strict puts the schema on a fake tool that never runs, and the answer comes back as the tool_use input. textFormat puts it on the request, and the answer is the reply itself.
strict: trueoutputConfig.textFormat
Where it goesInside a tool definitionTop level of the request
What it constrainsThe JSON the model puts in the fake tool’s inputThe model’s output when it answers in text
When it appliesWhen the model chooses to call that toolWhen the model chooses not to call a tool and answers in text (every response, if no tools are offered)

Sending both in one request works, and each keeps to its own output. I sent 30 requests to Sonnet 5 with a strict tool, toolChoice: auto, and textFormat all set. Every reply came back as either a tool call or text, never both.

With auto, though, the model mostly called the tool, including 4 of the 10 runs told not to use any tool, so textFormat applies only when the model decides not to call the tool.

strict and textFormat take two separate paths. When the model calls the tool, strict applies; when it answers in text instead, textFormat applies. Either way, the result matches a schema. All 24 tool inputs and all 6 texts matched their own schema.

Three ways to ask for the same JSON on a real batch

I tried three combinations: forced tool use + strict, strict + auto, and textFormat. textFormat was the only request that held on both Sonnet 5 and Sonnet 5.5 without leaving a decision to the model.

The test batch had 63 segments, and the prompt rule naming text as a forbidden key was removed so that a shape error had a chance to appear. Each request ran 20 times on Sonnet 5 and 10 times on Sonnet 5.5.

Three requests for the same JSON and what happened to eachThree cards. S1 forces the tool and marks it strict: 20 of 20 valid on Sonnet 5, rejected with a 400 on Sonnet 5.5 because the 5.5 models refuse forced tool use. A1 marks the tool strict and leaves toolChoice on auto: 19 of 20 on Sonnet 5, where one call submitted an empty array, and 10 of 10 on Sonnet 5.5. T1 sends outputConfig.textFormat with no tool: 20 of 20 on Sonnet 5 and 10 of 10 on Sonnet 5.5.S1Forced tool use + strictRequesttoolChoice: {tool: …}tools: [{strict: true}]Sonnet 520 / 20Sonnet 5.5400 RejectedWhy it failsSonnet 5.5 and Opus 5.5refuse forced tool useA1strict + autoRequesttoolChoice: {auto: {}}tools: [{strict: true}]Sonnet 519 / 20Sonnet 5.510 / 10Why it fails1 call submitted an emptyarray, which the schema allowsT1textFormat, no toolRequestoutputConfig.textFormat(no tools)Sonnet 520 / 20Sonnet 5.510 / 10Never failedNo tool, so no callfor the model to skip
S1 forces the tool, A1 lets the model decide whether to call it, and T1 uses no tool at all. Only T1 never failed.
RequestModelValidMedian time
Forced tool use, prompt rule keptSonnet 520 of 2025 s
Forced tool use, prompt rule removedSonnet 515 of 2025 s
S1: forced tool use + strictSonnet 520 of 2024 s
A1: strict + autoSonnet 519 of 2024 s
T1: textFormatSonnet 520 of 2024 s
S1: forced tool use + strictSonnet 5.5Rejected with a 400—
A1: strict + autoSonnet 5.510 of 1036 s
T1: textFormatSonnet 5.510 of 1036 s

The five failures without the rule all named every key text, the same error the earlier article chased. S1 fixes that with the smallest change, but Sonnet 5.5 and Opus 5.5 refuse forced tool use outright:

tool_choice: type "tool" and "any" are not supported for this model.

A1’s one failure called the tool with an empty translations array after 75 output tokens and two seconds. An empty array satisfies the schema, so strict let it through. I chose T1 in the end. Under auto, A1 can also skip the call entirely, and T1 has no tool to skip.

What structured outputs still leaves to the code

The schema guarantee has edges, and the adapter still checks each one:

  1. A response cut off at max_tokens, or a refusal, may not match the schema. Anthropic’s structured-outputs page says so for both. Converse has no refusal stop reason, so the adapter reads the reply only when stopReason is end_turn.
  2. The item count and the ids are not constrained. minItems (how many items an array must have) accepts only 0 or 1, so it cannot require 63, which is how A1’s empty array passed. The comparison against the input ids stays.
  3. Every object needs "additionalProperties": false. Bedrock returns a 400 for any other value.

Leaving forced tool use turned adaptive thinking on

Forced tool use had been keeping Sonnet 5 from thinking, and textFormat let it think again. Adaptive thinking is Claude’s default mode on Sonnet 5: the model decides per request whether to reason before answering and for how long, and the reasoning tokens count against maxTokens.

Translating a whole document with textFormat, most batches took as long as before, but three slowed down sharply. The slowest was attempted six times, because five of those ran past the 120-second read_timeout. botocore, the AWS SDK under boto3, retried each one without raising, so every timed-out attempt was billed and none was logged as a failure.

Time per batch with and without adaptive thinkingHorizontal bars for two batches, one row per method. The 143-segment batch took 47 seconds under forced tool use, 731 seconds over 6 attempts with thinking on, and 45 seconds with thinking off. The 80-segment batch took 25 seconds, 74 seconds, and 25 seconds. With thinking on, every attempt but the last ran past the 120-second read timeout and was re-sent.Attempt past the timeout, re-sentread_timeout 120 s143-segment batchForced tool usetextFormat, thinking ontextFormat, thinking off47 s731 s · 6 attempts45 s80-segment batchForced tool usetextFormat, thinking ontextFormat, thinking off25 s74 s25 s0120240360480600720seconds
The 143- and 80-segment batches under forced tool use, textFormat with thinking on, and textFormat with thinking off. With thinking on, the 143-segment batch ran past the 120-second timeout five times before one attempt finished.

Running the slow batches three more times each, with a 300-second timeout and no retries, separated the cause:

RequestTimeStop reasonOutput tokensRuns with reasoning
Forced tool use42–47 s6 of 6 tool_use6,767–7,3040 of 9
textFormat, thinking on45–138 s2 of 6 max_tokens6,854–16,3845 of 9
textFormat, thinking off41–46 s6 of 6 end_turn6,598–6,8710 of 9

Time, stop reason, and output tokens cover the two slowest batches (six runs). The last column covers all nine.

The JSON itself was the same length in every mode, about 13,000 characters for one batch, so the extra tokens were reasoning. The 143-segment batch was just as slow on a second full run, long after the grammar had been compiled and cached, so compilation was not the delay.

Anthropic’s thinking troubleshooting page documents the cause: “A forced tool_choice ({"type": "any"} or a named tool) also returns no thinking block: the response starts with the tool call.” Under forced tool use, the pipeline had been translating without thinking all along. So after switching to textFormat, thinking has to be turned off to avoid the timeouts and the extra tokens.

Turning thinking off, and the models that refuse it

Sending thinking: {"type": "disabled"} brought textFormat back to the forced-tool-use numbers. Converse passes Anthropic-specific fields through additionalModelRequestFields:

Converse request
client.converse(
# …
additionalModelRequestFields={"thinking": {"type": "disabled"}},
outputConfig={"textFormat": {"type": "json_schema", "structure": {"jsonSchema": {
"schema": json.dumps(schema), "name": "translations",
}}}},
)

On 23 batches, every response matched the schema and every id came back. The longest call took 45 seconds, and none was retried. Translation quality on the pipeline’s evaluation suite stayed within the run-to-run spread of the forced-tool-use version.

Not every model accepts that setting. Anthropic’s per-model table:

Modelthinking: {"type": "disabled"}
Sonnet 5Accepted
Opus 5, Haiku 5.5Accepted at effort high or below
Sonnet 5.5400; send {"type": "between_tools"} instead
Opus 5.5, Fable 5, Fable 5.1400; thinking is always on

disabled turns thinking off entirely. between_tools, accepted only by Sonnet 5.5, turns off the thinking before the reply, but the short updates the model writes between tool calls still come back as thinking blocks. By the documentation, a single call with no tools, like this pipeline’s, should behave the same under either.

The pipeline reads its model ID from SSM Parameter Store at runtime, so it can be switched to Sonnet 5.5 without a redeploy, and every batch after that would fail with a 400. I made the adapter refuse a model ID that has not been verified with thinking off, before it calls Bedrock:

bedrock adapter
THINKING_DISABLED_MODELS = frozenset({"claude-sonnet-5"})
def __init__(self, *, model_id: str) -> None: # other arguments elided
if model_id.rsplit("anthropic.", 1)[-1] not in THINKING_DISABLED_MODELS:
raise ConfigurationError(
f"model not verified to run with thinking off: {model_id}"
)

The error surfaces with the model ID in it, instead of as a stream of Bedrock 400s, and adding a model means measuring it first.

Summary

ProblemWhat handles it
Forced tool use guarantees a call, not its contentsoutputConfig.textFormat, with no tool: 20 of 20 without the prompt rule
Sonnet 5.5 and Opus 5.5 refuse forced tool useThe same textFormat request: 10 of 10 on Sonnet 5.5
Leaving forced tool use turns adaptive thinking back onthinking: {"type": "disabled"}, restoring the forced-tool-use latency and token count
max_tokens cuts, refusals, item count, and idsRead only end_turn, and keep the id check
A model switch to one that rejects disabledAn allow-list that fails with the model ID before calling Bedrock

On Bedrock, Claude Sonnet 5 now takes a JSON Schema for its reply. Moving off forced tool use also changes whether the model thinks, so measure latency and output tokens on your heaviest batch before switching, not only the shape of the JSON.

References

Share this article