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;anyforces at least one tool call and no text;toolforces one named tool. The API reference liststoolas supported only by Anthropic Claude 3 and Amazon Nova models, so check your model before you depend on it.- Add
"strict": trueto 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
toolChoiceper 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 thebedrock:InvokeModelIAM 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.

The three toolChoice modes
ToolChoice is a union: you set exactly one of its members on each request.
| Mode | What the model must do | Good for | What goes wrong if misused |
|---|---|---|---|
{"auto": {}} (default) | Decide between a tool call and a text answer | Interactive chat, planning, the final summary for a human | In CI, the agent narrates instead of acting |
{"any": {}} | Request at least one tool; no text is generated | Steps where some action is mandatory but the model should pick which | It may pick a weak tool; you still need validation |
{"tool": {"name": "run_tests"}} | Request that specific tool | Deterministic 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.
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.
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.
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 executeValidation 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:
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
- A CI job receives “fix the failing date parser test” and starts
run_agentwith the four-step plan. - Step 1 forces
search_repo_v1. The model searches for the parser; the executor returns the first 50 matches. - Step 2 forces
apply_patch_v1. The first diff touchespnpm-lock.yaml, so the executor returnsFORBIDDEN_PATHwithstatus: error. The runtime escalates instead of retrying, and the job comments on the PR. After a policy fix, the next run’s diff only touchespackages/dates/and applies. - 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 withreplayed: true. No double patch. - Step 3 forces
run_tests_v1; the tests pass. Step 4 usesautoand the model writes a short summary for the reviewer.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
ValidationException when forcing a tool | The model doesn’t support the tool option, or the name doesn’t match a tool in the request | Check model support; use any with a single tool in the list as a fallback; check spelling |
400 error after adding "strict": true | The 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 slow | Bedrock compiles the schema grammar on first use | Expected; compiled grammars are cached for 24 hours. Warm up after deploys |
| Agent writes prose in CI instead of acting | Every step uses auto | Force tools for mechanical steps; keep auto for the summary |
| Same patch applied twice | No idempotency key, or the key is generated inside the tool on each call | Require the key in the schema; store and replay results |
| Model keeps retrying a failing call | Errors returned as free text | Return code and retryable; cap replans per step |
stopReason is malformed_tool_use | The model produced an invalid tool request | Treat it as a failed step; consider strict mode for that tool; retry once |
Production checklist
- ☐ Per-step
toolChoicestored in workflow state;autoonly 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
codeandretryable; 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:InvokeModelscoped 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
- Use a tool to complete an Amazon Bedrock model response
- ToolChoice (auto, any, tool and model support)
- Get validated JSON results from models (strict tool use and supported schema features)
- ToolResultBlock (the
statusfield) - Converse API reference (stop reasons, Prompt Management restrictions, IAM)
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.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- Css Animation Performance: Performance-Optimized Motion for Modern UIs
Newly added
- AWS Systems Manager Parameter Store: Hierarchical Runtime Config for Coding Agents
- Amazon Cognito Identity Pools: Federate Coding-Agent Tool Callers Without Long-Lived Keys
- Amazon Athena for Coding Agents: Query Tool Audit Logs and Eval Traces on S3 Without a Warehouse
- AWS Batch for Coding Agents: Parallel Overnight PR Reviews and Long-Running Jobs Without Lambda Timeouts
- Amazon Bedrock Prompt Management: Version and A/B Test Coding-Agent System Prompts
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.
