Tool use & structured output
Tools turn a chat model into something that can act and return machine-readable data. On Bedrock that is toolConfig — and it is the exact foundation the managed Agents in W5 are built on.
- AWS credentials (
aws configure) + Bedrock model access enabled in your region +pip install boto3 - AWS credentials (
aws configure) +pip install boto3
Learning objectives
- Give Claude tools via the Converse
toolConfigand run the tool loop. - Force valid JSON out of the model with a tool schema (structured output).
- Handle the
tool_usestop reason and returntoolResultblocks. - Understand why tool schemas are the backbone of every Bedrock Agent (W5).
code/aws3-bedrock-tooluse/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.Tools on Bedrock essential
Tool use on Bedrock is the same idea as the Anthropic SDK: you describe tools with a JSON schema, the model decides when to call one, you execute it and hand the result back. Converse carries this in toolConfig.
tools.pyimport boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL = "anthropic.claude-3-5-sonnet-20241022-v2:0"
TOOLS = {"tools": [{
"toolSpec": {
"name": "get_pod_status",
"description": "Get the status of a Kubernetes pod. Use when asked about a pod's health.",
"inputSchema": {"json": {
"type": "object",
"properties": {"name": {"type": "string", "description": "pod name"}},
"required": ["name"],
}},
}
}]}
def get_pod_status(name): # the real implementation
return {"name": name, "phase": "CrashLoopBackOff", "restarts": 7}
Before a model can "use a tool", you have to describe that tool in a way the model understands — a name, a plain-English description, and a JSON schema for its inputs. This block does exactly that, then writes the actual Python function the tool stands for. Nothing calls the model yet; this is just the setup.
boto3.client("bedrock-runtime", ...)opens a connection to Amazon Bedrock (AWS's hosted-model service) in a region.boto3is AWS's Python SDK;brtis the handle you'll call the model through.MODELis the exact Claude model ID on Bedrock.TOOLSis a plain Python dictionary describing one tool. The important keys:name(what the model calls it),description(the model reads this to decide when to use it — write it well), andinputSchema.- The
inputSchemais JSON Schema: it says the input is an object with anamestring, and thatnameisrequired. This is the contract — the model must fill in anameto use the tool. def get_pod_status(name):is the real code that runs when the tool is chosen. Here it just returns a fake status dict so you can learn the flow without a live cluster.
What the output means: Nothing prints — this file only defines the tool and its function. The next lab imports TOOLS and get_pod_status from here and actually runs them.
Try this: Change the description to be vague (e.g. just "pod tool") and later watch the model become less reliable about calling it. The description is how the model decides — treat it like a prompt.
The tool loop essential
When the model wants a tool, the response stopReason is tool_use and the content holds a toolUse block. You run the tool, append a toolResult, and call again — exactly the agentic loop from Ch 4.
tool_loop.pyimport boto3
from tools import TOOLS, get_pod_status
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL = "anthropic.claude-3-5-sonnet-20241022-v2:0"
messages = [{"role": "user", "content": [{"text": "Is the checkout-api pod healthy?"}]}]
resp = brt.converse(modelId=MODEL, messages=messages, toolConfig=TOOLS)
if resp["stopReason"] == "tool_use":
messages.append(resp["output"]["message"]) # remember the tool request
for block in resp["output"]["message"]["content"]:
if "toolUse" in block:
tu = block["toolUse"]
result = get_pod_status(**tu["input"]) # run it
messages.append({"role": "user", "content": [{
"toolResult": {
"toolUseId": tu["toolUseId"],
"content": [{"json": result}],
}
}]})
final = brt.converse(modelId=MODEL, messages=messages, toolConfig=TOOLS)
print(final["output"]["message"]["content"][0]["text"])
The checkout-api pod is not healthy: it is in CrashLoopBackOff with 7 restarts, which means the container keeps failing to start.
This is the heart of tool use: the tool loop. You send a question plus the tool list; the model may answer directly, or it may say "I want to call a tool." If it does, you run the tool, hand the result back, and call the model a second time so it can write the final answer.
messagesis the running conversation — a list where each turn has arole(useror the model'sassistant) andcontent. We start with the user's question about the checkout-api pod.brt.converse(...)sends the messages andtoolConfig=TOOLSto Bedrock. Passing the tools is what lets the model choose to call one.if resp["stopReason"] == "tool_use":— the model tells you why it stopped.tool_usemeans "I'm not done; please run a tool for me." We first append the model's own message back intomessagesso the conversation stays complete.- We loop over the reply's content looking for a
toolUseblock. It carries the toolinput(a dict the model filled in) and atoolUseId.get_pod_status(**tu["input"])unpacks that dict into function arguments and runs the real tool. - We append a
toolResultback as a newuserturn, tagged with the sametoolUseIdso the model knows which request it answers. Thenconverseis called again — now the model has the data and writes a normal sentence, which we print.
What the output means: See the box below: the model uses the returned status (CrashLoopBackOff, 7 restarts) to explain in plain English that the pod is unhealthy. The model never saw a cluster — it only saw the JSON your tool returned.
Try this: Comment out the second converse call and print messages instead. You'll see the raw toolUse and toolResult blocks — the exact data the loop shuttles back and forth.
Structured output via a tool intermediate
The reliable way to force JSON is to define a tool whose schema is your output shape and tell the model to call it. The toolUse.input you get back is guaranteed to match the schema — no parsing prose, no regex.
structured.pyimport boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL = "anthropic.claude-3-5-sonnet-20241022-v2:0"
CLASSIFY = {"tools": [{"toolSpec": {
"name": "record_triage",
"description": "Record the triage classification of a support ticket.",
"inputSchema": {"json": {
"type": "object",
"properties": {
"severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
"category": {"type": "string"},
"needs_human": {"type": "boolean"},
},
"required": ["severity", "category", "needs_human"],
}},
}}]}
resp = brt.converse(
modelId=MODEL,
messages=[{"role": "user", "content": [{"text": "Ticket: 'prod DB is down, customers affected'"}]}],
toolConfig={**CLASSIFY, "toolChoice": {"tool": {"name": "record_triage"}}}, # force the tool
)
for block in resp["output"]["message"]["content"]:
if "toolUse" in block:
print(block["toolUse"]["input"]) # a dict matching the schema
{'severity': 'critical', 'category': 'database-outage', 'needs_human': True}
Sometimes you don't want a tool executed — you just want the model to return clean, valid JSON in a fixed shape (a severity, a category, a flag). The trick: define a tool whose input schema is your output shape, then force the model to call it. What the model "passes" to that tool is your JSON.
CLASSIFYdefines arecord_triagetool. ItsinputSchemalists the exact fields you want back: aseverityrestricted to anenum(only low/medium/high/critical are allowed), a free-textcategory, and a booleanneeds_human. All three arerequired.- In the
conversecall,toolConfig={**CLASSIFY, "toolChoice": {"tool": {"name": "record_triage"}}}merges the tool list with atoolChoicethat forces that specific tool. The model has no option to reply with prose — it must fill the schema. - The message asks the model to triage a ticket ("prod DB is down, customers affected"). Because the tool is forced, the model classifies it into the schema instead of chatting.
- The loop pulls the
toolUseblock and printsblock["toolUse"]["input"]— a Python dict that is guaranteed to match your schema. No regex, no "parse the JSON out of the prose," no risk of an unexpected field.
What the output means: A dict like {'severity': 'critical', 'category': 'database-outage', 'needs_human': True}. Every key is present and severity is one of your allowed values — that's the schema doing its job.
Try this: Add a "confidence" number field to properties and required, then re-run. The returned dict gains the new field — this is how you shape any structured output you need.
Exercise W3.1 — Two-tool agent
Context: One tool proves the loop works; two tools prove the model can reason across them — checking state, deciding, then acting. A diagnose-then-fix agent is the smallest example of that chaining, and logging every call is how you audit what it did.
Your task: Add a restart_pod tool alongside get_pod_status, ask the model to diagnose and fix the checkout-api pod, and watch it chain status → decide → restart, printing every tool call.
Requirements:
- Register two tools,
get_pod_statusandrestart_pod, each with a propertoolSpecschema - Run the multi-turn dispatch loop until the model stops requesting tools
- Observe the model call status first, then decide to restart based on the result
- Print every tool call (name and input) so the chain is visible
- Make
restart_poda no-op that only logs — do not actually restart anything
💡 Hint: The chaining is emergent, not scripted: give the model both tools and a clear goal, and let the loop feed each toolResult back so it can decide the next step.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Tool use lets a model call your functions, but first it needs a machine-readable contract describing each tool. Bedrock wraps a JSON Schema in its own toolSpec envelope, and getting that shape exactly right is the foundation for everything else.
Your task: Write a toolConfig declaring one tool get_weather(city: str) using Bedrock's toolSpec / inputSchema.json shape.
Requirements:
- Structure it as
{"tools": [{"toolSpec": {...}}]} - Give the tool a
nameand adescriptionthe model can reason about - Nest the JSON Schema under
inputSchema.jsonwithtype,properties, andrequired - Validate the structure offline (e.g. assert the tool name) — no API call needed
💡 Hint: The doubly-nested keys are the gotcha: the schema lives at toolSpec.inputSchema.json, not directly under toolSpec.
Show solution
Bedrock tools wrap a JSON Schema under toolSpec.inputSchema.json.
TOOLS = {"tools": [{
"toolSpec": {
"name": "get_weather",
"description": "Get current weather for a city.",
"inputSchema": {"json": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
}},
}
}]}
assert TOOLS["tools"][0]["toolSpec"]["name"] == "get_weather"
print("tool spec ok")
Context: The model never runs your tool — it requests one and stops. Recognizing that pause, signalled by a specific stop reason, is what separates a plain chat call from an agentic one.
Your task: Call converse with a toolConfig and detect when the model wants a tool, pulling out the requested tool name and input.
Requirements:
- Pass the
toolConfigalongside themessages - Check that
stopReason == "tool_use" - Scan the response content blocks for the one containing a
toolUse - Extract the requested
nameandinputfrom that block
💡 Hint: A tool request is just another content block in the assistant's message — loop the blocks and pick the one with a toolUse key.
Show solution
On a tool request, stopReason is "tool_use" and the args are in a toolUse block.
import boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
TOOLS = {"tools": [{"toolSpec": {"name": "get_weather",
"description": "Weather for a city.",
"inputSchema": {"json": {"type":"object",
"properties":{"city":{"type":"string"}}, "required":["city"]}}}}]}
r = brt.converse(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=[{"role":"user","content":[{"text":"Weather in Paris?"}]}],
toolConfig=TOOLS,
)
if r["stopReason"] == "tool_use":
for b in r["output"]["message"]["content"]:
if "toolUse" in b:
print(b["toolUse"]["name"], b["toolUse"]["input"])
Context: A tool request is only half a conversation. To finish it you run the tool, feed the result back tied to its request id, and call the model again so it can phrase a final answer — the canonical two-hop tool loop.
Your task: After a tool_use stop, run the tool, append a toolResult keyed by toolUseId, and call converse again for the final answer.
Requirements:
- Append the model's own tool-requesting
messageback onto the transcript first - Execute the requested tool with its
inputto produce a result - Return the result as a
toolResultblock whosetoolUseIdmatches the request - Send the result inside a
userturn, then re-call converse for the grounded final reply
💡 Hint: The toolUseId is the thread that stitches request to result — copy it exactly, and remember the result goes back as a user role turn.
Show solution
The toolUseId ties the result back to the request; the result goes back as a user turn.
import boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
TOOLS = {"tools": [{"toolSpec": {"name":"get_weather","description":"w",
"inputSchema":{"json":{"type":"object",
"properties":{"city":{"type":"string"}},"required":["city"]}}}}]}
def run_tool(name, inp):
return {"tempC": 18} if name == "get_weather" else {}
msgs = [{"role":"user","content":[{"text":"Weather in Paris?"}]}]
r = brt.converse(modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=msgs, toolConfig=TOOLS)
if r["stopReason"] == "tool_use":
msgs.append(r["output"]["message"]) # model's turn
for b in r["output"]["message"]["content"]:
if "toolUse" in b:
tu = b["toolUse"]
result = run_tool(tu["name"], tu["input"])
msgs.append({"role":"user","content":[{"toolResult":{
"toolUseId": tu["toolUseId"],
"content": [{"json": result}]}}]})
final = brt.converse(modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=msgs, toolConfig=TOOLS)
print(final["output"]["message"]["content"][0]["text"])
Context: Tool use isn't only for actions — forcing a single tool is the cleanest way to get guaranteed schema-valid JSON out of a model with zero prose to parse. It's structured output by construction.
Your task: Force a single record_ticket tool via toolChoice so the model returns schema-valid JSON with no prose, then read toolUse.input.
Requirements:
- Declare a tool whose schema captures the fields you want (e.g. an enum
priorityand asummary) - Set
toolChoiceto{"tool": {"name": ...}}to force that specific tool - The response contains a
toolUseblock rather than free text - Read the structured data from
toolUse.input, which conforms to your schema
💡 Hint: Because the tool is forced, you never inspect message text — the answer is the validated input object the model filled in.
Show solution
Forcing the tool with toolChoice guarantees the output matches the schema; you read the input, not text.
import boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
TOOLS = {"tools": [{"toolSpec": {"name":"record_ticket",
"description":"Structured ticket.",
"inputSchema":{"json":{"type":"object","properties":{
"priority":{"type":"string","enum":["low","high"]},
"summary":{"type":"string"}},"required":["priority","summary"]}}}}],
"toolChoice": {"tool": {"name": "record_ticket"}}}
r = brt.converse(modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=[{"role":"user","content":[{"text":"Server down, urgent!"}]}],
toolConfig=TOOLS)
for b in r["output"]["message"]["content"]:
if "toolUse" in b:
print(b["toolUse"]["input"]) # {'priority':'high','summary':...}
Context: Real agents have many tools and can chain several before answering. The durable pattern is a dispatch loop over a name→callable registry that keeps running tools until the model stops asking — so adding a tool is a one-line change.
Your task: Build a loop that repeatedly calls converse and dispatches tools by name from a registry until stopReason != "tool_use".
Requirements:
- Keep a registry mapping tool names to Python callables
- A
dispatch(name, input)looks the callable up and invokes it - Unknown tool names return a structured error rather than crashing the loop
- The loop feeds each result back as a
toolResultand re-calls converse until a non-tool stop reason - The dispatch/registry logic is verifiable offline, independent of the model call
💡 Hint: Separate the two concerns cleanly: the registry+dispatch is pure local code you can unit-test, while the converse loop just wires toolUse to dispatch and back.
Show solution
A registry maps tool names to Python callables so adding a tool is a one-line change.
REGISTRY = {
"get_weather": lambda a: {"tempC": 18},
"get_time": lambda a: {"iso": "2026-01-01T00:00:00Z"},
}
def dispatch(name, inp):
fn = REGISTRY.get(name)
if not fn:
return {"error": f"unknown tool {name}"}
return fn(inp)
# offline check of the dispatch table:
print(dispatch("get_weather", {"city":"Paris"})) # {'tempC': 18}
print(dispatch("nope", {})) # {'error': 'unknown tool nope'}
# In production the converse loop calls dispatch() for each toolUse block,
# appends a toolResult keyed by toolUseId, and re-calls converse until done.
Context: A 'where is my order?' bot is a textbook agent: the model decides when to look up the order, but your code owns the actual backend call and hands structured tracking data back for the model to phrase naturally.
Your task: Model the tool loop for a retail order-status agent where a lookup_order tool hits a (stubbed) backend and returns tracking JSON the model then phrases.
Requirements:
- Declare a
lookup_ordertool taking anorder_id - Back it with a stub standing in for a real API/DB, returning tracking JSON (status, ETA) or a not-found result
- On
stopReason == "tool_use", call the backend with the model'sinputand return atoolResultkeyed bytoolUseId - Re-call converse so the model turns the JSON into a customer-facing sentence
- The backend stub returns feedable JSON and can be exercised offline
💡 Hint: Keep the boundary crisp: the model chooses to call the tool and writes the final prose, but the lookup itself is ordinary code returning a plain dict.
Show solution
The model decides when to call the tool; your code owns the backend call and feeds structured JSON back as a toolResult.
def order_service(order_id): # stub for a real API/db
db = {"A100": {"status": "shipped", "eta": "2026-01-05"}}
return db.get(order_id, {"status": "not_found"})
TOOLS = {"tools": [{"toolSpec": {"name":"lookup_order",
"description":"Look up an order by id.",
"inputSchema":{"json":{"type":"object",
"properties":{"order_id":{"type":"string"}},
"required":["order_id"]}}}}]}
# offline: prove the tool returns feedable JSON
print(order_service("A100")) # {'status':'shipped','eta':'2026-01-05'}
# In the loop: on stopReason=='tool_use', call order_service(input['order_id']),
# append {'toolResult':{'toolUseId':..,'content':[{'json': result}]}}, re-converse.
✓ Checkpoint — you can move on when you can…
- Define a tool with
toolSpecand run the Converse tool loop end-to-end. - Explain the
tool_usestop reason and thetoolResultblock. - Force schema-valid JSON out of the model with
toolChoice. - Explain how this manual loop relates to a managed Bedrock Agent.
Knowledge check check yourself
In the Converse tool loop, what does a stopReason of tool_use signal and what are the next steps you must take?
Show answer
How do you force Claude to return schema-valid JSON instead of prose, and why is this more reliable than parsing the model's text?