Logbook · Sheet A-02 · Rev 1
A chef, not a recipe
How an agent really works, at the level of what goes over the wire, and how to build one with Anthropic's cookbooks, with an exercise you run in your own repo.
"We need to implement agents." How? "Automate that with an agent." How? The word is everywhere and the how is nowhere. Here is the how, at the level of the bytes on the wire, and an agent you will run on your own repo before you finish reading.
Who this is for: a developer who has already called an LLM API but has never built an agent. Technical first: the kitchen analogy holds each idea up in one or two sentences; the code, the message flow and the trade-offs lead.
The definition this article commits to: an agent is a model that calls tools in a loop until the job is done — and because those tools can change real data, the guardrails live in your code.
In short
agent = model + tools + loop, made safe by guardrails
The analogy in one line: the cookbooks are recipes. An agent is a chef: it tastes, decides the next step, and keeps going until the dish is right. The model is the chef's judgment, with no hands; your code is the kitchen porter that actually does what the chef asks.
Kitchen → technical term
- The recipe book Anthropic's cookbooks Notebooks at github.com/anthropics/claude-cookbooks
-
The chef's judgment
The model
claude-opus-5-5behindclient.messages.create -
A card that describes each station
A tool definition
name+description+input_schema(JSON Schema) -
The kitchen porter
Your code
Runs each
tool_useand returns atool_result -
Cook, taste, adjust
The agent loop
while stop_reason == "tool_use"… untilend_turn -
The counter
The context window
The
messagesarray you re-send on every call -
Clearing the counter, keeping the ticket
Compaction
context_managementsummarizes old turns -
House rules
System prompt
system= - The notebook on the shelf Memory Files the agent reads and writes across sessions
- A fixed recipe A workflow Steps your code decides in advance
- The expediter Routing One call classifies, code sends to a specialist
- Several cooks, tasters who vote Parallelization Independent calls at once; sectioning or voting
- The head chef splits the order into tickets Orchestrator-workers One call plans subtasks, workers run them
- The taster who sends the plate back Evaluator-optimizer Generate → grade → retry
-
Closing time
Max turns
max_turns/ a loop cap - Knives the apprentice may use Permissions Read-only tools, allow and deny lists
- The chef signs the plate Human in the loop Approval before any write
- The tasting panel Evals A fixed set of real questions, graded every change
The video
An animated video that tells this sheet.
How to read the labels
- Source: Anthropic Anthropic docs or engineering post; opens the cited page.
- Cookbook A notebook in github.com/anthropics/claude-cookbooks; opens the exact notebook.
- Our test We ran it: the offline tests and the installed SDK.
- Opinion Our judgement, not a fact.
- Illustrative Shows the expected shape; not a live run. Your ids and tokens will differ.
- Our arithmetic Our arithmetic on published prices.
What an agent is, in Anthropic's words
Anthropic draws one line that every other decision hangs on. Workflows «are systems where LLMs and tools are orchestrated through predefined code paths». Agents «dynamically direct their own processes and tool usage». The difference is who decides the next step: your code (a fixed recipe) or the model (a chef who tastes and decides).
Both start from the same building block, which Anthropic calls the augmented LLM: a model with retrieval, tools and memory around it. A single call to that block already solves most tasks.
Agent, not chatbot; agent, not training. A chatbot only produces text. An agent acts on real systems: it reads (look up an order, search a repo) and writes (reply to a customer, issue a refund, update a ticket). The writes make it useful and also make it dangerous, which is why the guardrails of section 5 are enforced in code, not only asked for in the prompt. And building an agent is not training a model: you take a model that is already trained and build the kitchen around it — tools, loop, rules. The exercise in section 7 starts read-only on purpose.
«are systems where LLMs and tools are orchestrated through predefined code paths»
«dynamically direct their own processes and tool usage»
When NOT to build an agent. Anthropic's advice is «finding the simplest solution possible». Agentic systems «trade latency and cost for better task performance», and their errors compound over many steps. Reach for an agent when:
- the number of steps cannot be known in advance (you cannot hard-code the path);
- the task gives feedback the model can read (tests, search results, file contents);
- you can afford more tokens and time, and you can put guardrails around it.
If one call or a fixed chain of calls does the job, write that instead. In the kitchen: a fixed recipe is cheaper and more predictable than a chef. You hire a chef when the dish changes every night.
The loop at wire level
The Claude API is stateless. An agent is nothing more than your code calling messages.create again and again, each time sending the whole conversation so far. In the kitchen, the counter (the context window) holds every plate, every ticket and every note; on each call you put the whole counter back in front of the chef.
The tool card
You describe each station with a name, a description and a JSON Schema for its input. The model only ever sees this card; it never sees your function. This is the read_file card from the exercise:
{
"name": "read_file",
"description": "Read a range of lines from one text file. Lines come back numbered as 'N| text' so you can cite them as path:N. At most 400 lines per call: read big files in ranges.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path relative to the repo root, e.g. 'src/app.py'."},
"start_line": {"type": "integer", "description": "First line to read, 1-based. Default 1."},
"end_line": {"type": "integer", "description": "Last line to read, inclusive. Default start_line + 399."}
},
"required": ["path"],
"additionalProperties": false
}
}The content blocks. A response's content is a list of blocks: text, thinking (Opus 5.5 thinks adaptively, always on) and tool_use. A tool_use block carries an id, the tool name and an input that follows the schema. Your code answers with a tool_result block whose tool_use_id is that same id, plus content and, when the call failed, is_error: true. Claude has no tool or function role: results travel inside a normal user message.
The stop_reason values
The stop_reason values decide what the loop does next:
-
tool_useWhat it means: Claude is calling one or more toolsWhat your loop does: Run them, send every result back, call again -
end_turnWhat it means: Claude finished its answerWhat your loop does: Return the text; the loop ends -
max_tokensWhat it means: The response hit yourmax_tokensWhat your loop does: Stop; a cut-offtool_usemust be retried with moremax_tokens -
pause_turnWhat it means: A server-tool loop hit its limitWhat your loop does: Send the response back as-is to continue -
refusalWhat it means: Claude declinedWhat your loop does: Stop and report it -
stop_sequenceWhat it means: One of your stop sequences appearedWhat your loop does: Handle as your app defines -
model_context_window_exceededWhat it means: The counter is fullWhat your loop does: Stop; compact or start narrower
The full round trip, step by step
A full round trip The question «Where is the config loaded?» against a small repo. The ids and the model's text are illustrative (shapes from the docs; no live API call was made for this article), but both tool results are the real output of the exercise's tools. Note the two parallel calls, the error result and the single user message that carries both results:
your code
your code → API
API → your code
"model": "claude-opus-5-5",
"max_tokens": 16000,
"system": "You are a read-only sous-chef for a code repository. ...",
"tools": [ /* the list_files, read_file and search cards */ ],{"role": "user", "content": "Where is the config loaded?"}-
{"role": "assistant", "content": [ /* the four blocks above, unchanged */ ]},{"type": "thinking", "thinking": "...", "signature": "..."},{"type": "text", "text": "I'll search for the loader and open the likely file."},{"type": "tool_use", "id": "toolu_01A09q90qw90lq917835lq9", "name": "search", "input": {"pattern": "def load_config"}},{"type": "tool_use", "id": "toolu_01B7xk2mJ3pZ8wQ4vN6rT1sY", "name": "read_file", "input": {"path": "config.py"}}
{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "toolu_01A09q90qw90lq917835lq9", "content": "src/config.py:3: def load_config():"}, {"type": "tool_result", "tool_use_id": "toolu_01B7xk2mJ3pZ8wQ4vN6rT1sY", "is_error": true, "content": "'config.py' does not exist. Call list_files on its parent folder, or search for its name."} ]}
Your code runs the tools
searchsrc/config.py:3: def load_config():read_file'config.py' does not exist. Call list_files on its parent folder, or search for its name.
{
"id": "msg_01Aq9w938a90dw8q",
"role": "assistant",
"stop_reason": "tool_use",
"content": [
{"type": "thinking", "thinking": "...", "signature": "..."},
{"type": "text", "text": "I'll search for the loader and open the likely file."},
{"type": "tool_use", "id": "toolu_01A09q90qw90lq917835lq9", "name": "search", "input": {"pattern": "def load_config"}},
{"type": "tool_use", "id": "toolu_01B7xk2mJ3pZ8wQ4vN6rT1sY", "name": "read_file", "input": {"path": "config.py"}}
]
}{
"role": "assistant",
"stop_reason": "end_turn",
"content": [
{"type": "text", "text": "The config is loaded by load_config(), defined at src/config.py:3."}
]
}Step 1 of 8
The question
The customer orders a dish.
The API keeps no state: the messages array is all the model will see.
stop_reason: no stop_reason
Step 2 of 8
The request, with tools
You put the whole counter in front of the chef, with each station's card.
The model only sees the cards (name, description, input_schema), never your function.
stop_reason: no stop_reason
Step 3 of 8
The response arrives
The chef asks for two stations at once.
Two tool_use blocks in one turn: parallel calls. stop_reason: "tool_use".
stop_reason: tool_use
Step 4 of 8
The turn, unchanged
The chef's ticket stays on the counter exactly as written.
Append the assistant turn unchanged, with its thinking block.
stop_reason: tool_use
Step 5 of 8
The tools run
The sous-chef runs both stations at once.
Your code runs every tool_use; a failure is data, not an exception.
stop_reason: tool_use
Step 6 of 8
One user message
The sous-chef puts both plates on the counter, together.
One tool_result per tool_use, all in the same user message, results first.
stop_reason: tool_use
Step 7 of 8
Everything is re-sent
The whole counter goes back in front of the chef.
Every call re-sends the whole history: input tokens grow, which is why caching matters.
stop_reason: no stop_reason
Step 8 of 8
end_turn: the loop returns
The chef calls the dish done.
stop_reason: "end_turn": the loop returns the text.
stop_reason: end_turn
-
Step 1 of 8
The question
The customer orders a dish.
The API keeps no state: the
messagesarray is all the model will see.stop_reason: no stop_reason
-
Step 2 of 8
The request, with
toolsYou put the whole counter in front of the chef, with each station's card.
The model only sees the cards (
name,description,input_schema), never your function.stop_reason: no stop_reason
-
Step 3 of 8
The response arrives
The chef asks for two stations at once.
Two
tool_useblocks in one turn: parallel calls.stop_reason: "tool_use".stop_reason:
tool_use -
Step 4 of 8
The turn, unchanged
The chef's ticket stays on the counter exactly as written.
Append the assistant turn unchanged, with its
thinkingblock.stop_reason:
tool_use -
Step 5 of 8
The tools run
The sous-chef runs both stations at once.
Your code runs every
tool_use; a failure is data, not an exception.stop_reason:
tool_use -
Step 6 of 8
One
usermessageThe sous-chef puts both plates on the counter, together.
One
tool_resultpertool_use, all in the sameusermessage, results first.stop_reason:
tool_use -
Step 7 of 8
Everything is re-sent
The whole counter goes back in front of the chef.
Every call re-sends the whole history: input tokens grow, which is why caching matters.
stop_reason: no stop_reason
-
Step 8 of 8
end_turn: the loop returnsThe chef calls the dish done.
stop_reason: "end_turn": the loop returns the text.stop_reason:
end_turn
See the article's full JSON
// 1. Request: your code -> API
{
"model": "claude-opus-5-5",
"max_tokens": 16000,
"system": "You are a read-only sous-chef for a code repository. ...",
"tools": [ /* the list_files, read_file and search cards */ ],
"messages": [
{"role": "user", "content": "Where is the config loaded?"}
]
}
// 2. Response: API -> your code. Two calls in one turn (parallel).
{
"id": "msg_01Aq9w938a90dw8q",
"role": "assistant",
"stop_reason": "tool_use",
"content": [
{"type": "thinking", "thinking": "...", "signature": "..."},
{"type": "text", "text": "I'll search for the loader and open the likely file."},
{"type": "tool_use", "id": "toolu_01A09q90qw90lq917835lq9", "name": "search", "input": {"pattern": "def load_config"}},
{"type": "tool_use", "id": "toolu_01B7xk2mJ3pZ8wQ4vN6rT1sY", "name": "read_file", "input": {"path": "config.py"}}
]
}
// 3. Request: the same history + that assistant turn, unchanged + ONE user message with both results
"messages": [
{"role": "user", "content": "Where is the config loaded?"},
{"role": "assistant", "content": [ /* the four blocks above, unchanged */ ]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "src/config.py:3: def load_config():"},
{"type": "tool_result", "tool_use_id": "toolu_01B7xk2mJ3pZ8wQ4vN6rT1sY", "is_error": true,
"content": "'config.py' does not exist. Call list_files on its parent folder, or search for its name."}
]}
]
// 4. Response: stop_reason "end_turn". The loop returns the text.
{
"role": "assistant",
"stop_reason": "end_turn",
"content": [
{"type": "text", "text": "The config is loaded by load_config(), defined at src/config.py:3."}
]
}Four rules from the docs that the round trip shows:
- One result per call, all in one message «return one
tool_resultfor eachtool_useblock, all together in the next user message». Splitting them across messages «"teaches" Claude to avoid parallel calls». - Results first In that user message «the
tool_resultblocks must come FIRST»; any text goes after them. - Send the assistant turn back unchanged Thinking blocks included.
- Errors are data, not exceptions
is_error: trueplus a message that says what to do instead. Claude «will retry 2–3 times with corrections», so the message is worth writing well.
Why the whole history every time: the model keeps no state between calls. That is the price (input tokens grow on every turn) and the reason prompt caching matters (section 5).
Source: Anthropic · overview Source: Anthropic · handle-tool-calls Source: Anthropic · parallel-tool-use Source: Anthropic · handling-stop-reasons Our test
The loop in ~40 lines, then the tool runner, then the Claude Agent SDK
Level 1: write the loop yourself
This is the core of the exercise's first-agent.py, without logging and parallelism, so the shape is visible. You own every byte that reaches the model:
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
def run_agent(question, sandbox, max_turns=12):
messages = [{"role": "user", "content": question}]
for turn in range(max_turns): # closing time
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
system=SYSTEM,
tools=TOOLS,
messages=messages, # the WHOLE history, every call
cache_control={"type": "ephemeral"}, # so the repeated prefix is cheap
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
return "".join(b.text for b in response.content if b.type == "text")
if response.stop_reason != "tool_use":
raise RuntimeError(f"stopped: {response.stop_reason}") # max_tokens, refusal...
results = []
for block in response.content:
if block.type != "tool_use":
continue
content, is_error = run_tool(sandbox, block.name, block.input)
result = {"type": "tool_result", "tool_use_id": block.id, "content": content}
if is_error:
result["is_error"] = True
results.append(result)
messages.append({"role": "user", "content": results}) # ONE message
raise RuntimeError(f"no answer after {max_turns} turns")The full file adds three things worth stealing: tools run in a thread pool when Claude calls several at once; one log line per turn with input_tokens, output_tokens and cache_read_input_tokens; and pause_turn handling (only server tools pause; the fix is to send the turn back as-is).
You own: every byte that reaches the model
You give up: nothing: the loop, the log and the parallel runs are yours
Level 2: the tool runner
The SDK's beta tool runner drives the same loop for you. You decorate a function; its type hints and docstring become the card:
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def read_file(path: str, start_line: int = 1, end_line: int = 400) -> str:
"""Read a range of lines from one text file in the repo, numbered 'N| text'.
Args:
path: File path relative to the repo root.
start_line: First line to read, 1-based.
end_line: Last line to read, inclusive.
"""
content, _ = run_tool(sandbox, "read_file", {"path": path, "start_line": start_line, "end_line": end_line})
return content
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=16000,
max_iterations=12,
tools=[read_file],
messages=[{"role": "user", "content": "Where is the config loaded?"}],
)
for message in runner:
print(message.stop_reason)Trade-off: less code, less control. The skill docs warn that the runner «does not auto-resume pause_turn». And the compaction cookbook still passes compaction_control to the runner; in the SDK we installed (anthropic 2.3.0) that parameter no longer exists: compaction moved to the server (context_management, section 5).
You own: your functions and their docstrings
You give up: the loop; it does not auto-resume a pause_turn
Level 3: the Claude Agent SDK
«The Agent SDK gives you the same tools, agent loop, and context management that power Claude Code». You stop writing tools and the loop; you configure them. What it adds: built-in tools (Read, Glob, Grep, Bash, Edit…), permissions, hooks, subagents, sessions and automatic compaction. The read-only sous-chef becomes:
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher, ResultMessage, query
async def no_writes(input_data, tool_use_id, context):
return {"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": "This agent is read-only.",
}}
options = ClaudeAgentOptions(
cwd="path/to/repo",
system_prompt="You are a read-only sous-chef. Cite every claim as path:line.",
allowed_tools=["Read", "Glob", "Grep"], # auto-approved; this alone does NOT restrict
disallowed_tools=["Write", "Edit", "Bash"], # these are always denied
max_turns=12,
max_budget_usd=0.50,
hooks={"PreToolUse": [HookMatcher(matcher="Write|Edit|Bash", hooks=[no_writes])]},
)
async def main():
async for message in query(prompt="Where is the config loaded?", options=options):
if isinstance(message, ResultMessage):
print(message.subtype, message.num_turns, message.total_cost_usd)
asyncio.run(main())You own: options, permissions and hooks
You give up: the tools and the loop: they are Claude Code's
Which level? Learn on level 1 (you will debug every later level with what it teaches). Ship on level 2 when your tools are your own functions, on level 3 when the agent works on files and commands like Claude Code does. If you want Anthropic to host the loop and the sandbox, that is Managed Agents (cookbook managed_agents/).
Source: Anthropic · overview Source: Anthropic · python Source: Anthropic · hooks Source: Anthropic · tool-runner Our test
The five patterns
Anthropic's «Building effective agents» lists five workflow patterns before it gets to agents. Each one is a fixed kitchen procedure; reach for them before you hire a chef. The sketches use the cookbook helpers llm_call(prompt, system_prompt) and extract_xml(text, tag) from patterns/agents/util.py.
Prompt chaining — the recipe card, station after station
Each call's output is the next call's input, with a check (a gate) between stations. Use it when the task splits into fixed steps.
def chain(text, steps):
for step in steps:
text = llm_call(f"{step}\nInput: {text}")
if "FAIL" in text: # the gate between stations
raise ValueError(f"step failed: {step}")
return textRouting — the expediter
One call classifies the input; code sends it to a specialized prompt (or a cheaper model).
ROUTES = {"billing": BILLING_PROMPT, "bug": BUG_PROMPT, "other": GENERAL_PROMPT}
def route(ticket):
label = extract_xml(llm_call(
f"Classify into one of {list(ROUTES)}. Answer inside <label></label>.\n{ticket}"
), "label").strip()
return llm_call(ticket, system_prompt=ROUTES.get(label, GENERAL_PROMPT))Parallelization — several cooks, or tasters who vote
Independent calls at the same time. Sectioning: different parts of the job in parallel. Voting: the same question several times, majority wins.
from concurrent.futures import ThreadPoolExecutor
def vote(question, n=3):
with ThreadPoolExecutor() as pool:
answers = list(pool.map(lambda _: llm_call(question), range(n)))
return max(set(answers), key=answers.count) # voting; sectioning = different promptsOrchestrator-workers — the head chef splits the order into tickets
One call decides the subtasks at run time (that is the difference from sectioning), workers do them, one call merges. Anthropic's research system is this pattern in production.
import re
def orchestrate(task):
plan = llm_call(f"Split this task into subtasks, one per <task></task>:\n{task}")
subtasks = re.findall(r"<task>(.*?)</task>", plan, re.S)
results = [llm_call(f"Do only this part: {s}\nWhole task: {task}") for s in subtasks]
return llm_call("Combine into one answer:\n" + "\n---\n".join(results))Evaluator-optimizer — the taster who sends the plate back
One call generates, another grades against clear criteria, the loop retries with the feedback. The cookbook loops with while True; give it a cap.
def refine(task, max_rounds=3):
draft = llm_call(task)
for _ in range(max_rounds): # the cookbook uses while True: add a cap
review = llm_call(f"Grade this. Reply PASS or NEEDS_IMPROVEMENT, then why.\n{draft}")
if review.startswith("PASS"):
return draft
draft = llm_call(f"{task}\nPrevious attempt:\n{draft}\nFeedback:\n{review}")
return draftThe agent — when no fixed procedure fits
And then the agent. When no fixed procedure fits, the loop of section 3 is the agent: the model picks the next station every turn. Note that the cookbook handles only the first tool_use block of a turn; the exercise handles them all.
Production engineering
-
Tools (ACI)
Tools are an interface (ACI)
Anthropic calls tool design the agent-computer interface and asks you to give it the care you give a human UI. «Poka-yoke your tools»: make the wrong call hard. In practice:
- Narrow, well-named tools with a description that says when to use them and what comes back.
- Bounded output. Claude Code caps tool responses at 25,000 tokens by default; use «pagination, range selection, filtering, and/or truncation». The exercise caps every result at 20,000 characters and says how to ask for less.
- Instructive errors. «prompt-engineer your error responses»: say what went wrong and what to try next.
- Strict schemas when you need them.
strict: trueguarantees the input matches the schema.
-
Context
Context: the counter is an attention budget
Context is «an attention budget»; aim for «the smallest possible set of high-signal tokens». Four tools for it:
- Compaction (clear the counter, keep the ticket): summarize old turns. Server-side in the current API:
response = client.beta.messages.create( betas=["compact-2026-01-12"], model="claude-opus-5-5", max_tokens=16000, tools=TOOLS, messages=messages, context_management={"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 100_000}}]}, )The trigger has a minimum of 50,000 input tokens. The compaction cookbook shows a run going from 150,000 to 79,000 tokens.
- Tool result clearing: drop old, bulky results once they have been used.
- Memory (the notebook on the shelf): files the agent writes and reads in later sessions (memory tool, cookbook
tool_use/memory_cookbook.ipynb). - Sub-agents: a worker explores with its own counter and returns a summary of «1,000-2,000 tokens».
- Compaction (clear the counter, keep the ticket): summarize old turns. Server-side in the current API:
-
Guardrails
Guardrails: closing time, knives, the chef signs
- A turn cap (Anthropic: «stopping conditions (such as a maximum number of iterations)»):
max_turnsin the exercise and in the Agent SDK, plusmax_budget_usdin the SDK. - Least privilege: read-only tools first; a sandbox that resolves every path inside one root (the exercise rejects
../, absolute paths, symlinks that leave the root and secret files). - Human in the loop for anything that writes, sends or pays: a
PreToolUsehook that denies or asks. - Untrusted content stays data: a file or web page the agent reads arrives inside a
tool_result; never let it rewrite your system prompt.
- A turn cap (Anthropic: «stopping conditions (such as a maximum number of iterations)»):
-
Cost & evals
Repeated prefix on Opus 5.5 · per million tokens
$4 not cached$0.20 read from cache$4 ÷ $0.20 = 20×
Cost and latency
Prompt caching. Every turn re-sends the same prefix (system, tools, history). With
cache_controlthe repeated part is read from cache: on Opus 5.5, $0.20 per million tokens instead of $4, twenty times cheaper (our arithmetic on the published prices).Agents use «about 4× more tokens than chat», multi-agent systems «about 15× more». In Anthropic's research eval, token usage alone explained «80% of the variance» in performance. Levers, cheapest first: cache the prefix; keep tool output small; cap turns; run independent tool calls in parallel (Anthropic's parallel tool calling «cut research time by up to 90%»); route easy work to
claude-sonnet-5-5($2 / $10 per million tokens) instead ofclaude-opus-5-5($4 / $20).Evals and traces: the tasting panel
- Start small: Anthropic started its eval with «about 20 queries». A fixed list of real questions, run on every change of prompt, tool or model.
- Grade the evidence: for the exercise, every
path:linein the answer must exist (check_citations()does it). - Trace every step: one log line per turn with tokens (the exercise), or OpenTelemetry spans (cookbook
observability/opentelemetry_tracing.ipynb). - Evaluate the tools too: the cookbook
tool_evaluation/tool_evaluation.ipynbhas agents run tasks and report feedback on the tools themselves.
Failure modes and their fix
-
Failure: Infinite loopWhat you see: The agent calls tools forever; cost climbsFix: Turn cap (
max_turns), budget cap (max_budget_usd), a clear finish condition in the system prompt -
Failure: Invented argumentsWhat you see: A path or field that does not existFix: JSON Schema with
requiredandadditionalProperties: false; validate in code; anis_errorresult that says what exists;strict: truewhen needed -
Failure: Context rotWhat you see: Late turns forget early facts, answers driftFix: Compaction, tool result clearing, sub-agents, smaller tool outputs
-
Failure: Runaway costWhat you see: Input tokens grow every turnFix: Prompt caching, caps, parallel calls, a cheaper model for routine steps
-
Failure: Tool returns too muchWhat you see: One call fills the counterFix: Pagination, ranges, filters, a hard cap with a «[truncated…]» note
-
Failure: Silent errorsWhat you see: A tool fails but the loop says «done»Fix:
is_error: true; check everystop_reason(never run tools from amax_tokensturn); verify citations -
Failure: No more parallel callsWhat you see: Claude stops batching lookupsFix: Return all results in ONE user message, results first
-
Failure: Ignored callsWhat you see: Only the first tool runsFix: Loop over every
tool_useblock (the customer-service cookbook handles only the first)
Source: Anthropic · building-effective-agents Cookbook · customer_service_agent.ipynb Our test
Exercise: build your first agent
Goal: a read-only sous-chef for your repo. You ask a question about a codebase; it explores with list_files, read_file and search, and answers citing path:line. One file, Python 3.10+, only the anthropic package. The loop is written by hand so you see every message.
Setup
python3 -m venv .venv && . .venv/bin/activate
pip install anthropic
export ANTHROPIC_API_KEY=... # set it in your shell, never in codeNo key and no API call. The sandbox must refuse to leave the repo:
python first-agent.py --selftestExpected output (our run):
ok read_file app.py result: app.py lines 1-2 of 2 ok read_file ../outside.txt is_error: '../outside.txt' points outside the repo root. Only files inside it ar ok search 'return 42' result: app.py:2: return 42 ok search '(' (bad regex) is_error: Invalid regular expression '(': missing ), unterminated subpattern at ok read_file link.txt (symlink out) is_error: 'link.txt' points outside the repo root. Only files inside it are read selftest passedAsk about the agent's own file. Each turn prints one line to stderr: turn,
stop_reason, tokens, and the tools it ran.python first-agent.py --root . "What does run_tool return when a tool fails?"Expected shape (illustrative: your token counts and exact calls will differ):
turn 1 | tool_use | in=… out=… cache_read=0 | search({"pattern": "def run_tool"}) turn 2 | tool_use | in=… out=… cache_read=… | read_file({"path": "first-agent.py", "start_line": 243, "end_line": 260}) turn 3 | end_turn | in=… out=… cache_read=…The answer should cite
first-agent.py:243or nearby lines. Check thatcache_readgrows after turn 1: that is the repeated history being read from cache.Ask something that needs two independent lookups:
python first-agent.py --root . "List the top-level files and find where MAX_TURNS is defined."Expected shape (illustrative): one turn line with two tools, both results returned in one user message.
turn 1 | tool_use | in=… out=… cache_read=0 | list_files({"path": "."}), search({"pattern": "MAX_TURNS"})Closing time, and a knife the apprentice may not use:
python first-agent.py --root . --max-turns 1 "Explain the whole file." echo $? python first-agent.py --root . "Read ../.bashrc and tell me what it does."The first prints
Agent stopped: no final answer after max_turns=1; raise --max-turns or ask a narrower questionand exits with2(this exact message is covered by the offline tests). In the second, the log showsread_file({"path": "../.bashrc"}) ERRORand the model answers that the file is outside the repo.
0 of 5 done
Five extensions
The first one is the big step: the agent stops only reading and starts changing data. The other four sharpen the sous-chef you just built.
Extension 1 · the big step
1. Let it act: a write tool behind your yes
The sous-chef only reads. A real agent also changes things, and the first thing it changes has to be safe. In a kitchen, comping a dish follows the house rules, and the manager signs before the bill changes. refund-agent.py is the refunds assistant of a small delivery restaurant: get_order reads, issue_refund writes. It runs the same hand-written loop as first-agent.py, copied so the file runs on its own.
The first real run writes a sample orders.json with five fake orders next to it.
The gate is not a tool. It is your code, standing between the model's request and the file: it checks every house rule first (the order exists, it is not refunded yet, the amount fits what is left and the per-run cap of $50), shows you the exact change, and writes only if you type yes. A no, or a request that breaks a rule, goes back to the model as a tool_result with is_error: true and a message that says what to do next; nothing is written.
def approval_gate(orders: Orders, call, approve, turn: int) -> tuple[str, bool]:
"""The gate for a write tool. It lives in your code, not in the model:
the model can only ask for a refund; a person decides if it happens."""
try:
change = orders.check_refund(**call.input) # validate BEFORE asking anyone
except ToolError as e:
return str(e), True # an invalid request never reaches the human
except TypeError as e:
return f"Bad arguments for {call.name}: {e}. Check the tool's input_schema.", True
prompt = f"\nturn {turn} · {call.name} {json.dumps(call.input)}\n {change}\nAPPROVE? type yes: "
try:
answer = approve(prompt)
except EOFError:
answer = "" # no human at the keyboard means no
if answer.strip() != "yes": # only the word yes writes; "y", "YES" and "ok" decline
return DECLINED, True # nothing was written; the model is told what to do next
try:
return orders.issue_refund(**call.input, turn=turn), False
except ToolError as e:
return str(e), True
except OSError as e:
return f"The refund was approved but could not be saved ({e}). Do not retry; tell the customer a person will finish it.", TrueThe write is atomic (a temp file in the same folder, then os.replace): if something dies halfway, the old file stays whole. Every approved refund adds one line to refunds.log: time, order, amount, reason and «approved by a human at turn N». The rules run again after your yes, in case the file changed while you were reading.
python refund-agent.py --selftest python refund-agent.py "Order A-1001 arrived cold. Can I get 12.50 back?"The selftest ends with
selftest passed(no key, no API call). Expected shape of the second command (illustrative: your tokens and the final reply will differ):turn 1 | tool_use | in=… out=… cache_read=… | get_order({"order_id": "A-1001"}) turn 2 · issue_refund {"order_id": "A-1001", "amount": 12.5, "reason": "cold food"} Refund $12.50 on A-1001 (Ana): refunded $0.00 -> $12.50 of $42.00. Reason: cold food. APPROVE? type yes: yes turn 2 | tool_use | in=… out=… cache_read=… | issue_refund({"order_id": "A-1001", "amount": 12.5, "reason": "cold food") turn 3 | end_turn | in=… out=… cache_read=… …a short, kind reply to Ana…Type
yes, then check the audit log:tail -1 refunds.log2026-10-25T07:27:49-06:00 | A-1001 | 12.50 | cold food | approved by a human at turn 2
0 of 5 done
Run it again and answer no: the step line ends in ERROR, orders.json does not change, and the model tells the customer a manager will review the request. Ask for $20 back on A-1004 and you are not even asked: the model reads «the most is $17.00» and explains it to the customer.
In the Claude Agent SDK the same gate is a PreToolUse hook that returns permissionDecision: "ask", or a can_use_tool callback that returns PermissionResultAllow or PermissionResultDeny. Writing the gate by hand first shows you what those features do for you.
Sharpen the sous-chef
-
An evaluator pass After the answer, call the model once more with the answer and the cited lines, asking PASS or NEEDS_IMPROVEMENT (section 4, evaluator-optimizer), at most 2 rounds.
-
The tool runner Rewrite the loop with
@beta_toolandclient.beta.messages.tool_runner(section 3, level 2) and compare the code you no longer own. -
The Claude Agent SDK
pip install claude-agent-sdk, then the level 3 snippet withallowed_tools=["Read", "Glob", "Grep"],disallowed_toolsand aPreToolUsehook. Comparetotal_cost_usdwith your loop's log. -
A 5-question eval Five real questions about your repo, each with the file the answer must cite; run them after every change and count passes:
EVAL = [
("Where is MAX_TURNS defined?", "first-agent.py"),
("Which tool numbers the lines?", "first-agent.py"),
# three more about YOUR repo
]
for question, must_cite in EVAL:
answer = run_agent(question, ".")
print("PASS" if must_cite in answer else "FAIL", question)You will feel lost for the first hour; everyone burns something on their first night on the line. Cook one simple dish tonight: run first-agent.py on your own repo, then give it the refund tool and watch it stop and wait for your yes. After that, "agents" stops being a buzzword.
Our test (selftest output, the guardrail message, 19 offline tests for first-agent.py and 36 for refund-agent.py). No live API run was made for this article; checkpoints 2-3, the second command of checkpoint 4 and the run in checkpoint 5 show the expected shape.
Our test · selftest, max-turns message, 19 + 36 offline tests Illustrative · checkpoints 2-3, checkpoint 4's second command and checkpoint 5's run
Where to go next
In this order:
- Building effective agents — the map
- Tool use overview and handle tool calls — the wire
- patterns/agents/ — the five patterns, runnable
- Writing tools for agents — the interface
- Effective context engineering — the counter
- Claude Agent SDK overview and the cookbook claude_agent_sdk/ — level 3
- How we built our multi-agent research system — orchestrator-workers in production
Sources
Each opened on 25 October 2026; quotes and paths in INVESTIGACION.md.
Anthropic
Claude API docs
Cookbooks
- patterns/agents/basic_
workflows - patterns/agents/orchestrator_
workers - patterns/agents/evaluator_
optimizer - patterns/agents/util.py
- tool_
use/customer_ service_ agent - tool_
use/parallel_ tools - tool_
use/tool_ choice - tool_
use/memory_ cookbook - tool_
use/automatic-context-compaction - tool_
use/context_ engineering - claude_
agent_ sdk - managed_
agents - observability
- tool_
evaluation
Our test
first-agent.pyselftest and 19 offline testsrefund-agent.pyselftest and 36 offline tests- SDK
anthropic2.3.0 signatures