Lesson 4 - Parallel Tools, Errors, and Strict Schemas

Welcome to Parallel Tools, Errors, and Strict Schemas

In Lesson 3 you built the agent loop: keep calling the model while stop_reason is "tool_use", run the tool, feed the result back, and stop at "end_turn". That loop is the engine of every tool-using agent. But the version you wrote assumed each turn produces exactly one neat tool call that succeeds. Real conversations are messier.

This lesson covers the three things that break a naive loop: a single turn that asks for several tool calls at once, a tool that fails and must report it, and arguments you need to be guaranteed valid before you act on them. Each one is a small change, and each one makes your agent dramatically more robust.

By the end of this lesson, you will be able to:

  • Handle a turn that contains multiple tool_use blocks and return all results together
  • Return a tool error with is_error and watch the model adapt instead of crashing
  • Use a strict schema to guarantee the model’s arguments match your function signature
  • Update the Lesson 3 loop so it collects every tool call each round, not just the first

You’ll reuse the loop from Lesson 3 and the same claude-haiku-4-5 setup. Let’s begin.


One Turn, Several Tool Calls

Suppose the user asks a question that needs two independent lookups: “What’s the temperature right now in Tokyo and in Cairo?” Neither answer depends on the other, so there’s no reason to make the model wait for the first before asking for the second. The model knows this — it can request both calls in a single turn.

Reading multiple tool_use blocks

Here’s the same get_weather tool from earlier lessons, asked a two-city question:

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from your environment

tools = [{
    "name": "get_weather",
    "description": "Get the current temperature for a city in Celsius.",
    "input_schema": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
    },
}]

messages = [{"role": "user", "content": "What's the temperature right now in Tokyo and in Cairo?"}]

response = client.messages.create(
    model="claude-haiku-4-5", max_tokens=300, tools=tools, messages=messages,
)
print("stop_reason:", response.stop_reason)
tool_uses = [b for b in response.content if b.type == "tool_use"]
print("number of tool_use blocks:", len(tool_uses))
for b in tool_uses:
    print(" call:", b.name, b.input, "id=", b.id[:14], "...")
stop_reason: tool_use
number of tool_use blocks: 2
 call: get_weather {'city': 'Tokyo'} id= toolu_01Dg65Dm ...
 call: get_weather {'city': 'Cairo'} id= toolu_01PbEGV5 ...

One assistant message, two tool_use blocks, each with its own id. This is why the loop you wrote in Lesson 3 must scan the whole content list, not just grab the first tool call. If you only handled response.content[0], you’d answer for Tokyo and silently drop Cairo.

The most common loop bug

A loop that does next(b for b in content if b.type == "tool_use") handles only the first call and ignores the rest. When the model later asks for two things at once, your agent will hang or answer half the question. Always iterate over all tool_use blocks in the turn.

Returning all results in one user message

The matching rule is just as important: every tool_use block must get a tool_result block, and they all go back in one user message, each keyed to the right id. Send only one of them and the API will reject the conversation as incomplete.

# run each requested call and collect a tool_result for each one
results = []
fake_readings = {"Tokyo": "14", "Cairo": "33"}
for b in tool_uses:
    output = fake_readings.get(b.input["city"], "20")   # in a real app: call the weather API
    results.append({
        "type": "tool_result",
        "tool_use_id": b.id,
        "content": output,
    })

messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": results})   # ALL results, one message

final = client.messages.create(
    model="claude-haiku-4-5", max_tokens=200, tools=tools, messages=messages,
)
print("final stop_reason:", final.stop_reason)
print(final.content[0].text)
final stop_reason: end_turn
The current temperatures are:

- **Tokyo**: 14°C
- **Cairo**: 33°C

Cairo is significantly warmer than Tokyo right now!

The model fired off both lookups at once, you returned both results in a single message, and it composed one answer. Because the two calls are independent, you could run them concurrently — with threads, asyncio, or any job runner — and the model neither knows nor cares how you executed them. It just needs every tool_result back before it continues.

The loop, corrected

Here is the Lesson 3 loop adjusted to handle any number of tool calls per round. The only change that matters is the list comprehension that collects every tool_use block, and the inner loop that builds one tool_result per call:

def run_tool(name, args):
    fake = {"Tokyo": "14", "Cairo": "33", "Oslo": "6"}
    return fake.get(args.get("city"), "20")

messages = [{"role": "user",
             "content": "Compare the temperature in Tokyo, Cairo, and Oslo right now."}]

while True:
    resp = client.messages.create(
        model="claude-haiku-4-5", max_tokens=400, tools=tools, messages=messages,
    )
    if resp.stop_reason != "tool_use":
        print("FINAL:", resp.content[0].text)
        break

    tool_uses = [b for b in resp.content if b.type == "tool_use"]   # EVERY call
    print(f"round: {len(tool_uses)} tool call(s)")

    results = [
        {"type": "tool_result", "tool_use_id": b.id, "content": run_tool(b.name, b.input)}
        for b in tool_uses
    ]

    messages.append({"role": "assistant", "content": resp.content})
    messages.append({"role": "user", "content": results})
round: 3 tool call(s)
FINAL: Here's the current temperature comparison:

| City | Temperature |
|------|-------------|
| **Cairo** | 33°C |
| **Tokyo** | 14°C |
| **Oslo** | 6°C |
...
Cairo is significantly warmer than the other two cities, with a 19°C difference compared to Tokyo and 27°C difference compared to Oslo.

Three cities, one round of three calls, one final answer. This is the loop you’ll carry forward into the guided project.


When a Tool Fails

Tools fail. APIs time out, a record isn’t found, an input is ambiguous. You do not raise a Python exception back at the model — you return a normal tool_result, but mark it as an error with "is_error": True and put a human-readable message in content. The model reads the error and adapts.

Returning an error result

Here we ask for the weather in “Springfield” — a name shared by dozens of cities — and have the tool report that ambiguity as an error:

messages = [{"role": "user", "content": "What's the temperature in Springfield right now?"}]

response = client.messages.create(
    model="claude-haiku-4-5", max_tokens=300, tools=tools, messages=messages,
)
tool_use = next(b for b in response.content if b.type == "tool_use")
print("call:", tool_use.name, tool_use.input)

messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": [{
    "type": "tool_result",
    "tool_use_id": tool_use.id,
    "content": "Error: 'Springfield' is ambiguous. Multiple cities match. Provide a country or state.",
    "is_error": True,
}]})

final = client.messages.create(
    model="claude-haiku-4-5", max_tokens=250, tools=tools, messages=messages,
)
print("final stop_reason:", final.stop_reason)
print(final.content[0].text)
call: get_weather {'city': 'Springfield'}
final stop_reason: end_turn
I need more information to help you. There are multiple cities named Springfield.
Could you please specify which Springfield you're asking about? For example:

- Springfield, Illinois (USA)
- Springfield, Massachusetts (USA)
- Springfield, Missouri (USA)
- Or another state/country?

Once you clarify, I can get you the current temperature!

The model didn’t crash and didn’t invent a temperature. It read the error message, understood why the call failed, and asked the user for the missing detail. That’s the whole point of is_error: it turns a failure into information the model can act on, instead of an exception that kills your loop.

Write errors for the model to read

The content of an error result is consumed by the model, so write it like a hint, not a stack trace. “Record not found — check the customer_id” steers a useful retry; KeyError: 4821 does not. Include what went wrong and, when you can, how to fix it.

When the error is something the model can fix on its own — a malformed query, the wrong unit, a missing required field — it will often retry with corrected arguments rather than ask the user. Either way, your job is the same: catch the exception in your tool, package it as a tool_result with is_error: True, and let the loop continue.


Strict Schemas for Guaranteed-Valid Arguments

By default the model tries hard to follow your input_schema, but it isn’t forced to. For most tools that’s fine. But when a wrong argument is expensive — placing an order, issuing a refund, writing to a database — you want a guarantee that what you receive matches your function signature exactly. That’s what a strict schema gives you.

Turning on strict

Add "strict": True as a top-level field on the tool definition. The model is then constrained to emit arguments that conform to the schema. There are two requirements: the schema must set "additionalProperties": false, and it must list every property in required.

tools = [{
    "name": "create_order",
    "description": "Place an order for a product.",
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {
            "product_id": {"type": "string"},
            "quantity": {"type": "integer"},
        },
        "required": ["product_id", "quantity"],
        "additionalProperties": False,
    },
}]

response = client.messages.create(
    model="claude-haiku-4-5", max_tokens=300, tools=tools,
    messages=[{"role": "user", "content": "Order 3 units of product SKU-9921."}],
)
print("stop_reason:", response.stop_reason)
for b in response.content:
    if b.type == "tool_use":
        print("call:", b.name, b.input)
        print("types:", {k: type(v).__name__ for k, v in b.input.items()})
stop_reason: tool_use
call: create_order {'product_id': 'SKU-9921', 'quantity': 3}
types: {'product_id': 'str', 'quantity': 'int'}

strict is accepted on claude-haiku-4-5, and the arguments come back exactly typed: product_id is a string, quantity is an integer (not the string "3"). With no extra keys and no missing fields, you can pass b.input straight into your function without defensive validation.

The requirements are enforced

These aren’t suggestions. If you set strict: True but forget additionalProperties: false, the API rejects the tool before the model ever runs:

tools = [{
    "name": "create_order",
    "description": "Place an order for a product.",
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {"product_id": {"type": "string"}, "quantity": {"type": "integer"}},
        "required": ["product_id", "quantity"],
        # additionalProperties: false is missing
    },
}]

try:
    client.messages.create(
        model="claude-haiku-4-5", max_tokens=200, tools=tools,
        messages=[{"role": "user", "content": "Order 3 of SKU-9921."}],
    )
except anthropic.BadRequestError as e:
    print(str(e)[:200])
Error code: 400 - {'type': 'error', 'error': {'type': 'invalid_request_error',
'message': "tools.0.custom: For 'object' type, 'additionalProperties' must be
explicitly set to false"}, ...}

The 400 response confirms strict mode is genuinely enforced, not silently ignored. Reach for it when an invalid argument would cause real harm; for read-only lookups like get_weather, the default behavior is usually enough and keeps your schemas simpler.


Practice Exercises

Exercise 1: Two different tools in one turn

Define a second tool, get_time (input: city, a string), alongside get_weather. Ask "What's the weather and the local time in Cairo?" and confirm the model returns two tool_use blocks of different names in one turn. Return both results together and read the final answer.

Hint

The two calls use different tool names but the same pattern. Loop over [b for b in resp.content if b.type == "tool_use"], branch on b.name to decide which fake value to return, and append all tool_result blocks to a single user message.

Exercise 2: A retry the model fixes itself

Add a unit field ("celsius" or "fahrenheit") to get_weather. Have your tool return an is_error result whenever the unit is anything other than those two values, with a message naming the allowed options. Ask a question that nudges a bad unit and watch whether the model retries with a valid one.

Hint

This is the error path from the lesson, but recoverable by the model alone. Make the error content explicit — “unit must be ‘celsius’ or ‘fahrenheit’” — and keep your loop running so the model’s corrected call comes back through it.

Exercise 3: Strict where it counts

Take the create_order tool and try to break it: ask the model to “order a couple” of something without a number, or to add a gift_wrap field you never defined. With strict: True, confirm the arguments still match your schema exactly. Then flip strict off and compare what slips through.

Hint

Print b.input and its value types in both modes. Strict mode forces quantity to a real integer and refuses extra keys; without it, you may see a string quantity or an invented field you’d have to validate yourself.


Summary

A robust agent loop handles more than one tidy call. A single turn can contain several tool_use blocks, so your loop must collect all of them and return all tool_result blocks in one user message — independent calls can even run concurrently. When a tool fails, return a tool_result with "is_error": True and a readable message; the model adapts, either retrying with better arguments or asking the user for what’s missing. And when a wrong argument would be costly, set "strict": True (with additionalProperties: false and full required) to guarantee the arguments match your schema exactly — verified accepted and enforced on claude-haiku-4-5.

Key Concepts

  • Parallel tool use — one assistant turn with multiple tool_use blocks; run them all, return all results together.
  • is_error — a flag on a tool_result that reports failure to the model instead of raising in your code.
  • Graceful recovery — the model reads an error message and retries or asks for clarification rather than crashing.
  • Strict schema"strict": True constrains the model to emit exactly-valid, exactly-typed arguments.
  • The loop invariant — each round, collect every tool_use block, not just the first.

Why This Matters

The difference between a demo and a dependable agent is how it behaves when things aren’t perfect: two requests at once, a flaky API, a malformed argument. The patterns here — gather all calls, report errors the model can use, lock down high-stakes inputs — are what keep real tool-using systems from silently dropping work or acting on bad data. You’ll lean on all three in the guided project next.


Next Steps

Continue to Lesson 5 - Guided Project: Multi-Tool Agent

Put the loop, parallel calls, error handling, and schemas together to build a small agent that uses several tools to finish a real task.

Back to Module Overview

Return to the Tool Use & Function Calling module overview


Continue Building Your Skills

You now have every moving part of a serious agent: the loop, multiple calls per turn, error recovery, and strict arguments. In the guided project you’ll assemble them into one working multi-tool agent — the moment all of this clicks into a thing that does real work.