Home / Blog / AI agents

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"))
The agent loop, in full Your requestplus tool definitions Model decidesreturns stop_reason You run the toolyour code, your infra tool_result backkeyed by tool_use_id Donestop_reason end_turn end_turn tool_use loop scalably.io
The happy path has two exits: another tool call, or 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.

ApproachYou writeRuns on
Manual loopThe loop aboveYour infra
Tool RunnerJust the tool functionsYour infra
Managed AgentsAgent config, tool resultsAnthropic's infra (or your own sandbox)
Claude Agent SDKA prompt and optionsYour 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.