The Agentic Loop — think / act / observe with the Anthropic SDK
Concept covered. An agent is a Claude instance wrapped in a loop that calls tools, observes results, and decides the next action until a stop condition fires. Every loop needs a termination condition.
Source: Domain 1 · The Agentic Loop mental model
Each turn is one client.messages.create() call. The stop_reason is the gate — tool_use sends you back around the loop, end_turn exits. The outer MAX_ITERATIONS bound is your last-resort seatbelt against runaway spend.
Record your spend baseline
Open console.anthropic.com/usage, filter to today, and note the current dollar amount (or screenshot the chart). You'll check the delta at the end of this tutorial so you know exactly what this one exercise cost.
Setup
≈ 2 minUbuntu / Debian users: the stdlib venv module needs a one-time package install — run this once, then skip it for every later tutorial:
sudo apt update && sudo apt install -y python3 python3-venv python3-pip
Now create the project directory and a fresh virtualenv. On macOS and most Linux distros, python3 is the canonical binary — python on its own is not reliable across systems.
mkdir agentic-loop && cd agentic-loop python3 -m venv .venv && source .venv/bin/activate pip install "anthropic>=0.40"
Create loop.py:
Lines with a coloured left-stripe are Claude API / agentic-loop code (CCA-F exam content). Unmarked lines are application plumbing you'd swap out for your own logic.
import anthropic, json, os client = anthropic.Anthropic() MODEL = "claude-sonnet-4-6" MAX_ITERATIONS = 5 tools = [{ "name": "get_weather", "description": "Return the current temperature in Celsius for a given city.", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, }] def run_tool(name, args): if name == "get_weather": fake = {"Paris": 14, "Tokyo": 22, "Cape Town": 27} return {"temp_c": fake.get(args["city"], 20)} raise ValueError(f"unknown tool {name}") messages = [{"role": "user", "content": "Compare the weather in Paris and Tokyo right now."}] for turn in range(MAX_ITERATIONS): resp = client.messages.create( model=MODEL, max_tokens=1024, tools=tools, messages=messages ) print(f"--- turn {turn} · stop_reason={resp.stop_reason} ---") messages.append({"role": "assistant", "content": resp.content}) if resp.stop_reason == "end_turn": print("FINAL:", resp.content[-1].text) break tool_results = [] for block in resp.content: if block.type == "tool_use": result = run_tool(block.name, block.input) print(f" → {block.name}({block.input}) = {result}") tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result), }) messages.append({"role": "user", "content": tool_results}) else: print("!! loop exhausted without end_turn — safety cap hit")
Code trace — what happens when you run it
≈ 5 min read
Before you run the script, walk through it in your head. The user asks "Compare the weather in Paris and Tokyo right now." A single get_weather tool is available. The model cannot answer without calling it — twice, once per city — and this is how the three-turn conversation unfolds.
Before the loop
anthropic.Anthropic()readsANTHROPIC_API_KEYfrom env and constructs an HTTP client. No network call yet.MAX_ITERATIONS = 5is the termination cap — the single most important line in the file. Without it,for turn in range(MAX_ITERATIONS)becomeswhile True:and your bill grows without bound. Tutorial 01's build-and-break exercise deletes exactly this line on purpose.tools = [...]is the catalogue the model sees. Three fields matter:name(identifier the model emits),description(free text it reads to decide when to call), andinput_schema(JSON Schema enforced server-side on the argument payload).run_tool(...)is a pure-Python dispatcher. The model never executes this — you do. The model only asks you to.messagesstarts with a single user turn. Everything below gets appended to it; state lives in your Python process, not on Anthropic's side.
-
Turn 0 — "Think". Model requests the Paris tool call.
client.messages.create(...)is HTTP call #1. The model reads the user prompt plus the tool catalogue, decides it cannot answer directly, and returns:responseCCAFstop_reason = "tool_use" content = [ ToolUseBlock(id='toolu_01A…', name='get_weather', input={'city': 'Paris'}) ]The loop appends the assistant turn, then — because
stop_reasonis notend_turn— falls through into the tool-dispatch block. "Act" and "observe" happen in the same Python iteration:run_toolruns, atool_resultblock is built, and it's appended as a new user message.Two subtle things.tool_use_idmust match theidthe model gave you — that's the correlation key. Andcontenthas to be a string (or a list of content blocks) — passing a raw dict here is the BadRequestError readers hit first. -
Turn 1 — model now knows Paris, still needs Tokyo.
Same
create()call, butmessagesnow carries the Paristool_result. The model returns anotherToolUseBlock(city='Tokyo'). Loop dispatches it, appends the Tokyo result, moves on.Parallel tool use. Sonnet sometimes returns both tool-use blocks in turn 0 as an optimisation. Thefor block in resp.contentloop handles that identically — it just dispatches both before the nextcreate()call, and turn 1 never happens. -
Turn 2 — "End". Model has both temperatures, composes the answer.
HTTP call #3. The conversation history now contains both tool results. No tool is needed. The model returns:
responseCCAFstop_reason = "end_turn" content = [ TextBlock(text='Paris is currently 14 °C while Tokyo is 22 °C, so Tokyo is 8 °C warmer…') ]Now the
if resp.stop_reason == "end_turn":branch fires.resp.content[-1].textgrabs the prose answer (the last block, because earlier blocks might be thinking text), prints it, andbreakexits the loop entirely — meaning thefor/elseclause is not executed.elseon aforonly fires when the loop exhausts withoutbreak.Why it matters:end_turnis the healthy exit. Iteration-cap exit (theelsebranch) means something went wrong.
The final messages ledger
When the loop breaks, messages is a 6-element record of the whole conversation:
[0] user "Compare the weather in Paris and Tokyo right now."
[1] assistant [ToolUseBlock(name='get_weather', input={'city': 'Paris'})]
[2] user [tool_result(Paris → {'temp_c': 14})]
[3] assistant [ToolUseBlock(name='get_weather', input={'city': 'Tokyo'})]
[4] user [tool_result(Tokyo → {'temp_c': 22})]
[5] assistant [TextBlock("Paris is 14 °C while Tokyo is 22 °C…")]
Two things worth internalising from this shape:
- The model has no memory between calls. Every
create()re-sends the fullmessageslist. That's why the loop's job is really just to accrete history. - Tool results ride inside user messages, not a third role. From the model's perspective, "you told me something" and "the tool told me something" are both inputs from the user role.
The agentic loop is a three-state machine — think (the create() call), act (your run_tool), observe (appending a tool_result back into messages) — that terminates on stop_reason == "end_turn", an iteration cap, or an error. Every line in loop.py exists to implement one of those three states.
Walkthrough
≈ 15 min-
Run the loop once.
bash
python loop.py
Expected: two turns with
stop_reason=tool_use(one per city), then a turn withstop_reason=end_turnand a prose answer.Why it matters: You can see the Think → Act → Observe cycle explicitly — the API never hides it. -
Inspect the first content block each turn.
Inside the
for turnloop, right after the turn-headerprint, add one line. The full file now looks like this (the new line is highlighted):python · loop.pyimport anthropic, json, os client = anthropic.Anthropic() MODEL = "claude-sonnet-4-6" MAX_ITERATIONS = 5 tools = [{ "name": "get_weather", "description": "Return the current temperature in Celsius for a given city.", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, }] def run_tool(name, args): if name == "get_weather": fake = {"Paris": 14, "Tokyo": 22, "Cape Town": 27} return {"temp_c": fake.get(args["city"], 20)} raise ValueError(f"unknown tool {name}") messages = [{"role": "user", "content": "Compare the weather in Paris and Tokyo right now."}] for turn in range(MAX_ITERATIONS): resp = client.messages.create( model=MODEL, max_tokens=1024, tools=tools, messages=messages ) print(f"--- turn {turn} · stop_reason={resp.stop_reason} ---") print(resp.content[0]) # ← added: inspect first content block messages.append({"role": "assistant", "content": resp.content}) if resp.stop_reason == "end_turn": print("FINAL:", resp.content[-1].text) break tool_results = [] for block in resp.content: if block.type == "tool_use": result = run_tool(block.name, block.input) print(f" → {block.name}({block.input}) = {result}") tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result), }) messages.append({"role": "user", "content": tool_results}) else: print("!! loop exhausted without end_turn — safety cap hit")
Re-run the script. On turns where
stop_reason=tool_useyou'll see a structured block —ToolUseBlock(id='toolu_…', input={'city': 'Paris'}, name='get_weather', type='tool_use')— and on the finalend_turnturn you'll see aTextBlock(text='…', type='text')instead.Why it matters: Tool use is a structured response block, not parsed text. The block type differs by turn, and your loop depends on that difference — this is the contract you're coding against. -
Trace the message list after run completes.
Append one line to the bottom of
loop.py, after thefor/elseblock. Full file (both instrumentation lines from steps 2 and 3 are highlighted):python · loop.pyimport anthropic, json, os client = anthropic.Anthropic() MODEL = "claude-sonnet-4-6" MAX_ITERATIONS = 5 tools = [{ "name": "get_weather", "description": "Return the current temperature in Celsius for a given city.", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, }] def run_tool(name, args): if name == "get_weather": fake = {"Paris": 14, "Tokyo": 22, "Cape Town": 27} return {"temp_c": fake.get(args["city"], 20)} raise ValueError(f"unknown tool {name}") messages = [{"role": "user", "content": "Compare the weather in Paris and Tokyo right now."}] for turn in range(MAX_ITERATIONS): resp = client.messages.create( model=MODEL, max_tokens=1024, tools=tools, messages=messages ) print(f"--- turn {turn} · stop_reason={resp.stop_reason} ---") print(resp.content[0]) # ← added in step 2: inspect first content block messages.append({"role": "assistant", "content": resp.content}) if resp.stop_reason == "end_turn": print("FINAL:", resp.content[-1].text) break tool_results = [] for block in resp.content: if block.type == "tool_use": result = run_tool(block.name, block.input) print(f" → {block.name}({block.input}) = {result}") tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result), }) messages.append({"role": "user", "content": tool_results}) else: print("!! loop exhausted without end_turn — safety cap hit") print(json.dumps([{'role': m['role']} for m in messages], indent=2)) # ← added in step 3: trace message roles
Expected:
user → assistant → user → assistant → user → assistantwith tool_use / tool_result alternating inside the user messages.Why it matters: The loop builds the history — the model has no memory betweencreate()calls except what you pass.
Build-and-break exercise
≈ 10 minStarve the loop of iterations
Two small edits to loop.py: lower the iteration cap near the top, and widen the prompt to three cities so the model needs more than one tool-use turn. Full file with both edits highlighted — save over your existing loop.py to run the exercise:
import anthropic, json, os client = anthropic.Anthropic() MODEL = "claude-sonnet-4-6" MAX_ITERATIONS = 1 # ← was 5 — deliberately too low tools = [{ "name": "get_weather", "description": "Return the current temperature in Celsius for a given city.", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, }] def run_tool(name, args): if name == "get_weather": fake = {"Paris": 14, "Tokyo": 22, "Cape Town": 27} return {"temp_c": fake.get(args["city"], 20)} raise ValueError(f"unknown tool {name}") messages = [{"role": "user", "content": "Compare weather in Paris, Tokyo, and Cape Town."}] # ← was two cities for turn in range(MAX_ITERATIONS): resp = client.messages.create( model=MODEL, max_tokens=1024, tools=tools, messages=messages ) print(f"--- turn {turn} · stop_reason={resp.stop_reason} ---") messages.append({"role": "assistant", "content": resp.content}) if resp.stop_reason == "end_turn": print("FINAL:", resp.content[-1].text) break tool_results = [] for block in resp.content: if block.type == "tool_use": result = run_tool(block.name, block.input) print(f" → {block.name}({block.input}) = {result}") tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result), }) messages.append({"role": "user", "content": tool_results}) else: print("!! loop exhausted without end_turn — safety cap hit")
Expected failure: the loop exits via the else branch with !! loop exhausted without end_turn. The model wanted another turn; you cut it off.
Confirm: the printed stop_reason on the final turn is tool_use, not end_turn.
Revert: restore MAX_ITERATIONS = 5.
Verification checklist
- I can name the three states of the agentic loop without looking at the diagram.
- I can point to the line of code that is the termination condition.
- I've seen
stop_reasonflip fromtool_usetoend_turn. - I can explain why the model has no memory between
create()calls. - I've watched the loop exit because of the cap, not the model.
Cleanup
deactivate && rm -rf agentic-loop
Further exploration
- Add a
stop_sequences=["TASK_COMPLETE"]parameter and watch howstop_reasonchanges. - Wire in streaming (
client.messages.stream(...)) and see tool-use blocks arrive incrementally.
Check your spend delta
Refresh console.anthropic.com/usage. The difference from your baseline is what this tutorial actually cost on Sonnet 4.6 — add it to a running tally so you can compare against the ~$2–4 total budget for the whole workbook.
Optional in-code tally. Drop this line after each client.messages.create(…) call to see per-request token counts in your terminal:
print(f"[usage] in={resp.usage.input_tokens} out={resp.usage.output_tokens}")