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.