Claude Tool Use Python: Build Your First Function-Calling POC

Series
AI in Production: 30 Real-World Use Cases with Claude

Part 2 of 30 · View the full series

TL;DR

  • Claude tool use Python (function calling) lets the model request your real Python functions by name instead of hallucinating data it doesn’t have.
  • You define tools with a JSON Schema input_schema; Claude fills the arguments and returns stop_reason: "tool_use" when it wants to call one.
  • The loop is: send message, check stop_reason, run the function, append a tool_result block, call the API again to get the final answer.
  • This article walks through a complete, runnable weather + city-database POC using the anthropic Python SDK and claude-sonnet-4-6.
  • Common mistakes (wrong message ordering, missing tool_use_id, forgetting max_tokens) are covered so you don’t hit them in production.
  • You can extend the same pattern to any real data source: databases, internal APIs, file systems, billing systems.

Why Tool Use Matters for Real Applications

Language models are good at reasoning, synthesis, and natural language. They are not good at knowing what the weather is right now, what a row in your database says, or what your internal pricing table contains. When you ask Claude a question that requires live or private data, without tool use it either refuses or makes something up.

Tool use, also called function calling, solves this cleanly. You tell Claude what functions exist and what parameters they accept. Claude decides when to call them, extracts the right arguments from the user’s request, and waits for you to run the actual function and return the result. Then it synthesizes a natural answer from the real data.

This is not a new concept. OpenAI popularized it in 2023. But the Anthropic implementation has some specific quirks, message-ordering rules, and a multi-turn loop that trips up engineers who are copying examples from other SDKs. This article gives you the correct pattern from the start, with a full working script you can run today.

Who should read this

  • Python developers adding AI features to an existing application.
  • Technical founders prototyping an AI-powered product that needs to read live data.
  • Engineers who have tried Claude tool use python and hit confusing API errors around message structure.
  • Anyone coming from OpenAI’s function calling who needs to understand the Anthropic differences.

What you get from this POC

A single Python file (weather_agent.py) that you can run from the command line. It accepts a natural-language question about the weather in a city, calls a mock weather API and a city-info database lookup, then returns a paragraph-length natural-language answer. The whole thing runs in under two seconds on a laptop with a standard API key.

User Question Claude API stop_reason: “tool_use” Python Functions get_weather / get_city_info tool_result Claude API stop_reason: “end_turn” Ans Claude Tool Use Loop (Single Tool Call) Two API calls. One round-trip to your Python functions. One final answer.
Figure 1: The tool use request/response cycle. Claude returns stop_reason “tool_use”, your Python code runs the function, and you send the result back to get the final natural-language answer.

Understanding the Claude Tool Use Python API Shape

Before writing code, you need the exact API contract. The Anthropic docs cover this, but the examples are minimal. Here is everything that matters for the weather + database pattern.

Defining a tool

Each tool is a Python dict with three keys: name, description, and input_schema. The description is read by Claude at inference time. Write it like a docstring: what the function does, what it returns, and (critically) when Claude should call it versus a different tool.

tool = {
    "name": "get_weather",
    "description": (
        "Returns current weather for a city. "
        "Call this when the user asks about weather, temperature, "
        "rain, wind, or conditions in a specific place."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {
                "type": "string",
                "description": "City name, e.g. 'London' or 'Karachi'"
            },
            "unit": {
                "type": "string",
                "enum": ["celsius", "fahrenheit"],
                "description": "Temperature unit. Default celsius."
            }
        },
        "required": ["city"]
    }
}

What Claude returns when it wants to call a tool

When msg.stop_reason == "tool_use", msg.content is a list that contains one or more blocks. Some blocks have type == "text" (Claude’s thinking before calling the tool, often empty). The ones you care about have type == "tool_use". Each tool_use block has three fields: id, name, and input. The id is a string like toolu_01XYZ.... You must echo it back in your result.

Feeding the result back

After running your function, you add two things to the messages list. First, the assistant’s message that contained the tool_use block. Second, a new user message whose content is a list with a tool_result dict. That dict needs type, tool_use_id (the id from above), and content (the result as a string).

messages.append({"role": "assistant", "content": msg.content})
messages.append({
    "role": "user",
    "content": [
        {
            "type": "tool_result",
            "tool_use_id": tool_block.id,
            "content": str(result)
        }
    ]
})
Key idea: The messages list must alternate roles strictly: user, assistant, user, assistant. When you feed a tool_result back, it goes inside a user message even though conceptually “you” are returning data to Claude. Violating this order gives you a 400 error with a confusing message about role ordering.

Parallel tool calls

Claude can request multiple tools in one turn. The msg.content list may contain two or more tool_use blocks. Your loop should process all of them and return all results in the same user message as a list of tool_result blocks. The POC below handles this correctly.

Parallel Tool Calls: One Round-Trip, Multiple Functions “What’s the weather in Karachi and tell me its population and timezone?” Claude: stop_reason = tool_use Returns 2 tool_use blocks in content[] get_weather city=”Karachi” get_city_info city=”Karachi” Both tool_results in one user msg Then Claude returns final answer
Figure 2: When Claude requests two tools at once, you run both Python functions and return both results in a single user message before making the second API call.

Setting Up the Project

Install and configure

pip install anthropic python-dotenv

Create a .env file in the project root:

# .env  (never commit this file)
ANTHROPIC_API_KEY=sk-ant-api03-...

Your requirements file:

# requirements.txt
anthropic>=0.28.0
python-dotenv>=1.0.0

Project layout

weather-agent/
  weather_agent.py   # the full script
  .env               # ANTHROPIC_API_KEY (gitignored)
  requirements.txt
  .gitignore
# .gitignore
.env
__pycache__/
*.pyc

The Complete POC: Weather and Database Tool-Use Loop

The script below is the full, runnable implementation. Read through it before running; each section is commented. The two “fake” data functions (get_weather and get_city_info) simulate real API calls. In production you would replace the body of each with an actual HTTP request or SQL query. Nothing else changes.

"""
weather_agent.py
A complete Claude tool-use POC: weather + city-database lookup loop.
Requires: pip install anthropic python-dotenv
Set ANTHROPIC_API_KEY in your .env file.
"""

import os
import json
from dotenv import load_dotenv
import anthropic

load_dotenv()

# ---------------------------------------------------------------------------
# 1. Mock data functions.
#    In production, replace these bodies with real API calls or DB queries.
# ---------------------------------------------------------------------------

WEATHER_DATA = {
    "karachi":   {"temp_c": 34, "condition": "Sunny", "humidity_pct": 72, "wind_kph": 18},
    "london":    {"temp_c": 14, "condition": "Overcast", "humidity_pct": 83, "wind_kph": 22},
    "new york":  {"temp_c": 21, "condition": "Partly cloudy", "humidity_pct": 61, "wind_kph": 14},
    "tokyo":     {"temp_c": 26, "condition": "Clear", "humidity_pct": 55, "wind_kph": 9},
    "paris":     {"temp_c": 17, "condition": "Light rain", "humidity_pct": 78, "wind_kph": 30},
    "lahore":    {"temp_c": 38, "condition": "Haze", "humidity_pct": 45, "wind_kph": 7},
    "singapore": {"temp_c": 30, "condition": "Thunderstorm", "humidity_pct": 90, "wind_kph": 25},
}

CITY_DATA = {
    "karachi":   {"population": 16_000_000, "country": "Pakistan", "timezone": "PKT (UTC+5)", "elevation_m": 8},
    "london":    {"population": 9_500_000,  "country": "UK",       "timezone": "BST (UTC+1)", "elevation_m": 11},
    "new york":  {"population": 8_300_000,  "country": "USA",      "timezone": "EDT (UTC-4)", "elevation_m": 10},
    "tokyo":     {"population": 13_960_000, "country": "Japan",    "timezone": "JST (UTC+9)", "elevation_m": 40},
    "paris":     {"population": 2_100_000,  "country": "France",   "timezone": "CEST (UTC+2)", "elevation_m": 35},
    "lahore":    {"population": 13_000_000, "country": "Pakistan", "timezone": "PKT (UTC+5)", "elevation_m": 217},
    "singapore": {"population": 5_920_000,  "country": "Singapore","timezone": "SGT (UTC+8)", "elevation_m": 15},
}


def get_weather(city: str, unit: str = "celsius") -> dict:
    """
    Returns current weather for a city.
    In production: call a real weather API (e.g. Open-Meteo or WeatherAPI).
    """
    key = city.lower().strip()
    if key not in WEATHER_DATA:
        return {"error": f"No weather data for '{city}'. Try: {', '.join(WEATHER_DATA)}"}

    data = dict(WEATHER_DATA[key])
    if unit == "fahrenheit":
        data["temp_f"] = round(data["temp_c"] * 9 / 5 + 32, 1)
        data["temp_display"] = f"{data['temp_f']}°F"
    else:
        data["temp_display"] = f"{data['temp_c']}°C"

    data["city"] = city.title()
    data["unit"] = unit
    return data


def get_city_info(city: str) -> dict:
    """
    Returns population, country, timezone, and elevation for a city.
    In production: query a PostGIS database or a Cities API.
    """
    key = city.lower().strip()
    if key not in CITY_DATA:
        return {"error": f"No city data for '{city}'. Try: {', '.join(CITY_DATA)}"}

    data = dict(CITY_DATA[key])
    data["city"] = city.title()
    return data


# ---------------------------------------------------------------------------
# 2. Tool definitions (what Claude sees at inference time).
# ---------------------------------------------------------------------------

TOOLS = [
    {
        "name": "get_weather",
        "description": (
            "Returns current weather conditions for a named city: "
            "temperature, condition (sunny/rainy/etc.), humidity, and wind speed. "
            "Call this when the user asks about weather, temperature, climate today, "
            "or whether to bring an umbrella."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "City name to look up, e.g. 'Karachi' or 'London'."
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "Temperature unit. Defaults to celsius."
                }
            },
            "required": ["city"]
        }
    },
    {
        "name": "get_city_info",
        "description": (
            "Returns demographic and geographic data for a city: "
            "population, country, timezone offset, and elevation above sea level. "
            "Call this when the user asks about city facts, population size, "
            "local time zone, or where a city is located."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "City name, e.g. 'Tokyo' or 'Paris'."
                }
            },
            "required": ["city"]
        }
    }
]


# ---------------------------------------------------------------------------
# 3. Dispatcher: map a tool name + input dict to the right Python function.
# ---------------------------------------------------------------------------

def dispatch_tool(tool_name: str, tool_input: dict) -> str:
    """Run the named tool and return the result as a JSON string."""
    if tool_name == "get_weather":
        result = get_weather(
            city=tool_input["city"],
            unit=tool_input.get("unit", "celsius")
        )
    elif tool_name == "get_city_info":
        result = get_city_info(city=tool_input["city"])
    else:
        result = {"error": f"Unknown tool: {tool_name}"}

    return json.dumps(result)


# ---------------------------------------------------------------------------
# 4. The agent loop.
# ---------------------------------------------------------------------------

def run_agent(user_question: str, verbose: bool = True) -> str:
    """
    Send a question to Claude, handle any tool calls in a loop,
    and return the final natural-language answer.
    """
    client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from environment

    messages = [{"role": "user", "content": user_question}]

    system = (
        "You are a helpful assistant with access to real-time weather data "
        "and a city information database. "
        "When answering questions about weather or city facts, always call the "
        "appropriate tool rather than guessing. "
        "After receiving tool results, give a clear, conversational answer."
    )

    iteration = 0
    max_iterations = 6  # safety limit; prevents runaway loops

    while iteration < max_iterations:
        iteration += 1

        try:
            msg = client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=1024,
                system=system,
                tools=TOOLS,
                messages=messages,
            )
        except anthropic.APIError as e:
            raise RuntimeError(f"Claude API error on iteration {iteration}: {e}") from e

        if verbose:
            print(f"\n[Iteration {iteration}] stop_reason={msg.stop_reason} "
                  f"| input_tokens={msg.usage.input_tokens} "
                  f"| output_tokens={msg.usage.output_tokens}")

        # --- Case 1: Claude is done, return the text answer ---
        if msg.stop_reason == "end_turn":
            for block in msg.content:
                if hasattr(block, "text"):
                    return block.text
            return ""  # should not happen but safe fallback

        # --- Case 2: Claude wants to call one or more tools ---
        if msg.stop_reason == "tool_use":
            # Collect all tool_use blocks from this response
            tool_use_blocks = [b for b in msg.content if b.type == "tool_use"]

            if verbose:
                for b in tool_use_blocks:
                    print(f"  Tool call: {b.name}({json.dumps(b.input)})")

            # Add assistant's message (including the tool_use blocks) to history
            messages.append({"role": "assistant", "content": msg.content})

            # Run each tool and collect results
            tool_results = []
            for block in tool_use_blocks:
                raw_result = dispatch_tool(block.name, block.input)

                if verbose:
                    print(f"  Tool result for {block.name}: {raw_result}")

                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": raw_result
                })

            # Return all results in a single user message
            messages.append({"role": "user", "content": tool_results})
            continue  # loop back to call Claude again with the results

        # --- Case 3: Unexpected stop reason ---
        raise RuntimeError(f"Unexpected stop_reason: {msg.stop_reason}")

    raise RuntimeError(f"Agent exceeded {max_iterations} iterations without finishing.")


# ---------------------------------------------------------------------------
# 5. CLI entry point.
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import sys

    questions = [
        "What's the weather like in Karachi right now? Should I wear light clothes?",
        "Compare the weather in London and Tokyo today.",
        "What is the population of Paris and what timezone is it in? Also, what's the weather there?",
    ]

    # Use a command-line question if provided, otherwise run the demo set
    if len(sys.argv) > 1:
        q = " ".join(sys.argv[1:])
        print(f"\nQuestion: {q}")
        answer = run_agent(q)
        print(f"\nAnswer:\n{answer}")
    else:
        for q in questions:
            print(f"\n{'='*60}")
            print(f"Question: {q}")
            answer = run_agent(q, verbose=True)
            print(f"\nAnswer:\n{answer}")

Running the script

python weather_agent.py "What's the weather in Singapore? What time zone is it?"

Sample output

Question: What's the weather in Singapore? What time zone is it?

[Iteration 1] stop_reason=tool_use | input_tokens=612 | output_tokens=68
  Tool call: get_weather({"city": "Singapore"})
  Tool call: get_city_info({"city": "Singapore"})
  Tool result for get_weather: {"temp_c": 30, "condition": "Thunderstorm", "humidity_pct": 90, "wind_kph": 25, "temp_display": "30°C", "city": "Singapore", "unit": "celsius"}
  Tool result for get_city_info: {"population": 5920000, "country": "Singapore", "timezone": "SGT (UTC+8)", "elevation_m": 15, "city": "Singapore"}

[Iteration 2] stop_reason=end_turn | input_tokens=768 | output_tokens=89

Answer:
Singapore is currently experiencing a thunderstorm with temperatures at 30°C (feels warmer given the
90% humidity). Winds are at 25 km/h. The city operates on Singapore Standard Time (SGT), which is
UTC+8. With nearly 6 million residents, it's a densely populated city-state sitting just 15 metres
above sea level, so stay indoors if you can until that storm passes.

Notice iteration 1 shows two tool calls in one shot. Claude recognised that both pieces of information were needed and requested them in parallel, saving one full round-trip to the API.

How the Loop Works Step by Step

Walking through the code in plain English helps when you need to adapt this to your own data sources.

Iteration 1: Claude decides what to call

You send the user’s question plus the two tool definitions. Claude reads the question, looks at the tool descriptions, and decides it needs both get_weather and get_city_info. It returns stop_reason == "tool_use" and puts both tool_use blocks inside msg.content.

Your code runs the functions

The dispatcher reads each block’s name and input dict, calls the right Python function, and serialises the result to JSON. Both results go into a single user message as a list of tool_result dicts. Each one carries the tool_use_id from its matching block.

Iteration 2: Claude synthesises the answer

You call the API again with the expanded messages list (original question, assistant’s tool_use message, user’s tool_results message). Claude now has real data and returns stop_reason == "end_turn" with a text block containing the final answer.

Why the max_iterations guard matters

If a tool returns an error, Claude might call it again with adjusted arguments. That’s fine for one retry. But if your function always errors, you get an infinite loop. The max_iterations = 6 cap stops that. In production, also set a timeout on each tool function call.

A worked edge case: graceful error recovery

Watch what happens when a user asks about a city the data does not cover. Suppose the question is “What’s the weather in Reykjavik?” The mock dataset has no entry for it, so get_weather returns {"error": "No weather data for 'Reykjavik'..."}. The loop does not crash. Instead the error string travels back to Claude inside a tool_result block exactly like a successful result would. Claude reads it and produces a sensible answer along the lines of “I don’t have current weather data for Reykjavik right now.” The trace looks like this:

Question: What's the weather in Reykjavik?

[Iteration 1] stop_reason=tool_use | input_tokens=598 | output_tokens=42
  Tool call: get_weather({"city": "Reykjavik"})
  Tool result for get_weather: {"error": "No weather data for 'Reykjavik'. Try: karachi, london, new york, tokyo, paris, lahore, singapore"}

[Iteration 2] stop_reason=end_turn | input_tokens=702 | output_tokens=55

Answer:
I don't have live weather data for Reykjavik in my current sources. I can pull
conditions for Karachi, London, New York, Tokyo, Paris, Lahore, or Singapore if
one of those helps instead.

This is the behaviour you want. By returning a structured error dict rather than raising a Python exception, you hand Claude a fact it can reason about. The model turns a failed lookup into a useful reply that even suggests the supported cities. If your function had raised instead, the loop would have crashed and the user would have seen a stack trace. The rule worth memorising: tool functions report failure as data, never as exceptions that escape the function.

Edge case: malformed arguments

Occasionally Claude will pass an argument your function did not expect, for example a city value of “the capital of France” instead of “Paris”. Your function should normalise defensively rather than assume clean input. In the POC, city.lower().strip() handles whitespace and case, but a production lookup should also handle obvious aliases and fall back to a clear error so Claude can re-ask or rephrase. Treat the model’s arguments the way you would treat any untrusted input: validate, normalise, and fail loudly inside the function but quietly to the loop.

Adapting the POC to Real Data Sources

The swap is surgical. Replace the function bodies, keep everything else. Examples:

Real weather API (Open-Meteo, free, no key required)

import httpx

def get_weather(city: str, unit: str = "celsius") -> dict:
    # Step 1: geocode the city name to lat/lon
    geo = httpx.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={"name": city, "count": 1}
    ).json()

    if not geo.get("results"):
        return {"error": f"City not found: {city}"}

    loc = geo["results"][0]
    temp_unit = "fahrenheit" if unit == "fahrenheit" else "celsius"

    # Step 2: fetch current weather
    wx = httpx.get(
        "https://api.open-meteo.com/v1/forecast",
        params={
            "latitude": loc["latitude"],
            "longitude": loc["longitude"],
            "current_weather": True,
            "temperature_unit": temp_unit,
        }
    ).json()

    cw = wx["current_weather"]
    return {
        "city": city.title(),
        "temp": cw["temperature"],
        "unit": temp_unit,
        "wind_kph": cw["windspeed"],
        "weathercode": cw["weathercode"],
    }

Database lookup with SQLAlchemy

from sqlalchemy import create_engine, text

_engine = create_engine(os.environ["DATABASE_URL"])

def get_city_info(city: str) -> dict:
    with _engine.connect() as conn:
        row = conn.execute(
            text("SELECT population, country, timezone, elevation_m "
                 "FROM cities WHERE lower(name) = lower(:city) LIMIT 1"),
            {"city": city}
        ).fetchone()

    if row is None:
        return {"error": f"City '{city}' not in database."}

    return {
        "city": city.title(),
        "population": row.population,
        "country": row.country,
        "timezone": row.timezone,
        "elevation_m": row.elevation_m,
    }

The rest of the agent loop, tool definitions, and dispatcher stay unchanged. This is the key architectural benefit of tool use: the AI layer and the data layer are cleanly separated.

For more complex pipelines, see Part 10: RAG with Claude and pgvector which adds vector-search retrieval to the same pattern, and Part 13: Build a Customer Support Agent with Claude which combines tools with a retrieval layer.

Cost and Latency of a Claude Tool Use Python Loop

For a tool-use loop, the model choice affects both quality and cost. The weather + city query is not a hard reasoning problem, but Claude needs to correctly parse the user’s intent, match it to the right tool, and extract clean arguments. Here is how the three current models compare:

Model Tool-calling accuracy Input ($/1M tokens) Output ($/1M tokens) Recommended for
claude-haiku-4-5 Good for simple, narrow tool sets (1-3 tools, clear schema) $0.80 $4.00 High-volume classification, routing, single-tool lookups
claude-sonnet-4-6 Strong on multi-tool, ambiguous queries, argument inference $3.00 $15.00 Production default; the right choice for this POC
claude-opus-4-8 Best on complex tool orchestration, adversarial inputs $15.00 $75.00 Hard reasoning chains, many tools, ambiguous multi-step tasks

For this weather + city POC, claude-sonnet-4-6 handles every test case correctly and runs the full loop (two API calls) for about 0.002 USD per query. If you are at high volume, you can gate on query complexity: route single-entity lookups to Haiku and multi-step questions to Sonnet. That pattern is covered in Part 27: Cut AI Costs: Model Routing and Batching with Claude.

Where the cost actually goes

The token math for a tool-use loop surprises people the first time they read an invoice. A single user question that looks cheap actually bills across every iteration, and the tool definitions are re-sent on each call. Walk through the Singapore example from the sample output:

  • Iteration 1 input (612 tokens): the system prompt, both tool definitions, and the user question. The tool definitions alone are roughly 200 of those tokens.
  • Iteration 1 output (68 tokens): the two tool_use blocks with their arguments.
  • Iteration 2 input (768 tokens): everything from iteration 1 plus the assistant’s tool_use message plus both tool_result payloads. The history grows on every loop.
  • Iteration 2 output (89 tokens): the final natural-language answer.

Total: about 1,380 input tokens and 157 output tokens. On Sonnet that is roughly 0.0041 + 0.0024 = 0.0065 cents, well under a cent. The number to watch is not the per-query cost but the multiplier. A three-iteration loop re-sends the full conversation three times, so a query that needs a long chain of tool calls can cost five to ten times a single-shot completion. This is why the max_iterations guard is a cost control, not only a safety control.

Latency: the round-trip tax

Each loop iteration is a full network round-trip to the API plus the model’s generation time. For the two-iteration weather query, expect roughly 1.5 to 3 seconds end to end on Sonnet, most of it in the two model generations rather than your local function calls. Three practical levers reduce the wall-clock time a user feels:

Lever What it does When to reach for it
Parallel tool calls Collapses N tool functions into one round-trip; you run them concurrently with asyncio Any time Claude requests two or more tools in the same turn
Prompt caching on tool definitions Skips re-billing and re-processing the static tool block on repeat calls Fixed tool registry hit many times per minute
Stream the final answer Time-to-first-token drops; the user sees text while later tokens generate Chat UIs where perceived speed matters
Right-size the model Haiku generates faster than Sonnet for simple argument extraction Single clear tool, high request volume

The single biggest latency win is making your tool functions concurrent. When Claude returns two tool_use blocks, the naive code runs them one after another. If each function makes a 400 ms HTTP call, you have added 800 ms for no reason. Running them with asyncio.gather brings that back to roughly 400 ms. Here is the concurrent dispatcher:

import asyncio

async def dispatch_tool_async(tool_name: str, tool_input: dict) -> str:
    """Async version: run the named tool without blocking the loop."""
    if tool_name == "get_weather":
        result = await get_weather_async(
            city=tool_input["city"],
            unit=tool_input.get("unit", "celsius"),
        )
    elif tool_name == "get_city_info":
        result = await get_city_info_async(city=tool_input["city"])
    else:
        result = {"error": f"Unknown tool: {tool_name}"}
    return json.dumps(result)


async def run_tools_concurrently(tool_use_blocks) -> list:
    """Run every tool_use block at once and return ordered tool_result dicts."""
    coros = [dispatch_tool_async(b.name, b.input) for b in tool_use_blocks]
    raw_results = await asyncio.gather(*coros)

    return [
        {
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": raw,
        }
        for block, raw in zip(tool_use_blocks, raw_results)
    ]

The ordering matters: asyncio.gather preserves input order, so zipping the blocks against the results keeps each tool_use_id paired with the correct payload. If you reorder or filter the results, you risk handing Claude the wrong data under the right id.

Common Pitfalls in Claude Tool Use Python

These are the mistakes that cause real errors, not theoretical ones.

1. Wrong message role ordering

The messages array must follow user/assistant/user/assistant alternation. After you append the assistant’s tool_use message, the next message must be role “user”. If you accidentally append two consecutive “assistant” messages or two consecutive “user” messages, you get a 400 error.

2. Forgetting to include tool_use_id in the result

Every tool_result block must carry the tool_use_id that came from the matching tool_use block. Without it, Claude cannot map the result to the right call and you get a validation error. The id looks like toolu_01Abc... and is a unique string per call.

3. Omitting max_tokens

The SDK does not have a default for max_tokens. If you omit it, you get a 400 immediately. Always pass it. For tool-use responses, 1024 is enough for most synthesis answers; bump to 2048 if you expect longer outputs.

4. Serialising tool results as Python objects instead of strings

The content field of a tool_result must be a string (or a list of content blocks for multi-modal results). If you pass a Python dict directly, the SDK will raise a type error. Always json.dumps(result) before putting it in the content field.

5. Not handling parallel tool calls

Beginners often write code that only looks at msg.content[0] for the tool block. When Claude requests two tools at once, you miss the second. Always iterate over all blocks in msg.content and collect every block where block.type == "tool_use".

6. Missing error handling in tool functions

If your tool function raises an exception, it will propagate up and crash the agent loop. Claude cannot handle Python exceptions. Wrap the function body in a try/except and return a dict with an "error" key. Claude will read the error message and either retry with different arguments or tell the user what went wrong.

7. Long tool descriptions that eat tokens

Tool definitions count against your input token budget on every API call in the loop. If you have 20 tools with 200-word descriptions each, that’s 4,000+ tokens per call. Keep descriptions concise. If you have many tools, consider prompt caching (Part 4) to amortise the cost.

Pitfall Symptom Fix
Wrong role order HTTP 400 “messages: roles must alternate” Append assistant msg before user tool_result msg
Missing tool_use_id HTTP 400 “tool_result missing tool_use_id” Copy block.id into every tool_result dict
No max_tokens HTTP 400 “max_tokens is required” Always pass max_tokens to messages.create()
Dict in tool content SDK TypeError at runtime json.dumps() before assigning to content
Only reading content[0] Second tool call silently skipped Filter all blocks by type == “tool_use”
Tool function throws Agent crashes mid-loop Return {“error”: str(e)} instead of raising

Extending to a Real Production Loop

The POC above is intentionally minimal. Before shipping this to production, add these layers:

Retry with exponential backoff

import time

def call_claude_with_retry(client, **kwargs):
    for attempt in range(3):
        try:
            return client.messages.create(**kwargs)
        except anthropic.RateLimitError:
            wait = 2 ** attempt  # 1s, 2s, 4s
            time.sleep(wait)
        except anthropic.APIError as e:
            raise  # don't retry non-rate-limit errors
    raise RuntimeError("Exceeded retry limit on Claude API")

Structured output from a tool

If you need a typed object back from Claude rather than free text, define a third tool that represents the output schema and pass tool_choice={"type": "tool", "name": "produce_summary"}. Claude will always call that tool and fill its schema. Read the answer from block.input instead of block.text. That pattern is the subject of Part 3: Structured Output from Claude.

Streaming the final answer

For a chat UI, you want to stream the final answer token by token while keeping the tool calls synchronous. The pattern is: run the tool-use iterations normally until the last API call (where stop_reason will be end_turn), then switch to a streaming call for that final response only. See Part 26: Streaming Responses with Claude for the exact implementation.

Observability

Log msg.usage.input_tokens and msg.usage.output_tokens from every iteration. In a long tool chain, tokens add up quickly across multiple loop iterations. You want to know which queries are expensive before they appear on your invoice. Part 28: Observability for LLM Apps covers a lightweight tracing layer you can add in an afternoon.

Frequently Asked Questions

Can Claude call a tool more than once in a single conversation turn?

Yes. Claude can request multiple rounds of tool calls before returning a final answer. Each round is one iteration of the while loop: Claude returns tool_use, you run the functions and return tool_results, Claude decides to either call more tools or finish. In the POC above, the max_iterations guard limits this to 6 rounds. In most queries the loop finishes in 2.

What is the difference between Claude tool use and OpenAI function calling?

The concepts are identical. The API shape differs slightly. In the Anthropic SDK you pass tools (not functions), the stop reason is "tool_use" (not "function_call"), and results go into a tool_result content block in a user message (not a separate function role message). The input_schema key wraps the JSON Schema (OpenAI puts the schema directly under the function definition). Once you see these differences they are easy to keep straight.

Does the tool description affect which tool Claude picks?

Yes, significantly. Claude reads the description at inference time, not the name. A well-written description that includes “call this when…” guidance reduces misrouting. If you have two tools with overlapping scope (e.g., a general search and a specific database lookup), add explicit disambiguation to each: “Use this for live data. Use search_knowledge_base only for archived articles.”

How do I handle a tool that takes a long time to run?

The Anthropic API call is synchronous from the model’s perspective: it waits for you to return results before generating the next response. For slow tools (SQL queries over large tables, external APIs with high latency), run them with asyncio and gather if you have parallel tool calls. For single slow calls, add a timeout and return an error dict if exceeded. The agent loop handles error dicts gracefully.

Can I pass binary data (images, PDFs) as tool results?

Yes, but only for models that support vision. Instead of a string content, pass a list of content blocks in the tool_result.content field, where one block has type: "image" and a base64-encoded source. Claude will process the image as part of its synthesis. For text-heavy PDFs, extract the text first and pass it as a string; vision processing is slower and more expensive.

Is there a limit on how many tools I can define?

The API does not enforce a hard tool count limit, but every tool definition is tokenised and sent with every request. At some point the tool list itself becomes a significant portion of your input token budget. In practice, keep it under 20 tools per request. For larger tool registries, implement dynamic tool selection: retrieve the 3-5 most relevant tools for the user’s query (using embeddings or a keyword match) before calling the API.

What happens if I send a tool_result with the wrong tool_use_id?

The API returns a 400 error stating that the tool_use_id does not match any pending tool call. This usually happens when you accidentally swap the ids in a parallel-call scenario. The fix is to keep the block reference in scope and copy block.id directly into the result dict rather than reconstructing the id as a string.

Explore more articles in the AI in Production series. Related parts you may want to read next:

External Resources

Previous