Calling Claude on Bedrock With the Converse API
10 min read · updated August 11, 2026
Before Converse, every model family on Bedrock had its own request body: Anthropic wanted anthropic_version and a messages array, Amazon Titan wanted inputText and textGenerationConfig, and you wrote a shim per provider. Converse is one shape for all of them, and the shape is worth learning properly because the awkward parts — tool results, stop reasons, token accounting — are where the per-provider differences went.
The request shape
Converse is a single POST to /model/{modelId}/converse on the bedrock-runtime endpoint. The model identifier is a URI parameter, not a body field, which is why switching models is a one-string change and why an inference profile ID drops into exactly the same slot. Everything else is JSON.
A message has a role — user or assistant — and a content array. The array matters: a single message can carry several blocks, and a block is one of text, image, document, video, toolUse, toolResult or guardContent. Text is not a string on the message; it is a block inside the array. That is the single most common shape mistake when porting code that used to talk to a provider’s own API.
import boto3
client = boto3.client("bedrock-runtime", region_name="us-east-1")
response = client.converse(
modelId="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
system=[{"text": "You are terse. Answer in at most two sentences."}],
messages=[
{
"role": "user",
"content": [{"text": "Why is output priced above input on most APIs?"}],
}
],
inferenceConfig={"maxTokens": 512, "temperature": 0.2},
)
print(response["output"]["message"]["content"][0]["text"])
print(response["stopReason"])
print(response["usage"]) # inputTokens, outputTokens, totalTokens
print(response["metrics"]["latencyMs"])The response mirrors the request. output.message is a message in exactly the format you would append to messages for the next turn, which is the point: you never transform an assistant reply before sending it back. usage carries inputTokens, outputTokens and totalTokens, plus cacheReadInputTokens and cacheWriteInputTokens when prompt caching is in play. metrics.latencyMs is the service’s own view of the call, which is useful precisely because it excludes your network time.
System prompts and inferenceConfig
The system prompt is its own top-level field, an array of blocks rather than a message with role: "system". Amazon documents four parameters in inferenceConfig and only four: maxTokens, stopSequences, temperature and topP. Anything else a model supports — top_k, Anthropic’s extended thinking budget, a provider-specific penalty — goes in additionalModelRequestFields, and comes back, if you ask for it, through additionalModelResponseFieldPaths as JSON Pointer strings.
response = client.converse(
modelId="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
inferenceConfig={"stopSequences": ["SUCCESS", "FAILURE"]},
additionalModelRequestFields={"top_k": 200},
additionalModelResponseFieldPaths=["/stop_sequence"],
)Amazon documents that an empty or malformed JSON Pointer in additionalModelResponseFieldPaths is rejected with a 400, while a well-formed pointer to a field the model did not return is simply ignored. So a typo in a path fails loudly and a stale path fails silently, which is the opposite of what most people assume.
Two newer fields are worth knowing about because they change billing and latency rather than output: performanceConfig.latency selects an optimised serving mode where a model supports it, and requestMetadata takes up to 16 key-value pairs that you can later filter invocation logs on. Both are per-request, both are optional, and requestMetadata is the only place in the Converse request where you can stamp a call with your own tenant or team identifier.
Tool use lives in the message list
Tools are declared once in toolConfig and then handled entirely through content blocks. A tool declaration is a toolSpec with a name, a description and an inputSchema.json holding an ordinary JSON Schema object. toolChoice takes auto, any, or a specific tool by name.
tool_config = {
"tools": [
{
"toolSpec": {
"name": "get_order_status",
"description": "Look up the current status of a customer order.",
"inputSchema": {
"json": {
"type": "object",
"properties": {"orderId": {"type": "string"}},
"required": ["orderId"],
}
},
}
}
],
"toolChoice": {"auto": {}},
}When the model wants a tool, it returns stopReason of tool_use and its message contains a toolUse block with a toolUseId, a name and an input object. You append that assistant message unchanged, run the tool, and append a new user message whose content is a toolResult block carrying the same toolUseId. The identifier is what pairs request to result, so a model that emitted two toolUse blocks in one turn needs two toolResult blocks back before it will continue.
messages.append(response["output"]["message"])
for block in response["output"]["message"]["content"]:
if "toolUse" not in block:
continue
use = block["toolUse"]
result = lookup(use["input"]["orderId"])
messages.append({
"role": "user",
"content": [{
"toolResult": {
"toolUseId": use["toolUseId"],
"content": [{"json": result}],
"status": "success",
}
}],
})Set status to error and put the message in the content block when the tool fails. That is better than raising, because the model can then apologise or retry with different arguments instead of your loop dying halfway through a conversation the user is watching.
Every stopReason value
Most code checks for end_turn and treats everything else as an error. Amazon’s API reference for Converse documents nine values, and they need different handling:
end_turn— the model finished. The normal case.tool_use— it wants a tool. Run it and continue the loop.max_tokens— it hit yourmaxTokens. The answer is truncated mid-sentence and is not safe to parse as JSON.stop_sequence— one of yourstopSequencesmatched.guardrail_intervened— a guardrail you attached acted on the request or the response.content_filtered— the provider’s own filtering, not yours.malformed_model_outputandmalformed_tool_use— the model produced something the service could not parse into the response shape. Retrying is reasonable; parsing is not.model_context_window_exceeded— the conversation no longer fits. Trimming history is the only fix; a retry reproduces it exactly.
The distinction that saves the most time is between the two filtering values. guardrail_intervened is your configuration and you can change it in the guardrail policy. content_filtered is not, and no amount of guardrail editing will move it.
Run it
- Grant the caller
bedrock:InvokeModel. Converse is authorised by that action, not by a separatebedrock:Converse— denyingbedrock:InvokeModelandbedrock:InvokeModelWithResponseStreamdenies Converse too. - Confirm the model is usable in this account with
aws bedrock get-foundation-model-availability --model-id .... A 403 here is an access problem, not a code problem — see the AccessDeniedException walkthrough. - Send the first snippet above. Check that
stopReasonisend_turnand thatusage.totalTokensis what you expected. - Add
toolConfig, then loop: call, append the assistant message, appendtoolResultblocks, call again, untilstopReasonis no longertool_use. Cap the loop — a model that keeps asking for the same tool will otherwise run until your budget notices. - Swap
converseforconverse_stream. The response becomes an event stream ofmessageStart,contentBlockDelta,contentBlockStop,messageStopandmetadata;stopReasonarrives onmessageStopandusageonmetadata, at the end. Anything you do with token counts has to move after the stream completes.
Converse API reference as published at the time of writing, August 2026. The operation has gained fields since launch — performanceConfig, serviceTier, outputConfig — and the stopReason list has grown with them. Check the AWS API reference for Converse before you write an exhaustive switch on it.