Claude Academy
Sign in

Project 06 — A Tool-Use Loop (Weather Agent)

Enforces: the full tool-use loop — define a schema, run model → tool_use → execute → tool_result → final answer; then a second tool and parallel calls (from building-with-the-claude-api) Surface: code · Time: ~75 min · Difficulty: 🟡 core

Why this project

Tool use is the foundation of every agent. The loop is always the same four beats — the model emits a tool_use block, you execute it, you feed back a matching tool_result, the model uses it to answer. Build this loop by hand once and every framework, MCP server, and subagent afterward is recognizable.

What you'll build

A console agent with a get_weather tool that runs the loop to completion, then a second get_calendar tool, and a prompt that triggers parallel tool calls in a single turn.

Steps

  1. Define the tool schema — name, description, and a JSON Schema for inputs. The description is how the model decides when to call it, so be prescriptive:
    tools = [{
        "name": "get_weather",
        "description": "Get the current weather for a city. Call this whenever the user asks about weather or temperature.",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {"type": "string", "description": "City name, e.g. 'Paris'"},
                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["location"],
        },
    }]
    
  2. Write the executor — a plain function the loop calls. Stub the data; the lesson is the loop, not a real API:
    def get_weather(location, unit="celsius"):
        return f"18°{'C' if unit == 'celsius' else 'F'} and clear in {location}"
    
  3. Run the loop — call the model; while it stops with tool_use, execute each requested tool and feed results back as a single user message of tool_result blocks. The tool_use_id on each result must match its tool_use block:
    messages = [{"role": "user", "content": "What's the weather in Paris?"}]
    while True:
        resp = client.messages.create(
            model="claude-opus-4-8", max_tokens=1024,
            tools=tools, messages=messages,
        )
        if resp.stop_reason != "tool_use":
            break
        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                out = get_weather(**block.input)  # dispatch on block.name in real code
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": out,
                })
        messages.append({"role": "user", "content": results})
    print(next(b.text for b in resp.content if b.type == "text"))
    
  4. Add a second tool — define get_calendar(date) and add it to tools. Dispatch by block.name so the loop handles either tool. Ask a question that needs both ("Should I bring an umbrella to my 3pm meeting tomorrow?").
  5. Trigger parallel tool calls — ask something that needs two independent lookups at once ("Compare the weather in Paris and Tokyo"). The model may return multiple tool_use blocks in one assistant message. Execute them all and return all tool_result blocks in a single user message — splitting them across messages trains the model to stop parallelizing.
  6. Handle a tool error — make one call fail and return its tool_result with "is_error": True and a helpful message. Confirm the model recovers instead of crashing.

Acceptance criteria — you're done when

  • get_weather has a clear schema and the loop runs tool_use → execute → tool_result → final answer.
  • Every tool_result carries the matching tool_use_id, and the assistant turn is appended before the results.
  • You added get_calendar and the loop dispatches by block.name.
  • A two-city prompt produces parallel tool_use blocks, all results returned in one user message.
  • A failing tool returns is_error: true and the model recovers gracefully.
  • The loop exits on stop_reason == "end_turn" and prints the final text.
  • You journaled the loop's four beats in your own words in learning-journal-template.

Stretch goals

  • Add a max_iterations guard so a misbehaving loop can't run forever.
  • Swap your hand-written loop for the SDK tool runner (@beta_tool + client.beta.messages.tool_runner) and compare.
  • Add tool_choice={"type": "tool", "name": "get_weather"} to force a call and observe the difference.

Self-assessment rubric

LevelSignal
🟢 Got itYou can write the loop from scratch and explain why results go back in one user message.
🟡 AlmostThe single-tool loop works but parallel calls or tool_use_id matching trip you up.
🔴 RevisitThe loop hangs or errors on tool_result; re-watch the tool-use module of building-with-the-claude-api.

See also