AI EngineeringZero to ProductionHome·About·What’s new·Contact
AWS AI Automation · Chapter W3

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.

⏱️ ~1.5 hours🧪 3 labs🎯 Intermediate
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • AWS credentials (aws configure) + Bedrock model access enabled in your region + pip install boto3
  • AWS credentials (aws configure) + pip install boto3
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

Learning objectives

  • Give Claude tools via the Converse toolConfig and run the tool loop.
  • Force valid JSON out of the model with a tool schema (structured output).
  • Handle the tool_use stop reason and return toolResult blocks.
  • Understand why tool schemas are the backbone of every Bedrock Agent (W5).
📋 Before you startThis page builds on: Bedrock & Claude · Agent & tool foundations. Jumping in? Skim those first — or open the recap.
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.
▶ Runnable companionEvery code block in this lesson is also a standalone file under 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.

the model DECIDES, your code DOES — the tool spec is the labelled phone between them Claude on Bedrock reasons over the request reads each tool's description to choose cannot see live data or run your code toolConfig name · description · inputSchema Your code get_pod_status(name) runs the real function returns a plain dict owns the doing and side effects you declare emits "call get_pod_status(name=…)" → you run it → answer goes back A tool is a labelled phone, not a hand-off of control. You declare each tool in 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.
🗺️ How to read this diagram

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 toolConfig card of name, description, and inputSchema so 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_status with name=…"), 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab W3.1
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}
▶ How this works

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.

  1. boto3.client("bedrock-runtime", ...) opens a connection to Amazon Bedrock (AWS's hosted-model service) in a region. boto3 is AWS's Python SDK; brt is the handle you'll call the model through. MODEL is the exact Claude model ID on Bedrock.
  2. TOOLS is 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), and inputSchema.
  3. The inputSchema is JSON Schema: it says the input is an object with a name string, and that name is required. This is the contract — the model must fill in a name to use the tool.
  4. 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:

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.

one round trip = TWO converse calls, with your tool run in between 1 · converse question + toolConfig stopReason tool_use toolUse block + toolUseId You, in between append model msg · run the tool wrap return value as toolResult toolResult (same toolUseId) appended as a new user turn 2 · converse grown transcript stopReason = end_turn → plain-English answer The loop is two calls with a tool run wedged between them. Call one stops with 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.
🗺️ How to read this diagram

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 converse call: you send the question plus toolConfig, and the amber box shows the model stopping early with stopReason = tool_use and a toolUse block that carries a toolUseId.
  • 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 same toolUseId and goes back as a user turn.
  • The arrow curving back to the second blue converse box sends the grown transcript again; the green box is the payoff — the model now has real data and stops with end_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.

Lab W3.2
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.
▶ How this works

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.

  1. messages is the running conversation — a list where each turn has a role (user or the model's assistant) and content. We start with the user's question about the checkout-api pod.
  2. brt.converse(...) sends the messages and toolConfig=TOOLS to Bedrock. Passing the tools is what lets the model choose to call one.
  3. if resp["stopReason"] == "tool_use": — the model tells you why it stopped. tool_use means "I'm not done; please run a tool for me." We first append the model's own message back into messages so the conversation stays complete.
  4. We loop over the reply's content looking for a toolUse block. It carries the tool input (a dict the model filled in) and a toolUseId. get_pod_status(**tu["input"]) unpacks that dict into function arguments and runs the real tool.
  5. We append a toolResult back as a new user turn, tagged with the same toolUseId so the model knows which request it answers. Then converse is 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.

This is the seed of an AgentA Bedrock Agent (W5) is this loop, managed for you: AWS runs the loop, calls your Lambda tools, and keeps session state. Understanding the manual loop first means the managed version holds no mystery.

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.

ASK IN PROSE — a suggestion FORCE A TOOL — a contract "return JSON with severity…" "Sure! Here's the classification:" ```json … ``` fenced "Critical" · extra field · sev-1 json.loads() breaks at 2am forces the record_triage tool inputSchema = your output shape severity: enum low · medium · high · critical only toolUse.input a dict valid BY CONSTRUCTION no regex, no fence-stripping Words are a suggestion; a schema is a contract. Asking for JSON in prose (left) can return preambles, code fences, or wrong-shaped values that break parsing; forcing a tool (right) hands you toolUse.input — a dict that matches your properties and enum by construction.
🗺️ How to read this diagram

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 ```json fence, or a wrong-shaped value like "Critical" or an invented field. They funnel into the amber box: json.loads() breaking downstream.
  • On the right, toolChoice forces the tool whose inputSchema is your output shape. The white box shows the constrained severity enum — 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."

Lab W3.3
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}
▶ How this works

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.

  1. CLASSIFY defines a record_triage tool. Its inputSchema lists the exact fields you want back: a severity restricted to an enum (only low/medium/high/critical are allowed), a free-text category, and a boolean needs_human. All three are required.
  2. In the converse call, toolConfig={**CLASSIFY, "toolChoice": {"tool": {"name": "record_triage"}}} merges the tool list with a toolChoice that forces that specific tool. The model has no option to reply with prose — it must fill the schema.
  3. 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.
  4. The loop pulls the toolUse block and prints block["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_status and restart_pod, each with a proper toolSpec schema
  • 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_pod a 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.

Exercise 1 · Define a tool specBeginner

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 name and a description the model can reason about
  • Nest the JSON Schema under inputSchema.json with type, properties, and required
  • 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")
Exercise 2 · Detect a tool_use stopIntermediate

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 toolConfig alongside the messages
  • Check that stopReason == "tool_use"
  • Scan the response content blocks for the one containing a toolUse
  • Extract the requested name and input from 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"])
Exercise 3 · Complete the tool loopAdvanced

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 message back onto the transcript first
  • Execute the requested tool with its input to produce a result
  • Return the result as a toolResult block whose toolUseId matches the request
  • Send the result inside a user turn, 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"])
Exercise 4 · Structured output via a forced toolExpert

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 priority and a summary)
  • Set toolChoice to {"tool": {"name": ...}} to force that specific tool
  • The response contains a toolUse block 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':...}
Exercise 5 · A reusable multi-tool dispatcherProfessional

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 toolResult and 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.
Exercise 6 · Order-status agent with a real backend callIndustry scenario

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_order tool taking an order_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's input and return a toolResult keyed by toolUseId
  • 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 toolSpec and run the Converse tool loop end-to-end.
  • Explain the tool_use stop reason and the toolResult block.
  • 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

✓ Knowledge check

In the Converse tool loop, what does a stopReason of tool_use signal and what are the next steps you must take?

Show answer
It means the model paused because it wants a tool run rather than finishing. You append the model's own message to the conversation, execute the tool named in the toolUse block, then append a toolResult (tagged with the same toolUseId) as a new user turn and call converse again.
✓ Knowledge check

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?

Show answer
Define a tool whose inputSchema is your desired output shape and set toolChoice to force that tool. The toolUse.input you get back is guaranteed to match the schema (e.g. an enum-restricted severity), so there is no regex/prose parsing and no risk of unexpected fields.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in