Converse and Messages: the two wire shapes
Part 2 named the three doors into Claude on Bedrock and moved on; this part goes through them. An agent platform lives or dies on getting the model-plane wire format exactly right, because a misplaced field is a 400 at 3 a.m., and the two shapes you will actually use, the Bedrock Converse API and the Anthropic Messages API, differ in ways that are invisible until you are reading their error messages. This chapter puts both bodies on the table, field by field, for the same logical request, and walks a real tool-calling turn through each. By the end you can read a Bedrock error in its own vocabulary instead of guessing.
Two shapes, one turn
Both APIs send the same thing (a system prompt, a conversation, some
tools) and get back the same thing (a message, a stop reason, token
usage). They disagree only on field names and nesting, and the
disagreement is systematic: Converse is AWS's uniform shape across every
Bedrock vendor, so it wraps everything in AWS-style nested objects;
Messages is Claude-native, so it is flatter and matches Anthropic's
first-party SDK exactly. The Mantle client
this book uses speaks Messages; the AWS SDKs and boto3 speak Converse.
Lab 12.1 emits both bodies for one framework-neutral request, so the mapping is legible:
python3 converse_translate.py
=== Bedrock Converse body (bedrock-runtime.converse) ===
{
"modelId": "anthropic.claude-opus-4-8",
"system": [ {"text": "You are an SRE assistant. Cite the tool you used."},
{"cachePoint": {"type": "default"}} ],
"messages": [ {"role": "user", "content": [{"text": "Why did checkout p99 spike at 02:00?"}]} ],
"inferenceConfig": {"maxTokens": 1024},
"toolConfig": {
"tools": [ {"toolSpec": {"name": "query_metrics", "description": "...",
"inputSchema": {"json": {...}}}} ],
"toolChoice": {"auto": {}} },
"additionalModelRequestFields": {"top_k": 40}
}
=== Anthropic Messages body (AnthropicBedrockMantle) ===
{
"model": "anthropic.claude-opus-4-8",
"max_tokens": 1024,
"system": [ {"type": "text", "text": "...", "cache_control": {"type": "ephemeral"}} ],
"messages": [ {"role": "user", "content": [{"type": "text", "text": "..."}]} ],
"tools": [ {"name": "query_metrics", "description": "...", "input_schema": {...}} ],
"tool_choice": {"type": "auto"},
"top_k": 40
}
=== the field mapping, concept by concept ===
concept Converse Messages
model id modelId model
max output inferenceConfig.maxTokens max_tokens
cache point system[].cachePoint system[].cache_control
tool name toolConfig.tools[].toolSpec.name tools[].name
tool schema ...toolSpec.inputSchema.json tools[].input_schema
force a tool toolConfig.toolChoice tool_choice
vendor knob additionalModelRequestFields (top-level)
tokens used usage.inputTokens/outputTokens usage.input_tokens/output_tokens
cache hit usage.cacheReadInputTokenCount usage.cache_read_input_tokens
The table is the chapter's reference card. Three rows deserve elaboration because they are where the shapes diverge most and where the bugs live.
Content blocks and tool use
In both APIs a message's content is a list of typed blocks, not a
string, and this is the single most important thing to internalize,
because tool use, images, and documents all ride as blocks. In Converse
a text block is {"text": "..."}; in Messages it is
{"type": "text", "text": "..."}. The typing style differs (Converse
uses the key as the discriminator, Messages uses an explicit type
field), but the idea is identical.
Tool use is where the block model earns its keep. When the model wants a tool, the assistant message comes back with a tool-use block, and you send the result back as a tool-result block. In Converse:
// assistant asks (in output.message.content):
{"toolUse": {"toolUseId": "tu_01", "name": "query_metrics",
"input": {"service": "checkout", "window": "24h"}}}
// you reply (a new user message):
{"toolResult": {"toolUseId": "tu_01",
"content": [{"text": "{\"p99_ms\": {...}}"}],
"status": "success"}}
In Messages the same exchange is {"type": "tool_use", "id": ..., "name": ..., "input": ...} and {"type": "tool_result", "tool_use_id": ..., "content": ...}. Note the field-name traps that a
uniform-versus-native split creates: Converse says toolUseId, Messages
says id on the way out and tool_use_id on the way back; Converse
wraps the result in a status field, Messages uses an is_error
boolean. These are exactly the mismatches that make a hand-ported loop
fail on the second turn, which is why the Part 1 loop
was built against an interface and the client does the translation.
Images and documents are blocks too: Converse's {"image": {"format": "png", "source": {"bytes": ...}}} and {"document": {"format": "pdf", "name": ..., "source": {"bytes": ...}}} let an agent's tools return
visual evidence (a screenshot, a scanned invoice) directly into the
model's context, which the document-intake capstone
uses.
The passthrough: additionalModelRequestFields
Converse's uniform shape cannot model every vendor's every knob, so it
has an escape hatch: additionalModelRequestFields is a passthrough
dict handed straight to the underlying model. The classic example is
top_k, an Anthropic sampling parameter Converse's inferenceConfig
does not include. This is also a live example of the
model-operations discipline: on the newest Claude
models (Opus 4.8, Fable 5) the sampling parameters are removed and
sending top_k returns a 400, so in current practice the passthrough
carries other things, Anthropic beta feature flags, reasoning
configuration, rather than sampling knobs. The mechanism is the point:
anything Claude-specific that Converse has no field for goes through
additionalModelRequestFields, and its response twin
additionalModelResponseFieldPaths pulls Claude-specific fields out of
the response. The Messages API needs no such hatch, because it is the
Claude-native shape, which is one honest argument for the
Mantle client on a Claude-only platform.
Streaming: the event sequence
Both APIs stream, and an agent platform streams for the progress-UX
reasons Part 1 established. Converse's ConverseStream
returns a sequence of typed events, and knowing the sequence is how you
build a streaming UI that does not miss a tool call:
messageStart {role: "assistant"}
contentBlockStart {start: {toolUse: {toolUseId, name}}} # a tool is coming
contentBlockDelta {delta: {toolUse: {input: "{\"ser"}}} # input JSON, in pieces
contentBlockDelta {delta: {toolUse: {input: "vice\":"}}}
contentBlockStop
messageStop {stopReason: "tool_use"}
metadata {usage: {...}, metrics: {latencyMs: ...}}
The subtlety the sequence teaches: a tool call's input arrives as
fragmented JSON across multiple deltas, so you accumulate the pieces
and parse once at contentBlockStop, never mid-stream. Text blocks
stream the same way (delta: {text: "..."}). The Messages API's
streaming events (message_start, content_block_start,
content_block_delta with input_json_delta, content_block_stop,
message_delta carrying stop_reason, message_stop) are the same
shape under different names, the last row of the same two-column table.
A real turn, end to end
Put it together for the checkout investigation the book keeps
returning to. The loop sends the Converse body above; the model streams
back a toolUse for query_metrics; the harness accumulates the
fragmented input, parses {"service": "checkout", "window": "24h"},
runs the tool, and sends a follow-up Converse request whose messages now
include the assistant's tool-use turn and a new user message carrying
the toolResult; the model streams its verdict as text and stops with
stopReason: "end_turn"; the final metadata event reports
usage.inputTokens, usage.outputTokens, and, if a cachePoint was
set and hit, usage.cacheReadInputTokenCount. That token-usage block is
the same one the trace and ledger roll up, so
the wire format and the observability plane meet exactly here: the
numbers on the dashboard are the numbers on the last stream event.
Don't be confused: Converse vs InvokeModel. Bedrock has an even lower-level door,
InvokeModel, where you POST the vendor's raw native body (for Claude, the full Messages JSON) and get the raw body back, with no uniform shape at all. Converse is the uniform layer over InvokeModel; the Mantle client's Messages path is effectively InvokeModel with Anthropic's SDK ergonomics on top. Reach for Converse when you want one request shape across vendors, the Messages path when you are Claude-only and want first-party features the day they ship, and raw InvokeModel almost never, only when a brand-new vendor feature has not reached the uniform APIs yet.
Full source
"""Lab 12.1: one logical request, two wire shapes.
The same agent turn can be sent to Claude two ways on AWS: the Bedrock
*Converse* API (AWS's uniform shape across vendors) and the Anthropic
*Messages* API (what the AnthropicBedrockMantle client and the
first-party SDK speak). This lab takes one framework-neutral request and
emits BOTH bodies, so the field-by-field mapping is visible: what
Converse calls `inferenceConfig.maxTokens`, Messages calls `max_tokens`;
what Converse nests as `toolConfig.tools[].toolSpec`, Messages puts flat
in `tools[]`; a cache breakpoint is a `cachePoint` block in Converse and
`cache_control` on a block in Messages.
Knowing both shapes is the difference between reading an AWS error
message and guessing at one. Standard library only. Deterministic.
"""
from __future__ import annotations
import json
# --- one framework-neutral request -----------------------------------------
REQUEST = {
"model": "claude-opus-4-8",
"max_tokens": 1024,
"system": "You are an SRE assistant. Cite the tool you used.",
"cache_system": True, # put a cache breakpoint after system
"messages": [
{"role": "user", "text": "Why did checkout p99 spike at 02:00?"},
],
"tools": [
{
"name": "query_metrics",
"description": "Read p99 latency by hour for a service.",
"schema": {
"type": "object",
"properties": {"service": {"type": "string"},
"window": {"type": "string"}},
"required": ["service", "window"],
},
},
],
"top_k": 40, # an Anthropic-specific knob
}
def to_converse(r: dict) -> dict:
"""Bedrock Converse (bedrock-runtime). Anthropic-only knobs ride in
additionalModelRequestFields; a cache breakpoint is a cachePoint block."""
system = [{"text": r["system"]}]
if r.get("cache_system"):
system.append({"cachePoint": {"type": "default"}})
return {
"modelId": "anthropic." + r["model"], # Bedrock id prefix
"system": system,
"messages": [{"role": m["role"], "content": [{"text": m["text"]}]}
for m in r["messages"]],
"inferenceConfig": {"maxTokens": r["max_tokens"]},
"toolConfig": {
"tools": [{"toolSpec": {
"name": t["name"],
"description": t["description"],
"inputSchema": {"json": t["schema"]},
}} for t in r["tools"]],
"toolChoice": {"auto": {}},
},
"additionalModelRequestFields": {"top_k": r["top_k"]},
}
def to_messages(r: dict) -> dict:
"""Anthropic Messages (AnthropicBedrockMantle / first-party). Cache
breakpoint is cache_control on the last block of the stable prefix."""
return {
"model": "anthropic." + r["model"], # Bedrock id prefix
"max_tokens": r["max_tokens"],
"system": [{"type": "text", "text": r["system"],
**({"cache_control": {"type": "ephemeral"}}
if r.get("cache_system") else {})}],
"messages": [{"role": m["role"],
"content": [{"type": "text", "text": m["text"]}]}
for m in r["messages"]],
"tools": [{"name": t["name"], "description": t["description"],
"input_schema": t["schema"]} for t in r["tools"]],
"tool_choice": {"type": "auto"},
"top_k": r["top_k"],
}
# --- the mapping, spelled out, so the two are legible side by side ----------
MAPPING = [
("model id", "modelId", "model"),
("max output", "inferenceConfig.maxTokens", "max_tokens"),
("system prompt", "system[].text", "system[].text"),
("cache point", "system[].cachePoint", "system[].cache_control"),
("a message", "messages[].content[].text", "messages[].content[].text"),
("tool name", "toolConfig.tools[].toolSpec.name", "tools[].name"),
("tool schema", "...toolSpec.inputSchema.json", "tools[].input_schema"),
("force a tool", "toolConfig.toolChoice", "tool_choice"),
("vendor knob", "additionalModelRequestFields", "(top-level)"),
("tokens used", "usage.inputTokens/outputTokens", "usage.input_tokens/output_tokens"),
("cache hit", "usage.cacheReadInputTokenCount", "usage.cache_read_input_tokens"),
]
if __name__ == "__main__":
print("=== Bedrock Converse body (bedrock-runtime.converse) ===")
print(json.dumps(to_converse(REQUEST), indent=2))
print("\n=== Anthropic Messages body (AnthropicBedrockMantle) ===")
print(json.dumps(to_messages(REQUEST), indent=2))
print("\n=== the field mapping, concept by concept ===")
print(f" {'concept':<15}{'Converse':<34}{'Messages'}")
for concept, conv, msg in MAPPING:
print(f" {concept:<15}{conv:<34}{msg}")
print("\nsame turn, two wire shapes. Converse is uniform across Bedrock "
"vendors; Messages is Claude-native and what the book's "
"AnthropicBedrockMantle client speaks. Pick one per call and read "
"its errors in its own vocabulary.")
👉 Next: Bedrock economics, wired up, where inference profiles, provisioned throughput, and quotas stop being concepts and become ARNs, model units, and a per-tenant cost-attribution setup you can actually deploy.