Step 13 of 18
Agents
Build a bounded agent loop with tools, plan and manage context, add guardrails, and decide when a fixed workflow is better.
In the tool-use lesson you sent a request, ran the tool the model asked for, and sent the result back. An agent is that loop left running: the model decides which tool to call next, reads the result, and keeps going until it judges the task done. That autonomy is powerful for open-ended tasks and expensive and risky everywhere else. This lesson covers when an agent is the right design, how to build a robust loop, how to design tools and manage context for long runs, and the guardrails that keep an agent from running away with your budget or your data.
Workflows vs agents, and when not to build one
There is a spectrum of designs, and it pays to pick the simplest one that works.
- Single call: one prompt, one answer. Classification, extraction, rewriting.
- Workflow: your code decides the steps; the model fills each one. Chaining (outline, then draft, then edit), routing (classify a ticket, then use the prompt for that category), or fan-out (summarise 20 documents in parallel, then merge).
- Agent: the model decides the steps, choosing tools in a loop until done.
# A workflow: the control flow is ordinary code you can read and test.
def handle_ticket(ticket: str) -> str:
category = classify(ticket) # one call, fixed set of labels
if category == "refund":
order = lookup_order(ticket) # plain code, no model involved
return draft_reply(ticket, order, template="refund")
return draft_reply(ticket, None, template="general")Workflows are cheaper, faster, and predictable: you can unit-test each step. A one-label step like classify doesn't even need a generative model; a decision model returns a probability per label in tens of milliseconds. Agents earn their cost when you cannot write the steps down in advance: debugging an unfamiliar codebase, researching a question across many sources, or handling requests where each answer changes what to do next.
Do not build an agent when:
- The steps are known. Encode them as a workflow.
- A user is waiting on a tight latency budget. Each loop turn is a full model call.
- Mistakes are costly and nobody reviews the result.
- Deterministic code can do it. A model that calls
sumfive times is a slow calculator.
The agent loop
The loop is short. The details that make it robust are what matter: a turn limit, checking stop_reason on every turn, sending all tool results back in one message, and turning tool failures into error results rather than crashes.
Here is a read-only research agent that answers questions about a folder of files. First, the tools:
from pathlib import Path
ROOT = Path("./project").resolve()
def safe_path(rel: str) -> Path:
p = (ROOT / rel).resolve()
if not p.is_relative_to(ROOT):
raise ValueError(f"{rel!r} is outside the project")
return p
def list_files(directory: str) -> str:
files = sorted(str(p.relative_to(ROOT)) for p in safe_path(directory).rglob("*") if p.is_file())
return "\n".join(files[:200]) or "(no files)"
def search(query: str) -> str:
hits = []
for p in sorted(ROOT.rglob("*")):
if not p.is_file():
continue
try:
lines = p.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
continue
for n, line in enumerate(lines, start=1):
if query.lower() in line.lower():
hits.append(f"{p.relative_to(ROOT)}:{n}: {line.strip()[:200]}")
if len(hits) == 20:
return "\n".join(hits) + "\n(truncated at 20 matches)"
return "\n".join(hits) or "no matches"
def read_file(path: str) -> str:
lines = safe_path(path).read_text(encoding="utf-8").splitlines()
more = f"\n... ({len(lines) - 400} more lines)" if len(lines) > 400 else ""
return "\n".join(lines[:400]) + more
HANDLERS = {"list_files": list_files, "search": search, "read_file": read_file}
def tool(name: str, description: str, param: str, param_desc: str) -> dict:
return {
"name": name,
"description": description,
"strict": True,
"input_schema": {
"type": "object",
"properties": {param: {"type": "string", "description": param_desc}},
"required": [param],
"additionalProperties": False,
},
}
TOOLS = [
tool("list_files", "List files under a directory. Call this first to see what exists.",
"directory", "Path relative to the project root, e.g. '.' or 'src'"),
tool("search", "Case-insensitive text search across all files; returns up to 20 'path:line: text' matches. "
"Call this to find which files mention a name or term.", "query", "Literal text to find"),
tool("read_file", "Read the first 400 lines of a text file. Call this on files found by list_files or search.",
"path", "Path relative to the project root"),
]Then the loop:
import anthropic
client = anthropic.Anthropic()
SYSTEM = """You answer questions about the project in the provided tools' file system.
Work step by step: look around, search, then read the most promising files.
Stop as soon as you can answer. Cite files as path:line. If the files do not
contain the answer, say so instead of guessing."""
def run_agent(task: str, max_turns: int = 15) -> str:
messages = [{"role": "user", "content": task}]
for turn in range(max_turns):
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=16000,
system=SYSTEM,
tools=TOOLS,
messages=messages,
)
# Append the full content: it carries tool_use (and thinking) blocks the API needs back.
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "refusal":
return "The model declined this task."
if response.stop_reason == "max_tokens":
return "Stopped: the response was cut off (raise max_tokens)."
calls = [b for b in response.content if b.type == "tool_use"]
if not calls:
return "".join(b.text for b in response.content if b.type == "text")
results = []
for call in calls:
print(f"turn {turn}: {call.name}({call.input})")
try:
output = HANDLERS[call.name](**call.input)
results.append({"type": "tool_result", "tool_use_id": call.id, "content": output})
except Exception as exc: # report the failure to the model; let it adapt
results.append({"type": "tool_result", "tool_use_id": call.id,
"content": f"Error: {exc}", "is_error": True})
messages.append({"role": "user", "content": results}) # ALL results, ONE message
return f"Stopped: reached the {max_turns}-turn limit before finishing."
print(run_agent("Where is the request timeout configured, and what is its default?"))A typical run looks like this:
turn 0: search({'query': 'timeout'})
turn 1: read_file({'path': 'src/config.py'})
turn 1: read_file({'path': 'src/http_client.py'})
The request timeout is set by REQUEST_TIMEOUT_SECONDS in src/config.py:14,
defaulting to 30 seconds; src/http_client.py:22 passes it to every request.The diagram traces that run turn by turn. Compared with the single round trip in the tool-use lesson, look for three things: the model, not your code, picks each next tool; one turn can request several tools; and the loop ends either when a response has no tool calls or when one of your limits trips.
Note turn 1: the model requested two reads at once. Both results go back in a single user message; splitting them into separate messages breaks the expected turn structure. A truncated response (max_tokens) may contain a half-written tool input, so never execute tools from it.
Designing tools for an agent
An agent is only as good as its tools. The model sees nothing but each tool's name, description and schema, so treat them as an interface designed for a new colleague:
- Few, focused tools. Three tools that cover the task beat fifteen overlapping ones. Every extra tool is another choice to get wrong.
- Say when to call it, not just what it does: "Call this first to see what exists."
- Constrain inputs. Use
enumfor fixed choices,strict: Trueso arguments always match the schema, and validate anyway in code. - Return compact, useful output. Truncate long results and say so ("truncated at 20 matches"), so the model knows to narrow its search instead of assuming it saw everything.
- Make errors actionable.
"Error: 'src/confg.py' not found. Did you mean src/config.py?"lets the model recover; a stack trace wastes context. - Separate reading from acting. Read-only tools can run freely. Tools with side effects (write, send, delete, pay) deserve their own narrowly typed tools so your code can gate them.
Planning and managing context
Current Claude models think before acting by default (adaptive thinking) and interleave that reasoning between tool calls, so you rarely need elaborate planning prompts. What helps is stating the strategy and the stopping condition, as in SYSTEM above. For long tasks, give the agent a way to keep its plan visible: a update_plan tool that stores a checklist your code echoes back, or simply asking for a short plan in the first turn. The output_config={"effort": ...} parameter trades depth of reasoning against tokens and speed; lower it for simple tasks.
Context is the agent's working memory, and it fills up. Every turn re-sends the whole conversation, so a 20-turn run that reads ten files pays for those files on every subsequent turn. Control it:
- Truncate tool output at the source, as
read_filedoes. - Use prompt caching so the repeated prefix (tools, system prompt, earlier turns) is billed at a fraction of the price. The cost and latency lesson covers how.
- Delegate to sub-agents. For a subtask like "read these 12 files and summarise what each does", run a separate agent with a fresh context and return only its short summary to the main loop. The orchestrator stays focused and cheap.
- Summarise and restart very long sessions: ask for a summary of findings so far, then continue in a new conversation that starts from it.
Guardrails
An agent loop is a program that decides its own next step, so bound it like one:
| Guardrail | Why |
|---|---|
Turn limit (max_turns) | Stops infinite loops |
| Token or cost budget | Sum response.usage each turn; stop when over |
| Wall-clock timeout | A user-facing agent cannot run for 10 minutes |
| Repeated-call detection | Same tool with the same input three times means it is stuck |
| Human approval for side effects | Irreversible actions need a person |
| Sandboxing and least privilege | Limit what a confused or manipulated agent can reach |
| Full trace logging | You cannot debug what you did not record |
Approval belongs in your tool-execution code, outside the model's control:
import json
NEEDS_APPROVAL = {"write_file", "send_email"}
def execute(call) -> dict:
if call.name in NEEDS_APPROVAL:
print(f"Agent wants to run {call.name}:\n{json.dumps(call.input, indent=2)}")
if input("Allow? [y/N] ").strip().lower() != "y":
return {"type": "tool_result", "tool_use_id": call.id,
"content": "The user declined this action.", "is_error": True}
return {"type": "tool_result", "tool_use_id": call.id,
"content": HANDLERS[call.name](**call.input)}Show the user the exact arguments, not the model's description of them. A declined action becomes an ordinary tool result, so the agent can explain or try another approach.
Practice
Extend the research agent:
- Add a
write_note(path, text)tool that writes a Markdown file underROOT / "notes"only, and requires human approval. - Add a cost budget: stop with a message when the run exceeds 200,000 total input + output tokens.
- Add repeated-call detection: if the same tool is called with identical input three times, stop and return what the agent has so far.
- Ask it: "Summarise how configuration is loaded and save the summary to notes/config.md."
Show solution
import json
from collections import Counter
NOTES = (ROOT / "notes").resolve()
def write_note(path: str, text: str) -> str:
target = (NOTES / path).resolve()
if not target.is_relative_to(NOTES) or target.suffix != ".md":
raise ValueError("notes must be .md files inside notes/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
return f"wrote {len(text)} characters to notes/{path}"
HANDLERS["write_note"] = write_note
TOOLS.append({
"name": "write_note",
"description": "Save a Markdown note under notes/. Call only when the user asks you to save something.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File name inside notes/, e.g. 'config.md'"},
"text": {"type": "string", "description": "Full Markdown content"},
},
"required": ["path", "text"],
"additionalProperties": False,
},
})
NEEDS_APPROVAL = {"write_note"}
def run_agent(task: str, max_turns: int = 15, token_budget: int = 200_000) -> str:
messages = [{"role": "user", "content": task}]
used = 0
seen = Counter()
for _ in range(max_turns):
response = client.messages.create(
model="claude-sonnet-5-5", max_tokens=16000,
system=SYSTEM, tools=TOOLS, messages=messages,
)
used += response.usage.input_tokens + response.usage.output_tokens
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason in ("refusal", "max_tokens"):
return f"Stopped: {response.stop_reason}"
calls = [b for b in response.content if b.type == "tool_use"]
if not calls:
return "".join(b.text for b in response.content if b.type == "text")
if used > token_budget:
return f"Stopped: token budget exceeded ({used:,} tokens)."
results = []
for call in calls:
key = (call.name, json.dumps(call.input, sort_keys=True))
seen[key] += 1
if seen[key] >= 3:
return f"Stopped: {call.name} repeated with the same input; agent appears stuck."
try:
results.append(execute(call))
except Exception as exc:
results.append({"type": "tool_result", "tool_use_id": call.id,
"content": f"Error: {exc}", "is_error": True})
messages.append({"role": "user", "content": results})
return "Stopped: turn limit reached."The budget check sits after the assistant turn is recorded, and every early return happens before tools run, so nothing executes after a limit trips. write_note validates its own path even though the model was told the rule: the tool enforces it, the prompt only describes it. When prompted to save, you see the full note text before approving.
Key takeaways
- An agent is a model choosing tools in a loop; use one only when the steps cannot be written down in advance, and prefer workflows otherwise.
- A robust loop has a turn limit, checks
stop_reasonevery turn (refusal,max_tokens), returns all tool results in one user message, and reports tool failures asis_errorresults. - Design few, well-described, strictly typed tools that return compact, actionable output; keep side-effecting actions in separate tools.
- Manage context by truncating results, caching the prefix, and delegating subtasks to sub-agents with fresh contexts.
- Bound every agent with budgets, timeouts, loop detection, sandboxing and human approval for irreversible actions, and evaluate it on end-state outcomes.
Check yourself
Quiz
0/4 correct · 4 left
1.You need to summarise each incoming support ticket and tag it with one of five categories. Which design fits best?
2.The model's response contains three tool_use blocks. How do you send the results back?
3.A tool call raises FileNotFoundError. What should the loop do?
4.Where should the human-approval check for a send_email tool live?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
- The tool-use loopThe model asks for a tool call, your code runs it and sends the result back, and the loop repeats until it answers.
- Prompt cachingReuse the model’s work on an unchanged prompt prefix for a fraction of the price and latency.
- Decision modelsJev, Clef and similar models return a probability for each allowed answer instead of text — fast routing, scoring and yes/no gates.
Finished reading?
Mark it complete to fill in your roadmap.