Lab 02 — Build an agent loop from scratch
Lesson 02 · Building Your Own Agents · hands-on, ~30 min
Goal: by the end of this lab you will have a single-file agent.py (~80 lines) that
accepts a user question on the command line, calls Claude with two mock tools, executes those
tools in-process, enforces all four stop conditions (natural exit, turn cap,
cost cap, KeyboardInterrupt), and prints the final answer plus a cost summary.
There is no notebook for this lab — the source of truth is this page. Copy the program below
into your own agent.py and run it.
Prereqs
- Python 3.10 or newer, in a virtual environment of your choice.
- Install the course requirements:
The only package actually exercised in this lab ispip install -r agents/requirements.txtanthropic(0.39.x). The rest ofrequirements.txtpins the framework SDKs for later modules — they are unused here. - Set your API key in the shell you will run the script from:
If you prefer aexport ANTHROPIC_API_KEY=sk-ant-....envfile, load it withpython-dotenvbefore running the script. The snippet below reads the key straight from the environment, so any mechanism that populatesos.environis fine. - A terminal where you can run
python agent.py "...".
Step 1 — Create the file
Create agent.py anywhere on your machine — it does not have to live
inside the course repo. This is your file, treat it as a scratchpad you can edit freely. A common
choice is a sibling directory next to the course checkout, e.g.
~/code/agents-lab/agent.py.
Step 2 — Paste the full program
Below is the complete program. Copy the entire block into agent.py. It is roughly
80 lines of executable code (plus comments and blank lines) and uses nothing beyond the
anthropic SDK and the Python standard library.
"""Hand-rolled agent loop — Lesson 02 of the Agents course.
Run:
export ANTHROPIC_API_KEY=...
python agent.py "What's the weather in Kyiv? Should I bring a jacket?"
"""
from __future__ import annotations
import os
import sys
import time
import anthropic
MODEL = "claude-3-5-sonnet-latest"
MAX_TURNS = 10
MAX_COST_USD = 0.10
# Sonnet pricing (2026 Q2): $3 / 1M input, $15 / 1M output.
INPUT_USD_PER_M = 3.0
OUTPUT_USD_PER_M = 15.0
SYSTEM = (
"You are a helpful assistant with access to two tools: get_weather "
"and get_clothing_advice. Use them to answer the user's question, "
"then give a one-sentence final answer."
)
# --- Tool implementations ------------------------------------------------
def get_weather(city: str) -> str:
# Mock: a real implementation would call a weather API.
return f"{city}: 4°C, light rain"
def get_clothing_advice(temp_c: int, condition: str) -> str:
if temp_c < 10:
return "Wear a warm jacket." + (" Bring an umbrella." if "rain" in condition.lower() else "")
return "Light layers should be fine."
TOOL_FUNCS = {
"get_weather": get_weather,
"get_clothing_advice": get_clothing_advice,
}
TOOLS = [
{
"name": "get_weather",
"description": "Get current weather for a city as 'temp°C, conditions'.",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City name."}},
"required": ["city"],
},
},
{
"name": "get_clothing_advice",
"description": "Suggest clothing given temperature and conditions.",
"input_schema": {
"type": "object",
"properties": {
"temp_c": {"type": "integer"},
"condition": {"type": "string"},
},
"required": ["temp_c", "condition"],
},
},
]
# --- The loop ------------------------------------------------------------
def run_agent(user_question: str) -> tuple[str, float]:
client = anthropic.Anthropic()
messages: list[dict] = [{"role": "user", "content": user_question}]
total_in = total_out = 0
for turn in range(MAX_TURNS):
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
system=SYSTEM,
tools=TOOLS,
messages=messages,
)
total_in += resp.usage.input_tokens
total_out += resp.usage.output_tokens
cost = (total_in * INPUT_USD_PER_M + total_out * OUTPUT_USD_PER_M) / 1_000_000
if cost > MAX_COST_USD:
raise RuntimeError(f"cost cap exceeded after turn {turn}: ${cost:.4f}")
if resp.stop_reason == "end_turn":
text = "".join(b.text for b in resp.content if b.type == "text")
return text.strip(), cost
if resp.stop_reason != "tool_use":
raise RuntimeError(f"unexpected stop_reason: {resp.stop_reason}")
# Append the assistant turn verbatim, then run each tool and append its result.
messages.append({"role": "assistant", "content": resp.content})
tool_results = []
for block in resp.content:
if block.type != "tool_use":
continue
func = TOOL_FUNCS.get(block.name)
if func is None:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"unknown tool: {block.name}",
"is_error": True,
})
continue
try:
out = func(**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(out),
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"tool error: {e}",
"is_error": True,
})
messages.append({"role": "user", "content": tool_results})
raise RuntimeError(f"turn cap ({MAX_TURNS}) reached without end_turn")
# --- CLI -----------------------------------------------------------------
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("usage: python agent.py 'your question here'")
question = sys.argv[1]
try:
answer, cost_usd = run_agent(question)
except KeyboardInterrupt:
sys.exit("\ninterrupted by user")
print(answer)
print(f"\n[cost: ${cost_usd:.4f}]")
That is the whole program — about 80 lines of executable code plus comments.
Inline highlight.js renders it with syntax colors on this page so you can read it
before you paste, but the file on disk is plain Python.
Step 3 — Run it
python agent.py "What's the weather in Kyiv? Should I bring a jacket?"
Expected: the agent runs 2 turns — one to call the tools, one to summarise. Output ends with something like:
It's 4°C with light rain in Kyiv — wear a warm jacket and bring an umbrella.
[cost: $0.0042]
Exact wording and exact cost vary turn-to-turn; what matters is that the loop terminates with
stop_reason == "end_turn" and prints a sensible one-sentence answer followed by
a cost line.
Step 4 — Walk through each section of the file
A guided tour of the program, matching the slides:
- Constants.
MODEL,MAX_TURNS,MAX_COST_USD, and the per-million pricing knobs. These are the stop-condition knobs — change them and the loop's safety boundaries change with no other edits. - System prompt. Short, names both tools explicitly, and instructs the model to give a one-sentence final answer. Models will often refuse to tool-call unless the system prompt acknowledges the tools by name, so this is load-bearing.
- Tool implementations. Two pure Python functions returning mock data. In a real app these would call a weather API or read from a database; here we keep them deterministic so the lab is reproducible.
- TOOL_FUNCS / TOOLS. The two-part registry: a name → function dict for
dispatch, and a list of
{name, description, input_schema}objects sent to the API. Notice the schema field names match the function signatures exactly — that's howfunc(**block.input)below works. run_agent. The loop itself. Walk through one full iteration of thefor turnbody line by line: API call → cost accumulation →stop_reasonbranch → append assistant content → per-block tool dispatch → appendtool_resultblocks as a singlerole: usermessage.- Branch on
stop_reason. Onlyend_turnandtool_useare valid. Anything else (max_tokens,stop_sequence, …) raises immediately — better to crash loudly than to silently desync the conversation. - Cost-cap check. Runs every turn, after the API call (so the cost is
already accurate) but before the branch. This means a single very expensive turn can still
push you over the cap and raise, even on a successful
end_turn. - Tool-error path.
try/exceptaroundfunc(**block.input). On failure we appendis_error: trueso the model sees the failure in the next turn and can recover (try different arguments, try a different tool, or give a final answer admitting it failed). Critically, we do not raise from the loop on a tool error — that's the model's problem to handle. - CLI wrapper. Reads
sys.argv[1]as the question and wrapsrun_agentin atry/except KeyboardInterruptso Ctrl-C is the fourth stop condition: any human reading this can yank the loop at any time.
Step 5 — Trigger the cost cap deliberately
Edit the constant near the top of the file:
MAX_COST_USD = 0.0001 # one-hundredth of a cent
Re-run the same command. Expected output:
RuntimeError: cost cap exceeded after turn 0: $0.001...
Even the very first response burns more than $0.0001 of tokens, so the cap fires on turn 0.
This is the cost stop condition in action. Restore MAX_COST_USD = 0.10 before moving on.
Step 6 — Trigger the tool-error path
Make get_weather blow up unconditionally:
def get_weather(city: str) -> str:
raise RuntimeError("API down")
Re-run. The loop will not crash — instead the LLM will receive a
tool_result block with is_error: true on the next turn. From there it
will either (a) try a different approach (maybe call get_clothing_advice with a
guessed temperature) or (b) give a final answer admitting it couldn't get the weather. Either
outcome is correct: it shows the model recovering from a tool failure on its own, exactly the
behaviour the is_error flag is designed to enable. Restore the working implementation
before moving on.
Step 7 — Trigger the turn cap
Now make get_weather never succeed and never error — it just keeps asking for more
information:
def get_weather(city: str) -> str:
return "need more details — call me again with the country code"
Re-run. The LLM will keep calling get_weather trying to satisfy the apparent contract,
and the loop will tick the turn counter up each iteration. After 10 turns:
RuntimeError: turn cap (10) reached without end_turn
This is the turn stop condition. It exists precisely for cases where a misbehaving tool (or a
confused model) would otherwise loop forever. Restore the original get_weather.
What you should leave with
- A working ~80-line
agent.pythat you can hand to a colleague and they can run it. - First-hand experience of all four stop conditions firing: natural
end_turn, cost cap, tool-error recovery (not technically a stop condition but it's the same control-flow family), turn cap, andKeyboardInterrupt(try Ctrl-C while a long answer streams back). - A feel for the content-block dance: assistant returns a list of blocks; the runtime appends
that list verbatim and then sends back a single
role: usermessage whose content is a list oftool_resultblocks, one pertool_useblock it just received.
Troubleshooting
Common failure modes
anthropic.AuthenticationError—ANTHROPIC_API_KEYis not set in the shell you ran the script from, or the value is invalid. Runecho $ANTHROPIC_API_KEYin the same terminal to confirm. If you use.env, make surepython-dotenvactually loaded beforeanthropic.Anthropic()is constructed.unexpected stop_reason: max_tokens— the last response hit themax_tokens=1024ceiling mid-generation. Either bump it (max_tokens=4096) or shorten the prompt / system message.- Loop runs once and returns garbage — usually the system prompt forgot to
mention the tools by name. Some models won't tool-call unless the system prompt acknowledges
them, even when
tools=...is set on the API call. Spell them out. tool_use_idmismatch error from the API — yourtool_resultblocks aren't ordered or matched correctly. Everytool_use_idyou send back must appear in the previous turn'sresp.content, and everytool_useblock from the assistant must be answered with atool_resulton the very next user turn. Missing or extra IDs both cause a 400.