How to build an AI agent: four ways, with code
The agent loop is about twenty lines. The decisions around it are the actual work: whether you need an agent at all, which of four architectures to use, and what breaks once it runs against live tools. An agent is a loop that lets a model call tools until it decides it's finished. Everything else is engineering around that idea.
First, decide whether you need one
Most "build an AI agent" tutorials show you one loop and call it done. The loop is the easy part.
Agents cost more and take longer than a single API call. Before building one, check all four:
- Complexity. Is the task multi-step and hard to specify up front? "Turn this design doc into a PR" qualifies. "Extract the title from this PDF" does not.
- Value. Does the outcome justify higher latency and cost?
- Viability. Is the model actually good at this kind of task?
- Cost of error. Can mistakes be caught and recovered from, with tests, review, or a rollback?
A "no" on any of these means you want a simpler tier: one call, or a workflow where your code controls the sequence and the model fills in individual steps. Most things labeled "agent" should have been a workflow. Workflows are cheaper and far easier to debug.
The loop, in full
Here's a complete agent. No framework.
import anthropic
client = anthropic.Anthropic()
MAX_TURNS = 10
tools = [{
"name": "get_weather",
"description": "Get current weather for a location.",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and state, e.g. San Francisco, CA",
},
},
"required": ["location"],
},
}]
def execute_tool(name, tool_input):
"""Your implementation. Return whatever the model should see."""
if name == "get_weather":
return f"18C and overcast in {tool_input['location']}"
raise ValueError(f"unknown tool: {name}")
messages = [{"role": "user", "content": "What's the weather in Paris?"}]
for _ in range(MAX_TURNS):
response = client.messages.create(
model="claude-opus-5",
max_tokens=16000,
tools=tools,
messages=messages,
)
if response.stop_reason == "pause_turn":
# A server-side tool ran long. Append the paused turn and
# re-send to resume. Do not add a "continue" message.
messages.append({"role": "assistant", "content": response.content})
continue
if response.stop_reason != "tool_use":
# end_turn, refusal, max_tokens, model_context_window_exceeded.
# Anything that is not a tool call ends the loop.
break
# Append the whole content list. It carries the tool_use blocks
# that the next turn needs.
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": execute_tool(block.name, block.input),
})
messages.append({"role": "user", "content": tool_results})
else:
raise RuntimeError(f"agent did not finish within {MAX_TURNS} turns")
if response.stop_reason == "refusal":
print("The model declined this request.")
else:
print("".join(b.text for b in response.content if b.type == "text"))
end_turn. The turn can also
stop for reasons that are not either, which is what the code's final branch is for.
That's the whole mechanism. The model returns stop_reason: "tool_use" with one or more
tool_use blocks, you run them, you hand back tool_result blocks keyed by
tool_use_id, and you go around again until it stops.
Four things in there are load-bearing, and they're the usual source of bugs:
Append response.content, not response.content[0].text. The
content list is where the tool_use blocks live. Extract the text and append that instead, and
the next turn has no idea it called anything.
Every tool_use needs a matching tool_result. Same
tool_use_id. Drop one and the request is rejected.
Return all results in one user message. The model can request several tools at once. If you split the results across multiple messages, you quietly train it to stop making parallel calls.
Exit on anything that isn't a tool call. Checking only for end_turn is the
most common bug in hand-written loops. A turn can also come back as refusal,
max_tokens, or model_context_window_exceeded, and on the model this example
pins a refusal is a documented outcome. Re-sending a refused prompt just refuses again.
One more thing worth knowing about this model: thinking is on by default, so content starts
with a thinking block rather than text, and max_tokens is a single budget covering thinking
plus output. That's another reason to append the whole list instead of reaching for
content[0].
Write tool descriptions like documentation
The single biggest lever on whether an agent works is the tool description, and it's where most people under-invest. The description is what the model uses to decide whether to call it.
State the trigger condition, then the behavior. "Get current weather" is weaker than "Get current weather
for a location. Call this when the user asks about conditions, temperature, or forecasts for a named
place." Describe each parameter. Use enum where the values are fixed. Say what the tool does
not return.
A vague one-liner is the most common cause of an agent that "won't use its tools," and no amount of system-prompt shouting fixes it.
Four ways to build this, and when each is right
This is the part almost no tutorial makes clear, and getting it wrong means rebuilding later. Two independent questions separate the options: who supplies the loop, and who supplies the infrastructure it runs on.
| Approach | You write | Runs on |
|---|---|---|
| Manual loop | The loop above | Your infra |
| Tool Runner | Just the tool functions | Your infra |
| Managed Agents | Agent config, tool results | Anthropic's infra (or your own sandbox) |
| Claude Agent SDK | A prompt and options | Your infra |
The manual loop is what you saw above. Take it when you want to own the whole control flow, or you don't want a beta dependency.
The Tool Runner is part of the regular SDK, currently behind a beta namespace. You decorate your functions, hand them over, and it drives the request-execute-loop cycle for you. It still gives you per-turn hooks for approval gates, logging, and error interception, so "I need control" is rarely a reason to avoid it. This is the right default for most custom-tool agents.
Managed Agents is the only option where Anthropic runs both the loop and hosts a sandboxed container where tools execute. It is also in beta today, as is the Tool Runner. Reach for it when you want persisted, versioned agent configs, long-running sessions, or work on a schedule, and you don't want to operate the infrastructure.
The Claude Agent SDK is a different product, and the one people most often conflate with the Tool Runner. It's Claude Code packaged as a library: built-in file read, write and edit, bash, grep, web search, subagents, permissions, and sessions, all included. You call it with a prompt and it drives everything. Take it when you want a batteries-included coding or filesystem agent on your own infra. It's a separate package with its own agent loop and architecture, not a wrapper over the tool-calling API.
The mistake to avoid: reaching for a full agent framework when the Tool Runner would do, or hand-rolling a loop when you actually wanted the Agent SDK's built-in tools.
Where the tools come from
You can define every tool by hand, as above. Once you have more than a handful, or want the same tools available to more than one agent, that stops scaling.
That's the problem the Model Context Protocol solves: tools live behind a server, and any compatible client can use them. Build the tool once, use it from anything. For a first agent, hand-defined tools are fine. Once you're writing the same integration twice, they aren't.
What actually breaks
The loop is not where production agents fail. Between February and June 2026, one platform I run executed 5,329 task-runs and 109 of them errored out, a 98% success rate. Almost none of those 109 were the model reasoning badly. They were third-party APIs timing out, rate limits, and credentials expiring mid-run.
Design for that. The specific failures worth handling before you ship:
Tool errors are results, not exceptions. When a tool fails, return a
tool_result with is_error: true and a message describing what went wrong. The
model reads it and adapts, usually by trying a different approach. Crash instead and you've thrown away its
ability to recover.
Server-side tools can pause. If you use hosted tools like web search, a long turn can come
back with stop_reason: "pause_turn" rather than finishing. Re-send the conversation with the
paused assistant turn appended and it resumes. Don't add a "continue" message, because the API detects the
pause itself. Handle this or you get silently truncated answers with no error.
Cap the loop. Set a maximum iteration count. An agent that misreads a tool result can otherwise retry forever, and it bills every attempt.
Model the cost on attempted runs, not successful ones. A run that retries twice costs up to three times the tokens of a clean one. I broke the full economics down separately in what an AI agent costs.
Start smaller than you think
The advice that has held up across every agent I've built: pick the task where the input and output are obvious and a mistake is cheap, put a human on the result, and watch the error rate for a month. If the errors are integration noise rather than bad reasoning, the model is trustworthy and your effort belongs in the plumbing.
The second agent costs a fraction of the first, because the integrations and the permission boundaries are already built. Start with the narrowest task you have and watch the error rate for a month.
Frequently asked questions
How do I build my own AI agent?
An agent is a loop: send the model a request with tool definitions, check whether it returned tool calls, execute them, hand the results back keyed by tool_use_id, and repeat until it stops. The loop itself is about twenty lines. The engineering is in the tool definitions, error handling, and deciding whether the task needs an agent rather than a simpler workflow.
Can you build an AI agent without a framework?
Yes, and for a first agent it's usually the better way to learn. A complete agent loop is roughly twenty lines against the API directly. Frameworks become worthwhile when you want the loop driven for you, built-in filesystem and shell tools, or managed hosting.
What is the difference between an AI agent and a workflow?
In a workflow your code controls the sequence and the model fills in individual steps. In an agent the model decides what to do next and which tools to call. Workflows are cheaper, faster, and easier to debug, so use one whenever the sequence is known in advance.
Why is my agent not using its tools?
Almost always the tool description. The model decides whether to call a tool from its description, so a vague one-liner produces an agent that ignores it. Say explicitly when the tool should be called, describe every parameter, and state what it returns.