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
- 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"], }, }] - 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}" - 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 oftool_resultblocks. Thetool_use_idon each result must match itstool_useblock: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")) - Add a second tool — define
get_calendar(date)and add it totools. Dispatch byblock.nameso the loop handles either tool. Ask a question that needs both ("Should I bring an umbrella to my 3pm meeting tomorrow?"). - 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_useblocks in one assistant message. Execute them all and return alltool_resultblocks in a single user message — splitting them across messages trains the model to stop parallelizing. - Handle a tool error — make one call fail and return its
tool_resultwith"is_error": Trueand a helpful message. Confirm the model recovers instead of crashing.
Acceptance criteria — you're done when
-
get_weatherhas a clear schema and the loop runstool_use→ execute →tool_result→ final answer. - Every
tool_resultcarries the matchingtool_use_id, and the assistant turn is appended before the results. - You added
get_calendarand the loop dispatches byblock.name. - A two-city prompt produces parallel
tool_useblocks, all results returned in one user message. - A failing tool returns
is_error: trueand 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_iterationsguard 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
| Level | Signal |
|---|---|
| 🟢 Got it | You can write the loop from scratch and explain why results go back in one user message. |
| 🟡 Almost | The single-tool loop works but parallel calls or tool_use_id matching trip you up. |
| 🔴 Revisit | The loop hangs or errors on tool_result; re-watch the tool-use module of building-with-the-claude-api. |
See also
- Course: building-with-the-claude-api
- Next project: p07-structured-extraction
- Deeper: introduction-to-mcp