To build an AI agent, you need three pieces: a model that can ask for tools, a few functions it is allowed to call, and a loop. The loop sends the conversation to the model, runs any tool it asks for, sends back the result, and repeats until the model says it is done.
With Anthropic’s Python SDK, that loop fits in about 100 lines, guardrails included. We ran every line below with anthropic 1.8.0 on Python 3.14. We had no API key, so we tested with a fake model that returns the SDK’s own message objects. One marked line switches to the real Claude.
- An agent is a model, some tools and a loop: the model asks for a tool, your code runs it, and the result goes back.
- A tool is a name, a description and a JSON schema. The description tells the model when to use it.
- End the loop when the model stops asking for tools, and always cap the number of steps.
- Guardrails live in your code: a sandbox folder, approval before any write, and errors sent back instead of crashes.
- Test the loop with a fake model first. It costs nothing and can misbehave on command.
What a tool call actually is
In the API, the model’s reply contains a tool_use block, and your next message answers it with a tool_result block that carries the same ID. Here is one real pair from our test run:
{"type": "tool_use", "id": "toolu_3", "name": "read_file", "input": {"path": "notes/launch.md"}}{"type": "tool_result", "tool_use_id": "toolu_3", "content": "# Launch plan\n\nLaunch date: October 14\nOwner: Sam\n", "is_error": false}That exchange, repeated until the job is done, is the whole idea. For the bigger picture, see what an AI agent is.
Set up the project
You need Python 3.10 or newer, which the SDK requires. Create a folder, a virtual environment and a tiny workspace for the agent to explore:
mkdir first-agent && cd first-agent
python3 -m venv .venv && source .venv/bin/activate
pip install anthropic
mkdir -p workspace/notes
printf '# Launch plan\n\nLaunch date: October 14\nOwner: Sam\n' > workspace/notes/launch.md
printf '# To do\n\n- Book the venue\n' > workspace/notes/todo.mdThe agent’s job: find the launch date in the notes and add it to the to-do list.
Build an AI agent loop in 100 lines
Save this as agent.py. It defines three tools, runs them safely, and loops:
import sys
from pathlib import Path
import anthropic
MODEL = "claude-opus-5-5"
MAX_STEPS = 10 # guardrail: never loop forever
WORKSPACE = Path("workspace").resolve() # guardrail: the only folder the agent can touch
TOOLS = [
{
"name": "list_files",
"description": "List every file in the workspace. Call this first to see what exists.",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "read_file",
"description": "Read a text file from the workspace. Call this when you need its contents.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Path inside the workspace"}},
"required": ["path"],
},
},
{
"name": "append_line",
"description": "Add one line to the end of a workspace file. The user must approve it.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path inside the workspace"},
"line": {"type": "string", "description": "The line to add"},
},
"required": ["path", "line"],
},
},
]
def safe_path(relative: str) -> Path:
path = (WORKSPACE / relative).resolve()
if not path.is_relative_to(WORKSPACE):
raise ValueError(f"{relative} is outside the workspace")
return path
def run_tool(name: str, args: dict) -> str:
if name == "list_files":
files = sorted(p for p in WORKSPACE.rglob("*") if p.is_file())
return "\n".join(str(p.relative_to(WORKSPACE)) for p in files)
if name == "read_file":
return safe_path(args["path"]).read_text()[:10_000]
if name == "append_line":
path = safe_path(args["path"])
answer = input(f"Allow: add {args['line']!r} to {args['path']}? [y/N] ")
if answer.strip().lower() != "y":
return "The user declined."
with path.open("a") as f:
f.write(args["line"] + "\n")
return "Line added."
raise ValueError(f"Unknown tool: {name}")
def run_agent(client, task: str) -> None:
messages = [{"role": "user", "content": task}]
for step in range(1, MAX_STEPS + 1):
response = client.messages.create(
model=MODEL, max_tokens=16000, tools=TOOLS, messages=messages
)
messages.append({"role": "assistant", "content": response.content})
for block in response.content:
if block.type == "text" and block.text:
print(f"[model] {block.text}")
if response.stop_reason != "tool_use": # end_turn, max_tokens, refusal...
print(f"[stop] {response.stop_reason} after {step} steps")
return
results = []
for block in response.content:
if block.type == "tool_use":
print(f"[tool] {block.name} {block.input}")
try:
output, failed = run_tool(block.name, block.input), False
except Exception as err:
output, failed = f"Error: {err}", True
print(f"[error] {err}")
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output, "is_error": failed})
messages.append({"role": "user", "content": results})
print(f"[stop] hit the {MAX_STEPS}-step limit")
if __name__ == "__main__":
if "--fake" in sys.argv:
from fake_model import FakeClient
client = FakeClient()
else:
client = anthropic.Anthropic() # the one line that needs your API key
run_agent(client, "When is the launch? Add the date to notes/todo.md.")Describe the tools
Each tool has a name, a description and an
input_schemain JSON Schema. Say when to use the tool, not only what it does, because the model picks tools by reading these descriptions.Call the model with everything so far
client.messages.creategets the whole conversation and the tool list on every step. The API keeps no memory between calls, so themessageslist is the agent’s memory.Keep the model’s reply whole
Append
response.contentas it is, including thetool_useblocks. Eachtool_resultmust point back to one of them by ID.Run the tools, then go around again
Every requested tool runs through
run_tool, and all results go back together in one message. Failures return as results withis_errorset, so the model can read what went wrong and try another way.
Test it with a fake model first
A fake model makes the loop free to run, and it can misbehave on demand. Ours returns the SDK’s real Message objects and reads the results the loop sends back, so it also proves the data flows. Save this as fake_model.py:
"""A scripted stand-in for Claude, so agent.py runs without an API key.
It returns the SDK's own Message objects, so agent.py cannot tell the
difference, and it reacts to the tool results the loop sends back.
"""
import re
from anthropic.types import Message, TextBlock, ToolUseBlock, Usage
def reply(*blocks, stop_reason):
return Message(id="msg_fake", type="message", role="assistant", model="fake",
content=list(blocks), stop_reason=stop_reason,
usage=Usage(input_tokens=0, output_tokens=0))
def call(n, name, **args):
return ToolUseBlock(type="tool_use", id=f"toolu_{n}", name=name, input=args)
class FakeMessages:
def create(self, *, messages, **request):
results = [block for m in messages if m["role"] == "user"
and isinstance(m["content"], list) for block in m["content"]]
for n, result in enumerate(results, start=1): # each result must answer its call
assert result["tool_use_id"] == f"toolu_{n}", "tool_result does not match"
step = len(results)
if step == 0:
return reply(TextBlock(type="text", text="I'll check which files exist."),
call(1, "list_files"), stop_reason="tool_use")
if step == 1: # misbehave on purpose, to show the sandbox at work
return reply(call(2, "read_file", path="../secrets.txt"), stop_reason="tool_use")
if step == 2:
launch = next(p for p in results[0]["content"].splitlines() if "launch" in p)
return reply(call(3, "read_file", path=launch), stop_reason="tool_use")
date = re.search(r"Launch date: (.+)", results[2]["content"]).group(1)
if step == 3:
return reply(call(4, "append_line", path="notes/todo.md", line=f"- Launch: {date}"),
stop_reason="tool_use")
done = results[3]["content"] == "Line added."
text = f"The launch is on {date}. " + ("I added it to notes/todo.md." if done
else "You declined, so I changed nothing.")
return reply(TextBlock(type="text", text=text), stop_reason="end_turn")
class FakeClient:
def __init__(self):
self.messages = FakeMessages()Run python agent.py --fake. This is our terminal, answering y at the prompt:
[model] I'll check which files exist.
[tool] list_files {}
[tool] read_file {'path': '../secrets.txt'}
[error] ../secrets.txt is outside the workspace
[tool] read_file {'path': 'notes/launch.md'}
[tool] append_line {'path': 'notes/todo.md', 'line': '- Launch: October 14'}
Allow: add '- Launch: October 14' to notes/todo.md? [y/N] y
[model] The launch is on October 14. I added it to notes/todo.md.
[stop] end_turn after 5 stepsThe sandbox blocked the escape attempt, the model got the error and moved on, and the write waited for a human. Answering n left todo.md untouched. We also ran the loop through the real anthropic.Anthropic() client with only the network faked, and checked the requests it built: every result carried the right tool ID.
When should an AI agent stop?
Every reply carries a stop_reason. Our loop keeps going only on tool_use and stops on anything else, which is the safe default:
stop_reason | What it means | What the loop does |
|---|---|---|
tool_use | The model wants tools run | Runs them and loops |
end_turn | The model finished its answer | Stops |
max_tokens | The reply hit the length cap | Stops, because the answer is cut off |
refusal | The model declined the request | Stops. The reply’s stop_details says why |
| Step limit | Our own cap, MAX_STEPS | Stops after 10 rounds |
We tested two of these with fakes built to fail. A model that never stopped was called exactly 10 times before [stop] hit the 10-step limit. A reply cut off by max_tokens ended the run after one step instead of acting on half an answer.
Guardrails that make it safe to run
The model decides what to try. Your code decides what actually happens, and that is where guardrails belong:
Where to go next
Let the SDK write the loop. Once you understand it, the SDK’s Tool Runner (
client.beta.messages.tool_runner, with the@beta_tooldecorator) runs the same cycle for you. It is in beta.Plug in ready-made tools. Instead of writing every tool, connect MCP servers. Our tutorial shows how to build an MCP server and a client for it.
Split big jobs. When one loop gets crowded, several focused agents can share the work. See multi-agent systems.
FAQ
Do I need a framework to build an AI agent?
No. A loop, a few functions and a model API are enough, as this tutorial shows. Frameworks add conveniences such as retries, tracing and memory, which help once you know what you need.
Why test an agent with a fake model?
It is free, fast and repeatable, and it can fail on command. A real model rarely tries to leave the sandbox or loops forever exactly when you want to test for it.
How much does running an agent cost?
Each step is one API call that resends the whole conversation, so cost grows faster than the step count. Cap the steps and keep tool output short. Our guide to cutting AI costs covers the rest.
Can I use a different model or provider?
Yes. The loop is the same everywhere: send messages and tools, run what the model asks for, send results back. The client call and the message format change between providers, but the loop does not.
Read next: agentic workflow examples worth automating first, and whether it is safe to let AI control your computer.
- Tool use with Claude, Anthropic documentation, accessed September 2026
- Stop reasons and fallback, Anthropic documentation, accessed September 2026
- Models overview, Anthropic documentation, accessed September 2026
- Tool runner (SDK), Anthropic documentation, accessed September 2026
- anthropic on PyPI, Python Package Index, version 1.8.0, September 2026




