IntermediateAI Engineer

Wire up Claude tool use with multiple tools

Build a Python script that gives Claude three tools (a calculator, a weather lookup stub, and a file reader), handles the tool_use / tool_result turn cycle correctly, and exercises a query that requires at least two tool calls in sequence before Claude can answer.

Why this matters

Tool use is how LLMs act on the world. Most production agents are not using a framework; they are driving the tool loop directly via the API. Getting the turn cycle (user to assistant to tool_use to tool_result back to assistant) right is the single biggest source of bugs in new AI engineers' first agentic projects.

Before you start

Step-by-step guide

  1. 1

    Define three tools in JSON Schema

    Write a Python list with three tool definitions: add_numbers (a, b: integers), get_weather (city: string), read_file (path: string). Each must have a name, description, and input_schema with type: object, properties, and required fields. The description is what Claude reads to decide when to call a tool; write it precisely.

    tools = [
        {
            "name": "add_numbers",
            "description": "Add two integers and return the sum.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "a": {"type": "integer", "description": "First number"},
                    "b": {"type": "integer", "description": "Second number"},
                },
                "required": ["a", "b"],
            },
        },
        {
            "name": "get_weather",
            "description": "Get the current weather for a city. Returns temperature in Celsius.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "City name, e.g. London"},
                },
                "required": ["city"],
            },
        },
        {
            "name": "read_file",
            "description": "Read the contents of a file at a given path.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Absolute file path"},
                },
                "required": ["path"],
            },
        },
    ]
  2. 2

    Wire the first turn

    Call messages.create with the three tools in the tools parameter. Print the stop_reason. For a query like 'What is 42 plus 58?', stop_reason should be tool_use, not end_turn. If it is end_turn, Claude answered without tools; your descriptions are too ambiguous.

    import anthropic
    
    client = anthropic.Anthropic()
    messages = [{"role": "user", "content": "What is 42 plus 58?"}]
    
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        tools=tools,
        messages=messages,
    )
    
    print(f"stop_reason: {response.stop_reason}")   # should be "tool_use"
    print(f"content blocks: {[b.type for b in response.content]}")
  3. 3

    Handle the tool_use block

    Inspect response.content for blocks with type == 'tool_use'. Extract name and input. Implement the actual logic for each tool (add_numbers is real; get_weather and read_file can return stub data). Execute the right function and collect the result.

    import os
    
    def execute_tool(name: str, input: dict) -> str:
        if name == "add_numbers":
            return str(input["a"] + input["b"])
        elif name == "get_weather":
            return f"15°C and cloudy in {input['city']}"  # stub
        elif name == "read_file":
            try:
                with open(input["path"]) as f:
                    return f.read()
            except FileNotFoundError:
                raise FileNotFoundError(f"No such file: {input['path']}")
    
    # Extract tool_use blocks from the response
    tool_calls = [b for b in response.content if b.type == "tool_use"]
    print(f"Tool called: {tool_calls[0].name}, input: {tool_calls[0].input}")
  4. 4

    Send the tool_result turn

    Append the assistant's response to messages, then append a new user message with type: 'tool_result', the tool_use_id from the block, and your result. Call messages.create again. Claude should now produce an end_turn response using the result.

    # Append the assistant's response to the conversation
    messages.append({"role": "assistant", "content": response.content})
    
    # Execute each tool and collect results
    tool_results = []
    for block in tool_calls:
        result = execute_tool(block.name, block.input)
        tool_results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": result,
        })
    
    # Send the tool results back to Claude
    messages.append({"role": "user", "content": tool_results})
    
    final = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        tools=tools,
        messages=messages,
    )
    print(f"stop_reason: {final.stop_reason}")   # should be "end_turn"
    print(final.content[0].text)
  5. 5

    Chain two tool calls in a loop

    Write a query that requires two tools in sequence. Handle the loop: keep calling until stop_reason is end_turn. Print the full message history so you can see the complete turn sequence. This multi-step loop is the core pattern behind every production agent.

    def run_agent(user_query: str) -> str:
        messages = [{"role": "user", "content": user_query}]
    
        while True:
            response = client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=1024,
                tools=tools,
                messages=messages,
            )
            messages.append({"role": "assistant", "content": response.content})
    
            if response.stop_reason == "end_turn":
                return response.content[0].text
    
            # Handle all tool_use blocks in this response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    print(f"  -> calling {block.name}({block.input})")
                    result = execute_tool(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })
            messages.append({"role": "user", "content": tool_results})
    
    # Requires two tools: add_numbers + get_weather
    answer = run_agent("What is 17 plus 25, and what is the weather in Paris?")
    print(answer)
  6. 6

    Add error handling for tool failures

    For the file reader, pass a path that does not exist. Return is_error: true in the tool_result. Observe how Claude handles a failed tool gracefully versus silently. A tool that reports errors explicitly produces much better agent behaviour than one that returns empty results.

    def execute_tool_safe(name: str, input: dict) -> dict:
        try:
            result = execute_tool(name, input)
            return {"type": "tool_result", "content": result}
        except Exception as e:
            # is_error=True tells Claude the tool failed; it will adapt its response
            return {
                "type": "tool_result",
                "content": f"Error: {e}",
                "is_error": True,
            }
    
    # Test: file that does not exist
    answer = run_agent("Read the file /nonexistent/file.txt and tell me what is in it.")
    print(answer)  # Claude should acknowledge the failure, not hallucinate content

Relevant Axiom pages

What to do next

Back to Practice Lab