Stage 02 · PractitionerModule 8 of 26~7h

Tool Use

Let Claude call your tools and APIs to take action.

← All modules in this stage

This is the module where Claude stops being a text-completion engine and starts being something that can take action. You define functions; Claude decides when to call them; you run them and feed the results back. It's the foundation under every "AI agent" you've ever heard about.

By the end of this module you'll have

Time: about 1.5 hours for the basics, ~7 hours with all three notebooks.

Prerequisites: Modules 4 (API basics), 6 (advanced prompting), 7 (building apps).


How tool use actually works

It's a three-step dance, and it's worth seeing once before reading code:

1.  You send messages + a list of tool definitions.
2.  Claude responds with content blocks. One of them might be a "tool_use"
    block: {"name": "get_weather", "input": {"city": "London"}}.
3.  YOU run the tool, then send a *new* request with the conversation so far
    plus a "tool_result" block. Claude reads the result and answers normally.

You stay in control. Claude never executes anything itself — it just asks.


A working example: Claude calls a Python function

Save as tool_use_demo.py:

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()
client = Anthropic()

# 1) The tool definition Claude sees.
tools = [{
    "name": "get_weather",
    "description": "Look up the current weather for a city. Returns a short string.",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name, e.g. 'London'."},
        },
        "required": ["city"],
    },
}]

# 2) Your real implementation. Pretend this hits a weather API.
def get_weather(city: str) -> str:
    fake = {"London": "13°C, drizzle", "Tokyo": "24°C, clear", "New York": "9°C, windy"}
    return fake.get(city, f"No data for {city}")

# 3) The conversation.
messages = [{"role": "user", "content": "What's the weather like in Tokyo right now?"}]

response = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=400, tools=tools, messages=messages,
)

# Claude's first reply may contain a tool_use block. Loop until it stops asking.
while response.stop_reason == "tool_use":
    # Append Claude's whole turn to the history (the SDK gives us the message back).
    messages.append({"role": "assistant", "content": response.content})

    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            result = get_weather(**block.input)            # YOUR code runs here
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": result,
            })

    messages.append({"role": "user", "content": tool_results})

    response = client.messages.create(
        model="claude-sonnet-4-6", max_tokens=400, tools=tools, messages=messages,
    )

# After the loop, Claude has stopped asking and has a final answer.
print(response.content[-1].text)

What you should see: Claude responds first with a tool_use block requesting get_weather(city="Tokyo"), your code runs, you send the result back, and Claude replies "It's 24°C and clear in Tokyo right now." — using your data, not its imagination.


What just happened?

Three new concepts are doing all the work:

  1. tools=[...] — you describe each function with a JSON Schema. Claude reads this and decides when (and with what arguments) to call.
  2. stop_reason == "tool_use" — the model stopped to ask you to run something. As long as it keeps asking, keep looping.
  3. tool_result blocks — you reply with a user message whose content is a list of results, each tagged with the tool_use_id Claude generated. That's how multiple tool calls in one turn stay matched up.

Adding a second tool

Adding tools is mostly mechanical: define the schema, write the function, add to the dispatch table.

tools.append({
    "name": "send_email",
    "description": "Send a one-line email. Use only when the user explicitly asked to email someone.",
    "input_schema": {
        "type": "object",
        "properties": {
            "to":      {"type": "string", "format": "email"},
            "subject": {"type": "string"},
            "body":    {"type": "string"},
        },
        "required": ["to", "subject", "body"],
    },
})

DISPATCH = {
    "get_weather": get_weather,
    "send_email":  lambda **kw: f"(pretend) sent: {kw['subject']}",
}

# In the loop:
result = DISPATCH[block.name](**block.input)

Two practical bits:


Safety patterns worth knowing

Risk Cheap mitigation
Claude calls a destructive tool unprompted Description starts with "Use only when explicitly asked." Plus a code-side check.
Tool errors crash the loop Wrap each call in try/except; pass {"is_error": True, "content": str(exc)} back as the tool_result. Claude will adapt.
Infinite tool-use loops Cap iterations: for _ in range(MAX_TURNS): around the loop.
User input bleeds into tool args Treat tool inputs as untrusted. Re-validate before hitting the network or the filesystem.
Sensitive tool needs human approval Don't auto-execute. Show the proposed call to the user, wait for OK, then run.

Try changing one thing


Going deeper: open the notebooks


Module checklist


Next module

Module 9 · RAG Systems — give Claude access to your documents, not just function calls.