AI EngineeringZero to ProductionHome·About·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).
▶ 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

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.

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.

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

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.

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