Objective
Architecture first
Before any code: here's the whole loop you're building.
task ──▶ [ messages ] ──▶ model.create(tools=TOOLS)
│
┌────────────┴────────────┐
│ │
stop_reason == stop_reason ==
"tool_use" "end_turn"
│ │
run_tool(name, input) return final text
│
append tool_result to messages
│
└──────▶ back to model.create (loop)
Every box in that diagram is code you already wrote in Lessons 1-2, except the loop itself and the max-iterations guard — that's genuinely all that's new today.
Why this is called "ReAct"
The pattern (reason about what to do, act by calling a tool, observe the result, repeat) comes from a 2022 paper called ReAct — "Reasoning and Acting." It predates most of the current agent-framework hype by two years. Frameworks like LangGraph didn't invent this loop, they just wrapped it in more abstraction. You're implementing the actual idea, not a framework's opinion about it.
The part everyone forgets: max iterations
messages = [{"role": "user", "content": "Keep counting up by 1 forever, one number per response."}]
for i in range(1000):
response = client.messages.create(model=MODEL, max_tokens=100, messages=messages)
print(response.content[0].text)
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": "continue"})
Nothing in this loop tells it to stop. The same is true of a tool-calling loop with an ambiguous task: without an explicit max_iterations cap, a confused agent will happily call tools forever, burning tokens and API cost with no natural end. This is the single most common bug new agent builders ship — a working loop with no exit condition beyond "the model eventually stops on its own," which it will not reliably do.
Routing the tool call
The mechanical part: when stop_reason == "tool_use", walk response.content, find the block with type == "tool_use", call run_tool(block.name, block.input) from Lesson 2's module, and send the result back as a tool_result content block — matched to the original call by tool_use_id, not by position. If the model requests two tools in one turn (it can), you must return a tool_result for every tool_use_id in that response, in one message, or the next API call will error.
Your agent calls web_search and run_python in the same response (two tool_use blocks). You send back a tool_result for only web_search and continue the loop. What happens?
The API enforces this: every tool_use block in an assistant turn needs a matching tool_result before the conversation can continue, or the request is rejected. This is exactly the kind of failure "it worked in my single-tool test" hides — always test with a task that plausibly triggers two simultaneous tool calls before you consider this loop done.
What you're building
The actual agent loop: takes a task string, runs the reason → act → observe cycle against your 4 tools, prints each step as it happens (you want to see the reasoning, not just the final answer), and hard-stops at a configurable max_iterations with a clear message if it hits the cap.