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).
30-second recap
- Bedrock & Claude: Amazon Bedrock hosts Claude and other models behind AWS IAM and billing; you call them with the AWS SDK instead of a vendor API key.
- Agent & tool foundations: The model asks to call a tool, your code runs it and returns the result, and the loop repeats until the model answers — with step caps so it can't run forever.
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
Here's the wall every beginner hits. You ask Claude "is the checkout-api pod healthy?" and it cheerfully replies — with a paragraph it invented, because a language model has no cluster to look at. It can only produce text from its training, so it guesses, and the guess sounds confident and is wrong. The model isn't broken; it's doing exactly what it was built to do. What's missing is a way for it to stop talking and ask you to go check. That mechanism is a tool.
Think of the model as a brilliant analyst locked in a room with no phone. It can reason about anything, but it cannot see live data or take an action in the world. A tool is the telephone you hand it — you don't do the work for the analyst, you give it a labelled phone it can pick up. When the analyst needs the pod's status, it doesn't hallucinate; it says "call get_pod_status with name=checkout-api" and waits. You place the call (run real code), and pass the answer back through the same phone. The model stays in charge of deciding; your code stays in charge of doing.
Now the precise version. On Bedrock's Converse API, you describe each tool with three things: a name, a plain-English description, and an inputSchema (a JSON Schema saying what arguments it takes). You pass this list in a field called toolConfig. The model reads your descriptions, and if a request looks like it needs one, it emits a structured request to call that tool instead of finishing its turn — you'll see the exact loop in the next section. This is the identical mental model as function-calling in the Anthropic SDK; only the field names differ (toolConfig/toolSpec on Bedrock's Converse rather than the SDK's tools array).
The mistake that costs people hours: treating the description as a throwaway label. It is not documentation for you — it is a prompt the model reads to decide whether to call the tool at all. A vague description like "pod tool" makes the model call it at the wrong times or skip it entirely; a precise one ("Get the status of a Kubernetes pod. Use when asked about a pod's health.") makes tool selection reliable. Write descriptions as carefully as you write prompts, because that is exactly what they are.
toolConfig; the model reads the description, decides it needs one, and asks — but your code is what actually runs the function and returns the data.
Read it left to right: the blue box on the left is the model, the green box on the right is your code, and the white card in the middle is the tool contract that connects them.
- The blue box (Claude on Bedrock) can reason and choose, but the sub-labels spell out its limits: it cannot see live data or run your functions.
- The dashed arrow pointing back to the model is you declaring the tool — handing over the
toolConfigcard ofname,description, andinputSchemaso the model knows what phone it can pick up. - The curved blue arrow along the bottom is the payoff: the model emits a structured request ("call
get_pod_statuswithname=…"), your code (green) runs the real function, and the answer travels back — the model decides, your code does.
In short: the tool spec is the boundary — the model picks the phone and dials; your code is always the one that actually does the work.
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.
The single biggest confusion here trips up almost everyone the first time: the model never runs your tool. Read that again. You define a Python function called get_pod_status, you hand its schema to Bedrock — and Bedrock never touches your function. It has no access to your machine. All it can do is send back a message that means "I would like you to run get_pod_status with these arguments, and tell me the result." You run the function. The model just requests and waits. Miss this and you'll stare at the response wondering why nothing happened; grasp it and the whole protocol clicks.
The clearest analogy is a waiter taking your order. You (the model) tell the waiter "I'll have the salmon." The waiter doesn't cook — the kitchen (your code) does. The waiter carries your order to the kitchen, waits, then brings back the plate. The conversation only continues once the food arrives. A tool call is that round trip: the model orders (a toolUse block), your code cooks (runs the real function), and the dish comes back (a toolResult block) so the model can finally "eat" — that is, write its answer using real data.
Concretely, that round trip is two converse calls, not one, and this is why:
- First call — you send the user's question plus
toolConfig. The model reads it, decides it needs live data, and stops early withstopReason == "tool_use". Its message contains atoolUseblock naming the tool, itsinput, and a uniquetoolUseId. - You, in between — append the model's own message to the transcript (so the history stays intact), run the named tool with that
input, and wrap the return value in atoolResultblock tagged with the sametoolUseId. That id is the thread that stitches the answer back to the exact request; it goes back as a newuserturn. - Second call — you send the grown transcript back. Now the model has the real status and writes an ordinary sentence for the human.
Two mistakes to avoid. First, forgetting to append the model's tool-request message before you append the result — the transcript becomes incoherent and the second call errors. Second, mismatching the toolUseId: it must be copied exactly, because with several tools in flight it is the only thing telling the model which result answers which request.
tool_use; you append the model's message, run the tool, append a toolResult carrying the same toolUseId; call two turns that data into the final sentence.
Follow the arrows as a clockwise loop starting at the top-left blue box, across the top, down the right, and back to the second call on the left.
- The top row is the first
conversecall: you send the question plustoolConfig, and the amber box shows the model stopping early withstopReason = tool_useand atoolUseblock that carries atoolUseId. - The white box "You, in between" is the work only your code can do: append the model's own message, run the named tool, and wrap its return value. The purple box below is the
toolResult— note it must carry the sametoolUseIdand goes back as auserturn. - The arrow curving back to the second blue
conversebox sends the grown transcript again; the green box is the payoff — the model now has real data and stops withend_turn, writing the plain-English answer.
In short: request → run → result → re-ask. The toolUseId is the thread that ties the result to the request across the two calls.
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
You have almost certainly written this brittle code, or inherited it. You ask the model to "classify this ticket and return JSON with severity, category, and needs_human," and it obliges — mostly. Then one day it wraps the JSON in a chatty preamble ("Sure! Here's the classification:"), or fences it in ```json, or invents a field you didn't ask for, or spells severity "Critical" instead of "critical." Your json.loads() throws, or worse, silently succeeds with the wrong shape, and a downstream service falls over at 2am. The root problem: you asked for JSON with words, and words are a suggestion the model is free to reinterpret.
Here's the trick that makes the whole class of bugs vanish, and it's a lovely bit of lateral thinking: you already have a mechanism that guarantees a rigid, validated shape — the tool schema. A tool's inputSchema is a contract the model must satisfy to call the tool. So instead of using tools only to do things, you use one to capture your answer. Define a tool named something like record_triage whose input schema is exactly the JSON shape you want out. You never intend to "run" it — the arguments the model fills in to call it are your output. It's like handing someone a form with labelled boxes instead of a blank page: they can't ramble, they can only fill the boxes.
The last piece is making it non-optional. Left to its own judgement the model might still answer in prose, so you set toolChoice to force that specific tool — telling Bedrock "you must call record_triage, no free text allowed." Now the model has exactly one way to respond: populate the schema. You read the answer from toolUse.input, which is a plain dict guaranteed to match your properties and required fields — no regex, no fence-stripping, no defensive parsing.
The real power is in constrained fields. Note the severity uses an "enum" of ["low","medium","high","critical"]. That is not decoration — it means the model cannot return "urgent" or "Critical" or "sev-1"; the value it gives you is provably one of your four allowed strings, safe to feed straight into a switch or a database column. This is the difference between "usually valid" and "valid by construction," and it is why forced-tool structured output is the standard way to get machine-readable data out of a model on Bedrock.
toolUse.input — a dict that matches your properties and enum by construction.
The dashed line down the middle splits two ways to get JSON: the fragile prose approach on the left, the forced-tool approach on the right. Read each side top to bottom.
- On the left, a prose request flows down into the red boxes — the ways the model can drift: a chatty preamble, a
```jsonfence, or a wrong-shaped value like "Critical" or an invented field. They funnel into the amber box:json.loads()breaking downstream. - On the right,
toolChoiceforces the tool whoseinputSchemais your output shape. The white box shows the constrainedseverityenum— the model literally cannot return anything but the four allowed strings. - The green box at the bottom right is the payoff: you read
toolUse.input, a dict that is valid by construction — no regex, no fence-stripping, no defensive parsing.
In short: stop asking the model to promise a shape and start making it fill one — the enum turns "usually valid" into "valid by construction."
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?