An agent is three things: a loop, a few tools, and state that survives on disk. This tutorial builds all three from scratch in Python with nothing but the official anthropic SDK. No agent framework. When you are done you will have an agent that searches the web, reads and writes files, remembers facts across runs, and resumes from a checkpoint after a crash, in about 200 lines.
Every output below comes from real runs on 2026-09-17. Token counts and timings are copied from the logs as they were; the tasks were given in Chinese, so where I quote the model's answers I have translated them. The full script is a single file you can download: minimal_search_agent.py.
If you want the architecture before the code, Chapter 3 of the Agent book, "Tool Calling Fundamentals", explains the ideas behind every block here. This post is the runnable version of that chapter.
Run it first
You need Python 3.10 or newer and an Anthropic API key.
pip install "anthropic>=1.6"
export ANTHROPIC_API_KEY=sk-ant-...
curl -O https://waylandz.com/examples/minimal_search_agent.py
python minimal_search_agent.py --task "Find out what happened to Tasks in the MCP 2026-07-28 specification. Write a 3-sentence conclusion with source links to notes/mcp-tasks.md, and use remember to store the current spec version."
Afterwards there is an agent_workspace/ directory containing the note the agent wrote, the facts it remembered, and a checkpoint of the whole conversation. The rest of this post walks through the code in three parts, tools, loop, state, and then looks at four runs.
Part one: tools
There are two kinds of tools, and the difference is who executes them.
Server-side tools run on Anthropic's servers and their results arrive inside the same response. Web search is one of these, and declaring it takes one line:
WEB_SEARCH = {"type": "web_search_20260209", "name": "web_search", "max_uses": 5}
No search-engine API key, no search code of your own. max_uses caps the number of searches per turn. It is the cheapest guard against a runaway loop.
Client-side tools run in your process. The model only says "I want to call write_file with these arguments"; your code writes the file and hands the result back. This agent has three:
| Tool | What it does | Why it exists |
|---|---|---|
read_file(path) | Read a file in the workspace | Lets the agent look at its own earlier output |
write_file(path, content) | Write a file in the workspace | Results have to land in a file, not only in the conversation |
remember(key, value) | Store one fact in memory.json | The next run does not have to search again |
A definition is a piece of JSON Schema. Three details matter:
{
"name": "write_file",
"description": "Create or overwrite a UTF-8 text file inside the workspace.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relative path"},
"content": {"type": "string"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
"strict": True,
}
descriptionis written for the model. It decides when the model picks this tool. A vague description leads to the wrong tool or missing arguments.strict: Truetogether withadditionalProperties: Falsemakes the API guarantee that the arguments match the schema exactly, so your code does not need its own type checks.- Paths are relative to the workspace.
safe_path()rejects anything like../before execution, and the rejection goes back to the model as text. The fourth run shows what the model does with it.
Executing a tool is an if chain:
def run_tool(name: str, args: dict) -> str:
if name == "read_file":
return safe_path(args["path"]).read_text(encoding="utf-8")
if name == "write_file":
target = safe_path(args["path"])
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(args["content"], encoding="utf-8")
return f"wrote {len(args['content'])} chars to {args['path']}"
if name == "remember":
memory = load_memory()
memory[args["key"]] = args["value"]
MEMORY_FILE.write_text(json.dumps(memory, ensure_ascii=False, indent=2), encoding="utf-8")
return f"remembered {args['key']}"
raise ValueError(f"unknown tool {name}")
The return value is a string for the model. Do not return nothing on a successful write. Return how much was written and where, so the model has something to plan its next step on.
Part two: the loop
The core of an agent is this while True. Each pass calls the model once, checks why it stopped, and branches on the reason.
while True:
response = client.messages.create(
model="claude-opus-5",
max_tokens=16000,
system=system,
tools=[WEB_SEARCH, *CLIENT_TOOLS],
messages=messages,
)
messages.append({"role": "assistant", "content": to_plain(response.content)})
if response.stop_reason == "pause_turn":
save_session(messages, "running", rounds)
continue # server-side search hit its iteration cap; resend as-is
if response.stop_reason == "refusal":
save_session(messages, "refused", rounds)
return # print the reason, do not retry
if response.stop_reason == "max_tokens":
save_session(messages, "truncated", rounds)
return # raise the limit or split the task
if response.stop_reason != "tool_use":
save_session(messages, "done", rounds)
break # no tool calls left: this is the final answer
results = []
for block in response.content:
if block.type != "tool_use":
continue # server_tool_use blocks already ran on the server
try:
output = run_tool(block.name, block.input)
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
except Exception as exc:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": f"Error: {exc}", "is_error": True})
messages.append({"role": "user", "content": results})
rounds += 1
save_session(messages, "running", rounds)
stop_reason is the switch for the whole loop. Five values, five meanings:
stop_reason | Meaning | What to do |
|---|---|---|
tool_use | The model wants to call tools | Run them, append the results as a user message, call again |
end_turn | The model is finished | Take the text, stop |
pause_turn | A server-side tool's internal loop hit its cap | Resend the messages unchanged; the server continues |
max_tokens | Output was cut off | Do not treat it as success |
refusal | Blocked by safety policy | Read stop_details, do not retry the same request |
The most common beginner mistake is handling only tool_use and end_turn. pause_turn appears only when server-side tools are involved, and the first time you see it the agent looks like it "stopped mid-sentence".
Two more rules:
- One response can contain several
tool_useblocks. The model will request tools in parallel. In the first run it issued the file write and the memory write in one go. Return all results in the sameusermessage. Splitting them across messages teaches the model to stop parallelizing. - Tool errors go back to the model. Do not crash on your side.
is_error: Trueplus the error text lets the model decide whether to reroute or give up.
Part three: state
There are two layers of state with different lifetimes.
The conversation checkpoint (session.json): after every tool round, the whole messages list and the current status are written to disk. The write goes to a temporary file first and then os.replace() swaps it in. If the process is killed halfway through a write, the disk holds either the old complete checkpoint or the new complete one, never half of one.
def save_session(messages, status, rounds):
tmp = SESSION_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps({"status": status, "rounds": rounds, "messages": messages}, ensure_ascii=False))
os.replace(tmp, SESSION_FILE)
--resume reads that file back and calls the model from where the last run stopped.
The state files live under agent_workspace/.agent/, and safe_path() refuses tool reads and writes there. Without that rule one wrong write_file to memory.json breaks the next startup while it loads memory.
Memory across runs (memory.json): the key-value pairs written by the remember tool. At startup they are read and appended to the system prompt, so a new task does not search for what the agent already knows. This is the simplest possible memory, enough to understand the principle. Production memory architectures are the subject of Chapter 8.
Why does the checkpoint store messages rather than "task progress"? Because to the model, the conversation history is the state. Tool results are in it, and so are its own intermediate judgments. Send the list back unchanged and the model continues from any step. That is also why model_dump() converts the SDK objects to plain JSON before saving.
Four real runs
The script version used for these four runs kept the state files in the workspace root; they were moved into .agent/ with the reserved-path check afterwards. The loop logic did not change.
Run 1: do the task, then crash on purpose
--crash-after 1 makes the process call os._exit(1) right after the first tool round, simulating a power cut or an OOM kill.
[call 1] stop_reason=tool_use in=22441 out=1391
tool write_file({"path": "notes/mcp-tasks.md", "content": "# MCP 2026-07-28: Tasks 的定位变化\n\n在 20) ok
tool remember({"key": "mcp_spec_version", "value": "当前 MCP 规范版本:2026-07-28(latest);Tasks 已从核心移) ok
--- simulated crash after 1 tool round(s); run with --resume ---
exit=1
One model call. Inside that call the server-side search already ran (in=22441, more than twice the size of the other calls, because the search results were injected into the context), and then the model issued the file write and the memory write together. Both succeeded, the checkpoint hit the disk, the process exited. The task in this run was given in Chinese, which is why the note is in Chinese; the code does not care.
Run 2: resume from the checkpoint
resuming after 1 tool round(s), 3 messages on disk
[call 1] stop_reason=end_turn in=9673 out=359
=== result ===
Conclusion: in the 2026-07-28 specification, Tasks moved out of the experimental protocol core
and became the formal extension `io.modelcontextprotocol/tasks` (SEP-2663), with a polling
`tasks/get` and a new `tasks/update` ...
calls=1 tool_rounds=1 searches=0 input_tokens=9673 output_tokens=359 cache_read=0 elapsed=8.3s
searches=0. The resumed run did not search again and did not rewrite the file, because the search results and the tool results were both in the checkpoint. The model saw that the tools had already returned success and produced the final summary. What this resume saved was one web search and two tool executions.
The guarantee has a boundary. The simulated crash happened after the checkpoint was written, which is why nothing was repeated. The checkpoint is written once a whole tool round has finished; a crash in the moment after the tools ran but before the file landed would make the resumed run execute that round again. This starter script does not guarantee that side effects happen exactly once for an arbitrary crash time. Overwriting a file is harmless. If the tool is "send email" or "place order", doing it twice is an incident, and getting there means recording the intent before a tool runs and the result after it. Even those two log entries leave a window in which the external operation succeeded but its result was never recorded, so the last step is a tool that is idempotent, or a check of the external state before resuming. The full treatment is in Mid-Turn Checkpointing in a Long-Running Agent Loop.
The note's conclusion is correct, by the way. Tasks did move from the experimental core into the io.modelcontextprotocol/tasks extension in the 2026-07-28 revision; compare with the official release post.
Run 3: a new task that uses memory instead of search
[call 1] stop_reason=tool_use in=6431 out=100
tool read_file({"path": "notes/mcp-tasks.md"}) ok
[call 2] stop_reason=end_turn in=6909 out=575
=== result ===
No search; answered from memory and the local file.
Remembered spec version: MCP current specification 2026-07-28 (marked latest) ...
calls=2 tool_rounds=1 searches=0 input_tokens=13340 output_tokens=675 cache_read=0 elapsed=12.0s
At startup the contents of memory.json were appended to the system prompt, so the model knew the version number outright. It made a single read_file call for the note and never went online. Two calls totaled 13,340 input tokens, less than the single first call of run 1, because there were no search results in the context.
Run 4: a tool fails and the model finds another way
The task deliberately asks for a write outside the workspace:
[call 1] stop_reason=tool_use in=6462 out=274
tool read_file({"path": "notes/mcp-tasks.md"}) ok
[call 2] stop_reason=tool_use in=8624 out=620
tool write_file failed: path '../backup/mcp-tasks.md' is outside the workspace
[call 3] stop_reason=tool_use in=9281 out=627
tool write_file({"path": "backup/mcp-tasks.md", ...}) ok
[call 4] stop_reason=tool_use in=9934 out=58
tool read_file({"path": "backup/mcp-tasks.md"}) ok
[call 5] stop_reason=tool_use in=10370 out=150
tool remember({"key": "workspace_write_boundary", "value": "write_file cannot write outside the workspace ..."}) ok
[call 6] stop_reason=end_turn in=10537 out=365
calls=6 tool_rounds=5 searches=0 input_tokens=55208 output_tokens=2094 cache_read=0 elapsed=38.8s
Call 2 was blocked by safe_path() and the error went back with is_error: True. The model did not hammer the same path. It wrote to backup/ inside the workspace instead, read the copy back to check it matched, stored the boundary with remember, and in the final answer said explicitly what it could not do and why. That is the error behavior you want from an agent: fail once, route around once, record the limit, report honestly.
The price was six model calls and 55,208 input tokens, four times run 3. Error recovery is not free. Every detour adds a slice of context. It is also why tool error messages should be specific: path is outside the workspace let the model fix the call on the first try. A bare Error might have taken three or four.
One thing I did not expect: the search tool brought a sandbox
Looking at run 4's checkpoint, call 1 contains a call I never defined, alongside read_file:
SERVER_TOOL_USE: bash_code_execution
{"command": "pwd; ls -la; ls -la notes 2>/dev/null; ls -la .. 2>&1 | head -20"}
The web_search_20260209 version of the search tool has built-in "dynamic filtering", implemented by giving the model a server-side code execution sandbox to filter search results with. The model knows it has that sandbox, so while working out whether it could write to ../backup it first ran ls there, and then wrote in its final answer that "the bash sandbox cannot see the notes/ directory at all".
Of course it cannot. That sandbox is a container on Anthropic's servers, not your workspace. The model's conclusion was still right (it correctly attributed the limit to write_file), but it spent an extra call probing an environment unrelated to the task.
The lesson: the tool surface you give the model is bigger than the tools you wrote. Declaring one server-side tool can bring its attached capabilities with it. Say so in the system prompt ("the sandbox filesystem is unrelated to the workspace"), or leave the search tool off for tasks that do not need it.
What these runs cost
Rough numbers at Claude Opus 5's list price in September 2026 (input $5 per million tokens, output $25 per million, web search billed per search on top; check the official pricing page):
| Run | Model calls | Input tokens | Output tokens | Elapsed | Approximate cost |
|---|---|---|---|---|---|
| 1 search + write + crash | 1 | 22,441 | 1,391 | not recorded (process killed) | ≈ $0.15 + search fees |
| 2 resume | 1 | 9,673 | 359 | 8.3 s | ≈ $0.06 |
| 3 answer from memory | 2 | 13,340 | 675 | 12.0 s | ≈ $0.08 |
| 4 error recovery | 6 | 55,208 | 2,094 | 38.8 s | ≈ $0.33 |
All four together came to under a dollar. Note cache_read=0: this tutorial does not turn on prompt caching because the conversations are short. Once your system prompt plus tool definitions exceed a couple of thousand tokens, add a cache_control breakpoint after them; input cost on later calls drops by roughly an order of magnitude. See Chapter 39, "Prompt Cache Stability".
What these 200 lines leave out
In order of importance:
- Context compaction. As a conversation grows,
messageswill exceed the window and you need to summarize or truncate old tool results. The principles and trade-offs are in Chapter 35 and Chapter 36. - Timeouts and stuck-loop detection. This loop has no upper bound. If the model keeps calling the same tool it keeps spending. A production version needs at least a maximum round count and a "three identical calls in a row means stop" guard: Chapter 42 and Chapter 43.
- A fallback model after a refusal. The API can switch to a backup model automatically on
refusal(thefallbacksparameter). It is off here so you can see therefusalbranch itself. - Where tools come from. The three tools here are hand-written. In a real project tools come from MCP servers, command-line programs, or Skills files, and the three carry different costs. I compared them on one task: MCP, WebMCP, CLI, Skills: Which Tool Surface for the Same Task?.
Questions people ask
Can I use a different model? Yes, change the MODEL constant. But the stop_reason values, strict mode, and the server-side search tool are features of the Anthropic API. Switching vendors means changing the branches in the loop, not just the model name.
Why not use the SDK's built-in tool runner? The SDK has a tool_runner that drives this loop for you. The tutorial writes it by hand so you can see every branch; once you understand them, the runner saves about 30 lines. In the SDK documentation I checked, the runner does not resume pause_turn on its own, so keep that branch in mind.
Does session.json grow without bound? Yes. Every round's tool results stay in it. The tasks here are short and finish at a few tens of kilobytes; long tasks need compaction or an append-only log per round.
Can I save search results and reuse them? The checkpoint stores encrypted references to the search results. Resuming the same conversation reuses them, but you cannot read them out on their own. To keep the text, have the model write_file it.