Tool use in Claude: the full tool-calling loop in Python

Tool use lets Claude call functions in your code. The whole loop: tool definitions, parallel calls, errors and limits.

4 min · · development · агенты

Tool use (also called function calling) lets Claude call functions in your code: the model decides which function to call and with which arguments, you run it and return the result, and the model continues. Every agent is built on this, from a calculator to a bot with database access. Below is a complete working example of tool use in Claude in Python with the Anthropic SDK and Zukko, checked against the Anthropic documentation.

How the tool use loop works

  1. You pass a list of tools in the request: name, description and a JSON Schema for the arguments.
  2. If Claude decides to call a tool, the response comes with stop_reason: "tool_use" and one or more tool_use blocks.
  3. You append the model's response to the history in full (response.content, not just the text).
  4. You execute every tool_use block and send the results in one user message, as tool_result blocks carrying the matching tool_use_id.
  5. You repeat the request. The loop ends when stop_reason is no longer tool_use.

Install and client

bash
pip install anthropic
bash
import json
import anthropic

client = anthropic.Anthropic(
    base_url="https://api.zukko.pro",
    api_key="fetch_your_key",
)

The address has no /v1: the Anthropic SDK adds the path itself. More in the Python guide and the Messages reference.

Functions and their definitions

First, plain Python functions. The calculator parses the expression with ast rather than eval: arguments come from the model, and so indirectly from the user, and must never be executed as code.

bash
import ast
import operator

def get_weather(location: str, unit: str = "celsius") -> str:
    data = {"London": 15, "Paris": 18, "Tokyo": 22}
    if location not in data:
        raise ValueError(f"No weather data for {location}")
    t = data[location]
    return f"{location}: {t}°C" if unit == "celsius" else f"{location}: {round(t * 9 / 5 + 32)}°F"

OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
       ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}

def _eval(node):

Now the definitions for the model. The description is the main thing Claude uses to decide when to call a tool, so make it detailed.

bash
tools = [
    {
        "name": "get_weather",
        "description": "Returns the current temperature in a city. Use it when asked about the weather.",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {"type": "string", "description": "City name in English, e.g. London"},
                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["location"],
        },
    },
    {

The full loop

The key detail: in one response Claude may request several tools at once (for example, the weather in two cities). You must execute all calls and return all results in one user message. If you handle only the first tool_use block, the API rejects the next request: every call needs its own tool_result.

bash
def run_agent(user_message: str, model: str = "claude-sonnet-5", max_steps: int = 10) -> str:
    messages = [{"role": "user", "content": user_message}]

    for _ in range(max_steps):
        response = client.messages.create(
            model=model,
            max_tokens=4096,
            tools=tools,
            messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            if response.stop_reason == "max_tokens":

What matters here:

  • The model's response is appended in full. Besides tool_use, response.content may contain text and other blocks; return them to the history unchanged.
  • Results come first. In the message with results, tool_result blocks must be at the start; if you want to add text, put it after all results.
  • Errors are not lost. If a function fails, return a tool_result with is_error: True and the error text. The model will see it and can fix the arguments or tell the user.
  • There is a step limit. max_steps protects against infinite loops and unexpected spending.

Running it

bash
print(run_agent("What's the weather in London and Paris? And what is (18 - 15) * 7?"))
print(run_agent("How many degrees Fahrenheit is it in Tokyo?"))
print(run_agent("What's the weather in Berlin?"))

The third request gets an error from the tool (no data), and the model reports it in its answer instead of crashing.

Controlling tool choice

tool_choice defaults to {"type": "auto"}: the model decides whether to call a tool. You can disable calls ({"type": "none"}) or limit them to one per turn: {"type": "auto", "disable_parallel_tool_use": True}.

Forced calls ({"type": "any"} or {"type": "tool", "name": "..."}) are not supported by every model. According to the Anthropic docs, these values return a 400 error for Claude Opus 5.5. If you need a guaranteed call with that model, use auto plus an explicit instruction in the prompt, such as "use the get_weather tool".

Async version

bash
import asyncio
from anthropic import AsyncAnthropic

aclient = AsyncAnthropic(base_url="https://api.zukko.pro", api_key="fetch_your_key")

async def run_agent_async(user_message: str, model: str = "claude-sonnet-5") -> str:
    messages = [{"role": "user", "content": user_message}]
    for _ in range(10):
        response = await aclient.messages.create(
            model=model, max_tokens=4096, tools=tools, messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason != "tool_use":
            return "".join(b.text for b in response.content if b.type == "text")

Common mistakes

  • Only the first tool_use is handled. With parallel calls the next request fails with 400. Iterate over all blocks.
  • Only the response text is added to history. Append response.content in full.
  • Results are split across several messages. All tool_result blocks for one turn go into one user message.
  • Missing tool_use_id. Without it the model cannot match the result to the call.
  • Arguments are executed as code. No eval, exec, or unchecked substitution into shell or SQL.

Each loop step is a separate request, and the whole history is billed again as input tokens. For agents with a long history, remember that Zukko bills cached tokens at the regular input price. You can create a key for experiments in the dashboard.

FAQ

Can Claude skip calling a tool?

Yes. If no tool is needed, the model answers right away with stop_reason: "end_turn". The loop above handles this: it returns the text as soon as stop_reason is not tool_use.

Can Claude call several tools in one turn?

Yes, that is the default. Execute all calls and return the results in one message. You can limit it to one call per turn with disable_parallel_tool_use.

Is there a limit on tool result size?

The result becomes part of the context and is billed as input tokens on every following step. Return only what is needed: filtered rows, not the whole response of an external API.

Can I do the same with the OpenAI format?

Yes, but tool definitions and the response format differ there. This example is for the Anthropic SDK; for the OpenAI format see the Chat Completions reference.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

Tool use with Claude: Anthropic SDK Python example · Zukko