The Loop That Makes an Agent
A chatbot maps one input to one output. An agent runs a loop where the model itself decides which tools to call, in what order, until the task is done. The loop is ~20 lines; everything else in this module is guardrails around it.
In Module 1 you built one tool-use round trip. The jump to an agent is smaller than the hype suggests: you put that round trip inside a while loop and let the model keep going. The defining property is who chooses the control flow. In a chatbot (or a workflow), your code decides what happens next. In an agent, the model decides — which tool, which arguments, whether to keep digging or stop. Same API, radically different system behavior.
| Dimension | Chatbot | Agent |
|---|---|---|
| Control flow | One request → one response; your code owns every step | Model picks the next action each iteration; path emerges at runtime |
| Tool calls | Zero or one, hardcoded by you | Zero to many, sequenced by the model |
| Cost & latency | Predictable: one call | Variable: N calls, unknown N until it runs |
| Failure surface | Bad answer | Bad answer, infinite loops, runaway cost, wrong tool spirals |
| When it shines | The path is known in advance | The path can't be predetermined (research, debugging, open-ended tasks) |
The canonical loop
# Colab cell 1 — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or just paste it when prompted.
!pip install -q anthropic
import os
try:
from google.colab import userdata
os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
from getpass import getpass
os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"
# A tiny in-memory "notes database" so the tools do real work — no setup,
# no files. Swap this for a real store later; the loop below never changes.
NOTES = {
"n042": {"title": "Exponential backoff for retries",
"body": "On 429/5xx, retry with backoff: wait 2**attempt seconds + "
"jitter, cap at 60s, give up after 5 tries. Jitter stops "
"retries from synchronizing. Never retry 4xx except 429."},
"n107": {"title": "Caching layer design",
"body": "Read-through cache, 5-min TTL, key on the normalized query. "
"Invalidate on write. Beware the stampede when a hot key "
"expires — use a lock or serve-stale-while-revalidate."},
"n153": {"title": "Rate limiting",
"body": "Token bucket beats fixed window. Return 429 + Retry-After so "
"clients back off deterministically. Limit per API key, not IP."},
}
def search_notes(query: str) -> str:
words = [w for w in query.lower().split() if len(w) > 2]
hits = [f"{nid}: {n['title']}" for nid, n in NOTES.items()
if any(w in (n["title"] + " " + n["body"]).lower() for w in words)]
return "\n".join(hits[:5]) if hits else f"No notes matched {query!r}."
def read_note(note_id: str) -> str:
n = NOTES.get(note_id)
return f"{n['title']}\n\n{n['body']}" if n else f"No note with id {note_id!r}."
IMPL = {"search_notes": search_notes, "read_note": read_note}
TOOLS = [
{
"name": "search_notes",
"description": (
"Search the local notes database for a keyword. Use whenever the "
"user asks about anything that might live in their notes. "
"Returns up to 5 matching snippets with note ids."
),
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
{
"name": "read_note",
"description": "Read one note in full, by id from search_notes results.",
"input_schema": {
"type": "object",
"properties": {"note_id": {"type": "string"}},
"required": ["note_id"],
},
},
]
def run_agent(question: str, max_iterations: int = 10) -> str:
messages = [{"role": "user", "content": question}]
for _ in range(max_iterations):
resp = client.messages.create(
model=MODEL, max_tokens=2048,
tools=TOOLS, messages=messages,
)
if resp.stop_reason != "tool_use":
return resp.content[0].text # model chose to stop
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type == "tool_use":
output = IMPL[block.name](**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
raise RuntimeError("max iterations exceeded") # we'll fix this in lesson 4
print(run_agent("What did I write about backoff?"))run_agent is one-time setup — the key, a fake notes DB, and the tool implementations — so the cell actually runs in Colab. The agent is only the loop. Read its body slowly: it's the same four-step dance from Module 1, just repeated, and notice what's absent — no if/else deciding whether to search first or read first. The model sequences the tools itself by reading the schemas and the accumulating results. The for instead of while True is your first guardrail; raising on exhaustion is bad manners we'll replace with graceful degradation in lesson 4.while not done: response = llm(messages + tools); if tool_calls: execute, append results; else: done. Everything else in agent engineering is guardrails around this loop — termination, budgets, context discipline, tracing, recovery. When a framework shows you an 'AgentExecutor', this loop is what's inside.AttributeError: 'ThinkingBlock' object has no attribute 'text'. Where's the latent bug?if resp.stop_reason != "tool_use":
return resp.content[0].text # model chose to stopThe inner loop lives inside an outer conversation
A distinction that sounds pedantic until an interviewer probes it: the agent loop runs entirely within one user turn. The user asks a question; your loop makes N model calls (each a full stateless request!); the user sees one answer. When they ask a follow-up, you append it to the same messages array — tool calls, results, and all — and the inner loop starts again with that history as context. Two design consequences: the follow-up turn inherits every token of the previous turn's tool spelunking (context cost compounds across user turns, which is why Lesson 5's compaction exists), and your termination budgets (Lesson 4) should be per user turn, not per conversation — a fresh question deserves a fresh budget.
Watch the path emerge
# Colab cell 2 — run the setup cell above first (it defines client,
# MODEL, TOOLS, IMPL). This just adds print() calls to the same loop.
def run_with_trace(question: str, max_iterations: int = 10) -> str:
messages = [{"role": "user", "content": question}]
for i in range(max_iterations):
resp = client.messages.create(
model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages,
)
if resp.stop_reason != "tool_use":
print(f"[{i}] final answer after {i} tool iterations")
return resp.content[0].text
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type == "tool_use":
print(f"[{i}] model chose: {block.name}({block.input})")
output = IMPL[block.name](**block.input)
print(f"[{i}] -> {len(output)} chars back")
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
raise RuntimeError("max iterations exceeded")
print(run_with_trace("What did I write about backoff?"))
print("---")
print(run_with_trace("Summarize my notes on rate limits and caching"))
# A typical run prints something like:
# Run 1: "What did I write about backoff?"
# [0] model chose: search_notes({'query': 'backoff'})
# [1] model chose: read_note({'note_id': 'n042'})
# [2] final answer after 2 tool iterations
#
# Run 2: "Summarize my notes on rate limits AND caching"
# [0] model chose: search_notes({'query': 'rate limits'})
# [0] model chose: search_notes({'query': 'caching'}) # parallel!
# [1] model chose: read_note({'note_id': 'n153'})
# [1] model chose: read_note({'note_id': 'n107'})
# [2] final answer after 2 tool iterationssearch_notes returns an empty list. Predict the plausible trajectories through the loop, from best to worst.Whiteboard drills
search_notes calls in one turn, then two read_note calls the next turn. An interviewer asks: "why didn't it issue all four at once, and what does that tell you about parallelism in agent loops?"- ▸Agent = LLM + tools + loop, with the model choosing the path. Chatbot/workflow = your code chooses.
- ▸The loop is: call model → if
tool_use, execute and append results → repeat → else return the text. - ▸The model can emit several tool calls per turn — answer all of them; they're safe to parallelize. Cross-turn sequencing is the model's data-dependency discovery — don't fight it.
- ▸Extract the final answer by block type, never by position —
content[0].textbreaks the day thinking blocks appear. - ▸Empty tool results invite hallucination — return what does exist, not
[]. - ▸The agent loop runs inside one user turn; budgets are per-turn, and context inherited across turns is why compaction exists.
- ▸Flexibility costs you: unknown iteration count means unknown cost, latency, and new failure modes.
- ▸Everything that follows in this module is guardrails bolted onto this one loop.