Bedrock Converse Tool Use for Coding Agents: toolChoice Modes and Strict JSON Tool Contracts

Bedrock Converse API: Tool Choice Modes for Production Coding Agents
19 views

A coding agent in CI is supposed to search the repository, apply a patch and run the tests. Instead it writes three paragraphs explaining what it would do, calls apply_patch with a field name that doesn’t exist, and then retries the same patch after a timeout. Nothing is wrong with the model. The problem is the contract between the model and your tools: nobody decided which tool must run at each step, the tool schemas are loose, and errors come back as prose.

The Amazon Bedrock Converse API gives you the controls to fix this. toolChoice decides whether the model may answer in text, must call some tool, or must call one specific tool. Strict tool definitions make the model’s tool input follow your JSON schema. And the toolResult block lets you send back a typed error instead of a wall of text. This guide shows how to combine them into an agent loop that makes progress, fails loudly and survives retries. It merges our earlier posts on tool-choice modes and strict tool schemas into one guide.

⚡ TL;DR
  • auto (the default) lets the model choose between text and tools; any forces at least one tool call and no text; tool forces one named tool. The API reference lists tool as supported only by Anthropic Claude 3 and Amazon Nova models, so check your model before you depend on it.
  • Add "strict": true to a tool spec to have Bedrock enforce the input schema. Strict schemas reject some JSON Schema keywords (minLength, maxLength, minimum, maximum), so keep those checks in your own validator.
  • Let your code, not the model, pick toolChoice per step. Validate arguments before any side effect, require an idempotency key on mutating tools, and return errors as structured JSON with "status": "error".
  • Version tools like APIs (apply_patch_v2), and remember that Converse authorization uses the bedrock:InvokeModel IAM action.

Who this is for: engineers building coding agents or PR bots on Amazon Bedrock. You should be comfortable with Python and boto3 and have access to a Bedrock model that supports tool use in the Converse API.

How tool use works in Converse

A “tool” is just a description of a function you are willing to run: a name, a description and a JSON schema for its input. You send the tool list with each Converse request in toolConfig. The model never runs anything itself. When it wants a tool, the response comes back with stopReason set to tool_use and a toolUse content block containing a toolUseId, the tool name and an input object. Your code runs the tool and sends the outcome back in the next user message as a toolResult block with the same toolUseId. The model then continues, either with another tool request or with a final text answer.

That loop is called client-side tool use, and it’s the mode the Converse API uses. It has an important consequence for safety: every tool call passes through your code, so your code is the place to validate, authorize, deduplicate and log. The model proposes; your runtime decides.

Figure 1. The agent runtime owns the loop: it picks toolChoice per step, validates the model's tool input, executes once and returns a typed toolResult. Diagram by CheatCoders.
Figure 1. The agent runtime owns the loop: it picks toolChoice per step, validates the model’s tool input, executes once and returns a typed toolResult. Diagram by CheatCoders.

The three toolChoice modes

ToolChoice is a union: you set exactly one of its members on each request.

ModeWhat the model must doGood forWhat goes wrong if misused
{"auto": {}} (default)Decide between a tool call and a text answerInteractive chat, planning, the final summary for a humanIn CI, the agent narrates instead of acting
{"any": {}}Request at least one tool; no text is generatedSteps where some action is mandatory but the model should pick whichIt may pick a weak tool; you still need validation
{"tool": {"name": "run_tests"}}Request that specific toolDeterministic steps such as “now run the tests”Only some models support it; a wrong name is a request error (which is good: it fails loudly)

Two practical notes. First, support differs by model. The API reference states that the tool option is only supported by Anthropic Claude 3 and Amazon Nova models, so if you switch models, test the forced-tool steps first. Second, forcing a tool doesn’t make the tool call correct. It only removes the option to answer in prose. Correctness comes from the schema and your validation.

Step 1: Define tools as strict, versioned contracts

Each tool spec has a name (1 to 64 characters matching [a-zA-Z0-9_-]+), an optional description and an inputSchema. Setting "strict": true turns on Bedrock’s structured output enforcement for that tool, so the model’s tool calls follow the input schema you defined. Bedrock validates the schema itself against a supported subset of JSON Schema Draft 2020-12 and returns a 400 error if you use something unsupported. The first request with a new schema can take longer because Bedrock compiles a grammar for it; compiled grammars are cached for 24 hours.

The unsupported list is where older tool schemas break. Strict mode doesn’t support numerical constraints (minimum, maximum, multipleOf), string length constraints (minLength, maxLength), recursive schemas, external $refs, or additionalProperties set to anything other than false. So a schema that says “idempotency key must be at least 8 characters” has to move that rule into your own validator. Keep the shape in the strict schema and the business rules in code.

python
APPLY_PATCH_V1 = {
    "toolSpec": {
        "name": "apply_patch_v1",
        "description": "Apply a unified diff to files under packages/ or apps/. "
                       "Reuse the same idempotencyKey when retrying the same change.",
        "strict": True,
        "inputSchema": {"json": {
            "type": "object",
            "properties": {
                "idempotencyKey": {"type": "string", "description": "One key per intended change"},
                "diff": {"type": "string", "description": "Unified diff"},
                "dryRun": {"type": "boolean"},
            },
            "required": ["idempotencyKey", "diff", "dryRun"],
            "additionalProperties": False,
        }},
    }
}

SEARCH_REPO_V1 = {
    "toolSpec": {
        "name": "search_repo_v1",
        "description": "Search the checked-out repository with a regular expression.",
        "strict": True,
        "inputSchema": {"json": {
            "type": "object",
            "properties": {
                "pattern": {"type": "string"},
                "path": {"type": "string", "description": "Optional sub-directory"},
            },
            "required": ["pattern"],
            "additionalProperties": False,
        }},
    }
}

RUN_TESTS_V1 = {
    "toolSpec": {
        "name": "run_tests_v1",
        "description": "Run the tests whose names match the filter.",
        "strict": True,
        "inputSchema": {"json": {
            "type": "object",
            "properties": {"filter": {"type": "string"}},
            "required": ["filter"],
            "additionalProperties": False,
        }},
    }
}

TOOLS = [SEARCH_REPO_V1, APPLY_PATCH_V1, RUN_TESTS_V1]

Notice the version suffix in each name. Treat a tool like a public API: never silently change what a field means. If apply_patch needs a new required field, publish apply_patch_v2, keep v1 working while in-flight sessions drain, and update the description so the model prefers the new one. Renaming an enum value in place strands every session that learned the old one.

Step 2: Let a state machine choose toolChoice

The most reliable agent loops don’t ask the model “what should we do now?” for mechanical steps. The runtime already knows that after a search comes a patch, and after a patch come the tests. Encode that order in code and use toolChoice to enforce it. Leave auto for the final summary a human reads.

python
import json
import os

import boto3

brt = boto3.client("bedrock-runtime")
MODEL_ID = os.environ["AGENT_MODEL_ID"]  # a model that supports forced tool choice

PLAN = [
    {"tool": {"name": "search_repo_v1"}},
    {"tool": {"name": "apply_patch_v1"}},
    {"tool": {"name": "run_tests_v1"}},
    {"auto": {}},  # final human-facing summary
]


def converse(messages: list, choice: dict) -> dict:
    return brt.converse(
        modelId=MODEL_ID,
        messages=messages,
        toolConfig={"tools": TOOLS, "toolChoice": choice},
        inferenceConfig={"maxTokens": 4096, "temperature": 0},
    )


def run_agent(task: str, execute, max_turns: int = 12) -> str:
    messages = [{"role": "user", "content": [{"text": task}]}]
    turns = 0
    for choice in PLAN:
        turns += 1
        if turns > max_turns:
            raise RuntimeError("turn budget exhausted")
        resp = converse(messages, choice)
        msg = resp["output"]["message"]
        messages.append(msg)
        if resp["stopReason"] != "tool_use":
            # Only the final auto step should end with text.
            return "".join(b.get("text", "") for b in msg["content"])
        results = []
        for block in msg["content"]:
            if "toolUse" not in block:
                continue
            use = block["toolUse"]
            outcome = execute(use["name"], use["input"])  # validates, dedupes, runs
            results.append({"toolResult": {
                "toolUseId": use["toolUseId"],
                "content": [{"json": outcome}],
                "status": "success" if outcome.get("ok") else "error",
            }})
        messages.append({"role": "user", "content": results})
    raise RuntimeError("plan ended without a final answer")

A few details matter here. The loop has a turn budget, so a confused model can’t spin forever. Each toolResult echoes the toolUseId from the request. And the status field tells the model the tool failed. The API reference notes that status is only supported by Amazon Nova and Anthropic Claude 3 and 4 models; for other models, keep the error details in the JSON content so the model can still read them.

Store the intended toolChoice with the step in your workflow state (for example in a Step Functions state). Then a retried step uses the same policy instead of quietly falling back to auto.

Step 3: Validate at the boundary, then execute once

Strict schemas guarantee shape, not safety. The executor is still the security boundary: it checks business rules the schema can’t express, rejects paths outside the allowlist, and makes mutating tools idempotent so a retry doesn’t apply the same patch twice. Retries come from many places in a real system: the SDK retrying a throttled call, a Lambda timeout, a Step Functions Retry, or the model calling the same tool again with the same intent.

python
import re

ALLOWED_PATH = re.compile(r"^\+\+\+ b/(packages|apps)/[A-Za-z0-9._/-]+$", re.M)
FORBIDDEN = re.compile(r"^\+\+\+ b/(\.env|.*\.pem$|pnpm-lock\.yaml$)", re.M)


def err(code: str, message: str, retryable: bool) -> dict:
    return {"ok": False, "error": {"code": code, "message": message[:500], "retryable": retryable}}


def make_executor(store, apply_patch, search, run_tests):
    """store: get(key) / put(key, value) backed by DynamoDB or similar."""

    def execute(name: str, args: dict) -> dict:
        if name == "apply_patch_v1":
            key = args.get("idempotencyKey", "")
            if not re.fullmatch(r"[\w:.-]{8,128}", key):
                return err("VALIDATION", "idempotencyKey must be 8-128 safe characters", False)
            if FORBIDDEN.search(args["diff"]):
                return err("FORBIDDEN_PATH", "diff touches a protected file", False)
            if not ALLOWED_PATH.search(args["diff"]):
                return err("FORBIDDEN_PATH", "diff must only touch packages/ or apps/", False)
            prior = store.get(f"apply_patch_v1#{key}")
            if prior is not None:
                return {**prior, "replayed": True}  # same key -> same result
            try:
                applied = apply_patch(args["diff"], dry_run=args["dryRun"])
            except TimeoutError:
                return err("TIMEOUT", "patch apply timed out", True)
            except ValueError as e:  # e.g. hunk does not apply
                return err("CONFLICT", str(e), False)
            result = {"ok": True, "applied": applied}
            store.put(f"apply_patch_v1#{key}", result)
            return result
        if name == "search_repo_v1":
            return {"ok": True, "matches": search(args["pattern"], args.get("path", "."))[:50]}
        if name == "run_tests_v1":
            return {"ok": True, "report": run_tests(args["filter"])}
        return err("UNKNOWN_TOOL", f"no such tool: {name}", False)

    return execute

Validation failures are returned before anything touches the file system. The idempotency record is written only after a successful apply, and a replay returns the stored result. For the durable version of that record, including atomic quota accounting, see our guide to DynamoDB transactions for tool ledgers.

Step 4: Typed errors beat conversational recovery

When a tool fails with {"message": "failed"}, the model has nothing to work with and usually retries blindly. A typed error with a code and a retryable flag lets both the model and your runtime decide what to do next. Put the retry policy in code, not in the system prompt:

python
def next_action(error: dict) -> str:
    """Decide what the runtime does after a failed tool call."""
    code = error["code"]
    if code in ("VALIDATION", "CONFLICT"):
        return "replan"      # let the model correct its input once
    if code in ("FORBIDDEN_PATH", "UNKNOWN_TOOL"):
        return "escalate"    # a human or a policy change is needed
    if error.get("retryable"):
        return "retry"       # same input, same idempotency key, with backoff
    return "escalate"

Cap “replan” at one or two attempts per step. If the model produces the same invalid input twice, escalate rather than burning more turns.

Prompt Management and tool configuration

If you store your agent’s prompt in Amazon Bedrock Prompt Management, you can call Converse with the prompt version’s ARN as the modelId and pass variables. In that case the Converse reference says you can’t include toolConfig, system, inferenceConfig or additionalModelRequestFields in the request; they must be defined in the prompt itself. So decide where tool configuration lives. A common split is to keep the system prompt in Prompt Management for review and versioning, and assemble the tool list and per-step toolChoice in code. Either way, pin a specific prompt version in production rather than the draft, so a prompt edit and a tool change roll out (and roll back) together.

Permissions and cost

There’s no separate IAM action for Converse: it is authorized through bedrock:InvokeModel, and ConverseStream through bedrock:InvokeModelWithResponseStream. Scope those to the model or inference profile ARNs you use. The tools themselves should run with their own narrowly scoped roles, not the agent’s role.

Tool definitions are sent with every request, so they count as input tokens on every turn. Keep descriptions short and precise, and if your model supports it, put a prompt cache checkpoint after the tools so repeated turns read them from the cache. Forced tool steps also avoid paying for narration you would throw away; measure the effect in your own usage metrics rather than assuming a percentage.

Worked example: a PR-fix agent in CI

  1. A CI job receives “fix the failing date parser test” and starts run_agent with the four-step plan.
  2. Step 1 forces search_repo_v1. The model searches for the parser; the executor returns the first 50 matches.
  3. Step 2 forces apply_patch_v1. The first diff touches pnpm-lock.yaml, so the executor returns FORBIDDEN_PATH with status: error. The runtime escalates instead of retrying, and the job comments on the PR. After a policy fix, the next run’s diff only touches packages/dates/ and applies.
  4. The Lambda running the step times out after applying. Step Functions retries the step; the model sends the same idempotencyKey, the executor finds the stored result and returns it with replayed: true. No double patch.
  5. Step 3 forces run_tests_v1; the tests pass. Step 4 uses auto and the model writes a short summary for the reviewer.

Troubleshooting

SymptomLikely causeFix
ValidationException when forcing a toolThe model doesn’t support the tool option, or the name doesn’t match a tool in the requestCheck model support; use any with a single tool in the list as a fallback; check spelling
400 error after adding "strict": trueThe schema uses an unsupported keyword (minLength, maximum, additionalProperties: true)Remove the keyword from the schema and enforce it in the executor
First request with a new schema is slowBedrock compiles the schema grammar on first useExpected; compiled grammars are cached for 24 hours. Warm up after deploys
Agent writes prose in CI instead of actingEvery step uses autoForce tools for mechanical steps; keep auto for the summary
Same patch applied twiceNo idempotency key, or the key is generated inside the tool on each callRequire the key in the schema; store and replay results
Model keeps retrying a failing callErrors returned as free textReturn code and retryable; cap replans per step
stopReason is malformed_tool_useThe model produced an invalid tool requestTreat it as a failed step; consider strict mode for that tool; retry once

Production checklist

  • ☐ Per-step toolChoice stored in workflow state; auto only for human-facing output
  • ☐ Tool names versioned; old versions kept until traffic drains
  • ☐ Strict schemas with additionalProperties: false; length and range rules enforced in the executor
  • ☐ Every mutating tool requires an idempotency key; results stored and replayed
  • ☐ Typed errors with code and retryable; replans capped per step
  • ☐ Path allowlists and protected-file checks before any patch is applied
  • ☐ Turn and time budgets on every agent run
  • ☐ Metrics: tool calls by name, forced vs auto, error codes, replays
  • ☐ IAM: bedrock:InvokeModel scoped to the models you use; tools on separate roles

FAQ

Should I use any or tool for a mandatory step?

Use tool when exactly one tool is correct and your model supports it. Use any when the step needs an action but several tools are acceptable, or when your model doesn’t support forcing a named tool.

Does strict mode replace my own validation?

No. It enforces the schema shape, but it can’t express length limits, ranges or business rules such as path allowlists, and it can’t tell whether an action is safe. Keep validating in the executor.

Can the model call more than one tool in a single turn?

A response can contain several toolUse blocks. Return one toolResult for each, matched by toolUseId, in the next user message.

How do I test tool contracts?

Like APIs: fixtures for valid input, each validation failure, an idempotent replay and a conflict, run against the executor in CI. Don’t rely only on recorded model transcripts.

Official documentation

Related on CheatCoders: DynamoDB transactions for tool ledgers · Bedrock Prompt Management · Bedrock prompt caching

Last updated on October 10, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.