How to Build an AI Agent With Tool Calling in Python (2026)

Oct 20, 2026 · 19 min · Ajay Kumar

This guide is for engineers who want to understand an AI agent by building one, without a framework hiding the loop. At the end you will have about 300 lines of Python: three tools with JSON Schema definitions and a sandboxed executor, an agent loop on the Anthropic Messages API that handles every stop_reason, tool errors reported back to the model, a hard iteration cap and token budget, one JSON log line per model turn and tool call, and the same loop running against any OpenAI-compatible endpoint such as a local Ollama server. It was written against anthropic 1.15.0 and openai 3.35.1 (the latest PyPI releases on 20 October 2026), Python 3.12, and the claude-sonnet-5-5 model. You need an Anthropic API key, Python 3.10 or newer, and uv or pip. If you want the concepts first (workflows versus agents, ReAct, when not to build one), read the companion explainer, What Are AI Agents?.

Step 1: Set up the project with pinned SDKs

Pin both SDKs: they release often, and the agent loop is the code that breaks when a field moves.

mkdir tool-agent && cd tool-agent
uv venv --python 3.12 .venv
source .venv/bin/activate
uv pip install "anthropic==1.15.0" "openai==3.35.1"

# Sample workspace the agent is allowed to read
mkdir -p workspace/logs
printf 'ERROR db timeout after 30s\nhealth: https://example.com/\n' > workspace/logs/app.log

export ANTHROPIC_API_KEY="..."   # from the Claude Console; never commit it

Check the install:

python -c "import anthropic, openai; print(anthropic.__version__, openai.__version__)"
# 1.15.0 3.35.1

Step 2: Make one API call and confirm the model ID

Model IDs change every few months, and a wrong one is the most common first error. The current lineup on the models overview is claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5 and claude-haiku-5-5. From the 4.6 generation on, dateless IDs are pinned snapshots, not moving aliases. Ask the Models API which IDs your key can use:

python - <<'EOF'
import anthropic
client = anthropic.Anthropic()
for m in client.models.list(limit=20):
    print(m.id)
EOF

I default to Sonnet 5.5 for agents ($2 per million input tokens, $10 per million output) and move to Opus 5.5 ($4 / $20) only when evals say so. Both have a 1M-token context window and 128K max output.

Step 3: Define tools with JSON Schema and a safe executor

A tool is three things: a name, a description the model reads, and a JSON Schema for its arguments. Anthropic's define tools page calls detailed descriptions "by far the most important factor" in tool performance and suggests at least three or four sentences each. Setting "strict": true makes the API constrain the model's output to your schema, so you never receive "2" where you asked for an integer (strict tool use).

The executor matters as much as the schema. Every file path is resolved and checked against the workspace root, errors are raised as a ToolError with a message that tells the model what to do next, and run_tool never raises, so one bad tool call cannot kill the run. Save this as tools.py:

# tools.py: tool schemas plus the Python functions that implement them
import json
import os
import urllib.error
import urllib.request
from pathlib import Path

# Every file tool is confined to this directory. The model never sees anything outside it.
WORKSPACE = Path(os.environ.get("AGENT_WORKSPACE", "./workspace")).resolve()


class ToolError(Exception):
    """Raised for errors the model should see and can recover from."""


def _safe_path(rel: str) -> Path:
    p = (WORKSPACE / rel).resolve()
    if not p.is_relative_to(WORKSPACE):
        raise ToolError(f"Path {rel!r} is outside the workspace. Use a relative path such as 'logs/app.log'.")
    return p


def list_files(path: str = ".") -> str:
    p = _safe_path(path)
    if not p.is_dir():
        raise ToolError(f"{path!r} is not a directory. Call list_files with '.' to see the top level.")
    entries = sorted(e.name + ("/" if e.is_dir() else "") for e in p.iterdir())
    return json.dumps({"path": path, "entries": entries[:200], "truncated": len(entries) > 200})


def read_file(path: str, max_bytes: int = 20_000) -> str:
    p = _safe_path(path)
    if not p.is_file():
        raise ToolError(f"{path!r} does not exist or is not a file. Call list_files first.")
    data = p.read_bytes()[:max_bytes]
    return data.decode("utf-8", errors="replace")


def http_status(url: str) -> str:
    if not url.startswith(("https://", "http://")):
        raise ToolError("url must start with https:// or http://")
    req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "tool-agent/0.1"})
    try:
        with urllib.request.urlopen(req, timeout=10) as r:
            return json.dumps({"url": url, "status": r.status})
    except urllib.error.HTTPError as e:
        return json.dumps({"url": url, "status": e.code})  # a 404 is a result, not a tool failure
    except (urllib.error.URLError, TimeoutError) as e:
        raise ToolError(f"Could not reach {url}: {e}. Check the hostname or try again later.")


# Provider-neutral definitions: name, description, JSON Schema for the arguments.
TOOL_SPECS = [
    {
        "name": "list_files",
        "strict": True,  # inputs are constrained to the schema
        "description": (
            "List files and directories inside the agent workspace. Directories end with '/'. "
            "Use this before read_file when you do not know the exact path. Paths are relative "
            "to the workspace root; absolute paths and '..' are rejected. Returns at most 200 entries."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Relative directory path, '.' for the root."}
            },
            "required": ["path"],
            "additionalProperties": False,
        },
    },
    {
        "name": "read_file",
        "strict": True,  # inputs are constrained to the schema
        "description": (
            "Read a UTF-8 text file from the agent workspace and return its contents. "
            "Use for config files, logs and source code. Binary files come back with replacement "
            "characters. Output is capped at max_bytes, so request a larger value only if needed."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Relative file path, e.g. 'logs/app.log'."},
                "max_bytes": {"type": "integer", "description": "Maximum bytes to return (default 20000)."},
            },
            "required": ["path"],
            "additionalProperties": False,
        },
    },
    {
        "name": "http_status",
        "strict": True,  # inputs are constrained to the schema
        "description": (
            "Send an HTTP HEAD request to a URL and return the status code. Use it to check whether "
            "an endpoint mentioned in a file is up. It does not return the response body. "
            "Timeouts and DNS failures come back as errors."
        ),
        "input_schema": {
            "type": "object",
            "properties": {"url": {"type": "string", "description": "Full URL including https://"}},
            "required": ["url"],
            "additionalProperties": False,
        },
    },
]

REGISTRY = {"list_files": list_files, "read_file": read_file, "http_status": http_status}

MAX_RESULT_CHARS = 30_000  # keep one tool result from eating the context window


def run_tool(name: str, args: dict) -> tuple[str, bool]:
    """Execute a tool. Returns (content, is_error). Never raises."""
    fn = REGISTRY.get(name)
    if fn is None:
        return f"Unknown tool {name!r}. Available tools: {sorted(REGISTRY)}", True
    try:
        out = fn(**args)
    except ToolError as e:
        return str(e), True
    except TypeError as e:  # wrong or missing arguments
        return f"Bad arguments for {name}: {e}", True
    except Exception as e:  # a bug in the tool: tell the model, keep the loop alive
        return f"{type(e).__name__}: {e}", True
    if len(out) > MAX_RESULT_CHARS:
        out = out[:MAX_RESULT_CHARS] + f"\n[truncated {len(out) - MAX_RESULT_CHARS} chars]"
    return out, False

Test the executor without the model at all. This is the cheapest test you will ever write for an agent:

python -c "
from tools import run_tool
print(run_tool('list_files', {'path': '.'}))
print(run_tool('read_file', {'path': '/etc/passwd'}))
print(run_tool('read_file', {'paht': 'x'}))
print(run_tool('http_status', {'url': 'https://example.com/'}))
"

Output from my run:

('{"path": ".", "entries": ["logs/"], "truncated": false}', False)
("Path '/etc/passwd' is outside the workspace. Use a relative path such as 'logs/app.log'.", True)
("Bad arguments for read_file: read_file() got an unexpected keyword argument 'paht'", True)
('{"url": "https://example.com/", "status": 200}', False)

A 404 from http_status is a normal result, not an error: the tool worked, the endpoint is down. Keep that distinction; the model reasons differently about each.

Step 4: Write the agent loop

The loop sends the conversation, reads stop_reason, and acts on it. The Messages API returns seven values: end_turn, max_tokens, stop_sequence, tool_use, pause_turn, refusal and model_context_window_exceeded (handling stop reasons). Handle all of them explicitly, because the default branch is where agents silently go wrong.

The agent loop in agent.py Append tool_result same ids, is_error flag next iteration User task + TOOL_SPECS, SYSTEM messages.create() model, tools, messages Read stop_reason one branch per value tool_use run_tool() per block end_turn return the text answer Other stop reasons retry 2x, or stop cleanly Guards on every turn At most 12 iterations and a 200k-token budget SDK retries 429 and 5xx, 120 s request timeout
The control flow implemented in agent.py below. The loop only continues on tool_use; every other stop reason ends the run or retries once with more output room.

Two rules from the handle tool calls docs shape the code. Tool results must immediately follow the assistant message that requested them, and in that user message the tool_result blocks must come first. If the model asks for three tools in one turn, all three results go back in one message. And append the assistant's resp.content unchanged: on the 5.x models thinking is on by default, and the content can include thinking blocks alongside the tool calls. Save this as agent.py:

# agent.py: a tool-calling agent loop on the Anthropic Messages API
import json
import logging
import os
import sys
import time
import uuid

import anthropic

from tools import TOOL_SPECS, run_tool

MODEL = os.environ.get("AGENT_MODEL", "claude-sonnet-5-5")
MAX_ITERATIONS = int(os.environ.get("AGENT_MAX_ITERATIONS", "12"))
MAX_TOTAL_TOKENS = int(os.environ.get("AGENT_MAX_TOTAL_TOKENS", "200000"))
SYSTEM = (
    "You are an operations assistant. Use the tools to inspect the workspace and answer "
    "precisely. If a tool returns an error, read it and adjust your next call. "
    "When you have the answer, reply with it and stop calling tools."
)

logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
log = logging.getLogger("agent")


def emit(event: str, **fields) -> None:
    """One JSON object per line: easy to grep, easy to ship to Loki or an OTel collector."""
    log.info(json.dumps({"ts": round(time.time(), 3), "event": event, **fields}, default=str))


client = anthropic.Anthropic(max_retries=4, timeout=120.0)  # SDK retries 429/5xx with backoff


def run_agent(task: str) -> str:
    run_id = uuid.uuid4().hex[:8]
    messages = [{"role": "user", "content": task}]
    tokens_used = 0
    max_tokens = 8192
    emit("run_start", run_id=run_id, model=MODEL, task=task[:200])

    for step in range(1, MAX_ITERATIONS + 1):
        resp = client.messages.create(
            model=MODEL,
            max_tokens=max_tokens,
            system=SYSTEM,
            tools=TOOL_SPECS,
            messages=messages,
        )
        tokens_used += resp.usage.input_tokens + resp.usage.output_tokens
        emit("model_response", run_id=run_id, step=step, stop_reason=resp.stop_reason,
             request_id=getattr(resp, "_request_id", None), input_tokens=resp.usage.input_tokens,
             output_tokens=resp.usage.output_tokens)

        # Always append the full assistant content (text, thinking and tool_use blocks) unchanged.
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason == "end_turn":
            answer = "".join(b.text for b in resp.content if b.type == "text")
            emit("run_end", run_id=run_id, steps=step, tokens=tokens_used, outcome="answered")
            return answer

        if resp.stop_reason == "tool_use":
            results = []
            for block in resp.content:
                if block.type != "tool_use":
                    continue
                t0 = time.monotonic()
                content, is_error = run_tool(block.name, block.input)
                emit("tool_call", run_id=run_id, step=step, tool=block.name, args=block.input,
                     is_error=is_error, ms=round((time.monotonic() - t0) * 1000),
                     result_chars=len(content))
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": content,
                    "is_error": is_error,
                })
            # One user message, tool_result blocks only, one per tool_use block.
            messages.append({"role": "user", "content": results})

        elif resp.stop_reason == "max_tokens":
            # A truncated tool_use block cannot be executed: drop the turn, retry with more room.
            messages.pop()
            if max_tokens >= 32768:
                emit("run_end", run_id=run_id, steps=step, tokens=tokens_used, outcome="max_tokens")
                return "Stopped: the model kept hitting the output limit."
            max_tokens *= 2
            emit("retry_max_tokens", run_id=run_id, step=step, new_max_tokens=max_tokens)

        elif resp.stop_reason == "pause_turn":
            continue  # server-tool loop paused; resend the conversation as is

        elif resp.stop_reason == "refusal":
            emit("run_end", run_id=run_id, steps=step, tokens=tokens_used, outcome="refusal",
                 stop_details=getattr(resp, "stop_details", None))
            return "The model declined this request."

        else:  # stop_sequence, model_context_window_exceeded, anything new
            emit("run_end", run_id=run_id, steps=step, tokens=tokens_used,
                 outcome=f"stopped:{resp.stop_reason}")
            return "".join(b.text for b in resp.content if b.type == "text")

        if tokens_used > MAX_TOTAL_TOKENS:
            emit("run_end", run_id=run_id, steps=step, tokens=tokens_used, outcome="token_budget")
            return f"Stopped: token budget of {MAX_TOTAL_TOKENS} exceeded after {step} steps."

    emit("run_end", run_id=run_id, steps=MAX_ITERATIONS, tokens=tokens_used, outcome="max_iterations")
    return f"Stopped after {MAX_ITERATIONS} iterations without a final answer."


if __name__ == "__main__":
    print(run_agent(" ".join(sys.argv[1:]) or "What files are in the workspace?"))

Run it:

python agent.py "Read the app log, tell me the error, and check whether the health URL is up."

The answer goes to stdout and the JSON logs to stderr. The log format below comes from my test run with a mocked client (so the token counts are placeholders and request_id is null; against the real API it carries the req_... ID you quote to support):

{"ts": 1792467999.834, "event": "model_response", "run_id": "a5a36638", "step": 1, "stop_reason": "tool_use", "request_id": null, "input_tokens": 100, "output_tokens": 20}
{"ts": 1792467999.834, "event": "tool_call", "run_id": "a5a36638", "step": 1, "tool": "list_files", "args": {"path": "."}, "is_error": false, "ms": 0, "result_chars": 55}
{"ts": 1792467999.834, "event": "tool_call", "run_id": "a5a36638", "step": 1, "tool": "read_file", "args": {"path": "../etc/passwd"}, "is_error": true, "ms": 0, "result_chars": 90}

Step 5: Handle errors at three layers

Agents fail at three different layers, and each needs its own handling.

Tool errors go back to the model. When a tool fails, return the message with "is_error": true rather than raising. The docs say Claude will retry an invalid call two or three times with corrections before giving up, and recommend instructive messages ("Rate limit exceeded. Retry after 60 seconds.") over "failed". That is why every ToolError in tools.py ends with a suggestion.

API errors are retried by the SDK. The client above sets max_retries=4 (the default is 2) and a 120-second timeout. The SDK retries connection errors, 408, 409, 429 and 5xx responses with exponential backoff. Anything that still fails raises a typed exception, which you should catch at the edge of your service, not inside the loop:

import anthropic
from agent import run_agent

try:
    print(run_agent("Summarise workspace/logs/app.log"))
except anthropic.RateLimitError as e:        # 429 after all retries
    print("rate limited, request id:", e.response.headers.get("request-id"))
except anthropic.BadRequestError as e:       # 400: malformed history, bad tool schema, bad model ID
    print("bad request:", e.message)
except anthropic.APIConnectionError:
    print("network problem reaching the API")

Model-level stops are branches, not exceptions. If stop_reason is max_tokens and the last block is a half-written tool_use, the docs say to retry with a higher max_tokens; the loop drops that turn, doubles the budget up to 32,768, and tries again. A refusal ends the run and logs stop_details, which names the policy category. A pause_turn only appears with server tools (web search, code execution) and means "send the conversation back as is".

One more 400 to know about: Opus 5.5 and Sonnet 5.5 reject forced tool use. tool_choice of {"type": "any"} or a named tool returns an error on those models, so leave it at the default auto and rely on strict schemas plus the prompt.

Step 6: Cap iterations and tokens

An agent without a cap is a billing incident waiting for a confusing tool result. agent.py stops on whichever comes first: AGENT_MAX_ITERATIONS (default 12) model turns, or AGENT_MAX_TOTAL_TOKENS (default 200,000) input plus output tokens summed across turns. Because every turn resends the whole history, input tokens grow roughly quadratically with the number of turns, so the token budget usually trips before the iteration cap on long tasks. Test the guard deliberately:

AGENT_MAX_ITERATIONS=1 python agent.py "List every file, read each one, then check every URL you find."
# Stopped after 1 iterations without a final answer.   (when the first turn is a tool call)

Pick the defaults from data: set the cap a little above the 95th percentile step count of successful runs in your eval set. Runs that hit it are usually stuck, not nearly finished.

Step 7: Log every model turn and tool call

The emit() helper writes one JSON object per line to stderr: run_start, model_response (stop reason, request ID, token counts), tool_call (tool name, arguments, error flag, duration, result size) and run_end (outcome, steps, total tokens). That answers the day-two questions: why did this run cost so much, and which tool keeps failing?

Two cautions. Tool arguments and results can contain secrets or personal data, so redact before shipping logs anywhere shared. And once you have more than one service, move these events onto OpenTelemetry spans using the GenAI semantic conventions, which I covered in agent observability in 2026. Find all failing tool calls in a run with nothing more than jq:

python agent.py "Check the log" 2> run.jsonl
jq -c 'select(.event=="tool_call" and .is_error==true) | {step, tool, args}' run.jsonl

Step 8: Add a second provider through an OpenAI-compatible API

Most other model servers speak the OpenAI Chat Completions format: OpenAI itself, vLLM, LiteLLM and Ollama among them. The tool specs translate directly: input_schema becomes parameters inside {"type": "function", "function": {...}}. The loop differs in four places: the system prompt is a message, tool calls arrive in message.tool_calls with arguments as a JSON string, results go back as role: "tool" messages keyed by tool_call_id, and there is no is_error field, so the error is marked in the text. Save this as agent_openai.py:

# agent_openai.py: the same loop against any OpenAI-compatible Chat Completions endpoint
import json
import os
import sys
import time
import uuid

from openai import OpenAI

from agent import MAX_ITERATIONS, SYSTEM, emit
from tools import TOOL_SPECS, run_tool

BASE_URL = os.environ.get("OPENAI_BASE_URL", "http://localhost:11434/v1/")  # Ollama default
MODEL = os.environ.get("OPENAI_MODEL", "qwen3:8b")
oai = OpenAI(base_url=BASE_URL, api_key=os.environ.get("OPENAI_API_KEY", "ollama"), max_retries=4)

# Same specs, OpenAI wire format: {"type": "function", "function": {name, description, parameters}}
OPENAI_TOOLS = [
    {"type": "function", "function": {
        "name": t["name"], "description": t["description"], "parameters": t["input_schema"]}}
    for t in TOOL_SPECS
]


def run_agent_openai(task: str) -> str:
    run_id = uuid.uuid4().hex[:8]
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": task}]
    emit("run_start", run_id=run_id, model=MODEL, base_url=BASE_URL, task=task[:200])

    for step in range(1, MAX_ITERATIONS + 1):
        resp = oai.chat.completions.create(model=MODEL, messages=messages, tools=OPENAI_TOOLS)
        choice = resp.choices[0]
        msg = choice.message
        emit("model_response", run_id=run_id, step=step, finish_reason=choice.finish_reason,
             usage=resp.usage.model_dump() if resp.usage else None)

        if not msg.tool_calls:  # finish_reason "stop" (or "length": treat as final)
            emit("run_end", run_id=run_id, steps=step, outcome=choice.finish_reason)
            return msg.content or ""

        # Echo the assistant turn back, including its tool_calls, before the tool messages.
        messages.append({
            "role": "assistant",
            "content": msg.content,
            "tool_calls": [
                {"id": tc.id, "type": "function",
                 "function": {"name": tc.function.name, "arguments": tc.function.arguments}}
                for tc in msg.tool_calls
            ],
        })
        for tc in msg.tool_calls:
            t0 = time.monotonic()
            try:
                args = json.loads(tc.function.arguments or "{}")  # arguments arrive as a JSON string
            except json.JSONDecodeError as e:
                content, is_error = f"Arguments were not valid JSON ({e}). Resend the call.", True
            else:
                content, is_error = run_tool(tc.function.name, args)
            emit("tool_call", run_id=run_id, step=step, tool=tc.function.name, is_error=is_error,
                 ms=round((time.monotonic() - t0) * 1000))
            # No is_error field in this format, so prefix the text instead.
            messages.append({"role": "tool", "tool_call_id": tc.id,
                             "content": ("ERROR: " + content) if is_error else content})

    emit("run_end", run_id=run_id, steps=MAX_ITERATIONS, outcome="max_iterations")
    return f"Stopped after {MAX_ITERATIONS} iterations without a final answer."


if __name__ == "__main__":
    print(run_agent_openai(" ".join(sys.argv[1:]) or "What files are in the workspace?"))

Run it against a local Ollama server (its OpenAI-compatible endpoint is http://localhost:11434/v1/, and the API key is required by the client but ignored, per the Ollama docs):

ollama pull qwen3:8b
OPENAI_BASE_URL=http://localhost:11434/v1/ OPENAI_MODEL=qwen3:8b \
  python agent_openai.py "What files are in the workspace?"

Point OPENAI_BASE_URL and OPENAI_API_KEY at any other compatible server to swap providers. Ollama lists tools as supported but not tool_choice, which is another reason the loop never forces a tool. For a fleet of providers with budgets and fallbacks, put a gateway in front instead of growing this file; see the LiteLLM proxy guide.

Anthropic also accepts the OpenAI SDK at https://api.anthropic.com/v1/, but its compatibility docs describe it as a way to test and compare models, not a long-term production path. It ignores strict on function definitions, does not return thinking, and does not support prompt caching. Use the native SDK for Claude and the compatible path for everything else.

Step 9: Know when to use the SDK's Tool Runner instead

Once you understand the loop, you may not want to maintain it. The Python SDK ships a beta Tool Runner that derives schemas from type hints and docstrings and runs the loop for you, with a max_iterations bound (Tool Runner docs):

from anthropic import Anthropic, beta_tool

client = Anthropic()

@beta_tool
def read_log(name: str) -> str:
    """Read a log file from the workspace.

    Args:
        name: File name inside workspace/logs, e.g. app.log
    """
    from tools import run_tool
    content, _ = run_tool("read_file", {"path": f"logs/{name}"})
    return content

runner = client.beta.messages.tool_runner(
    model="claude-sonnet-5-5",
    max_tokens=4096,
    max_iterations=10,
    tools=[read_log],
    messages=[{"role": "user", "content": "What error is in app.log?"}],
)
for message in runner:
    print(message.stop_reason)

The docs recommend the manual loop for human approval steps, custom logging or conditional execution, which covers most production agents. If you need approvals, the LangGraph human-in-the-loop guide shows that pattern.

Troubleshooting

Symptom Likely cause Fix
400: tool_use ids were found without tool_result blocks immediately after A message was inserted between the tool request and its results, or one tool_use got no result Send exactly one tool_result per tool_use id, in the very next user message
400 when the user message has text plus results Text placed before the tool_result blocks Put all tool_result blocks first; any text goes after them
400 mentioning forced tool use tool_choice set to any or a named tool on Opus 5.5 or Sonnet 5.5 Use auto (the default) with strict: true schemas
404 or "model not found" Typo or retired model ID List IDs with client.models.list() and use one of them exactly
Agent loops until the cap, repeating one tool Error message gives the model nothing to act on, or the tool returns the same useless output Make ToolError messages say what to try next; log args to spot the repeat
KeyError or TypeError inside a tool kills the run Tool exception not caught Route every call through run_tool, which returns (message, True) instead of raising
Bill far higher than expected History resent every turn, large tool outputs Cap result size (MAX_RESULT_CHARS), lower the token budget, use prompt caching on the native API
json.JSONDecodeError on the OpenAI-compatible path Model produced invalid JSON in function.arguments Return the parse error as a tool message so the model resends; try a stronger tool-calling model
Ollama model never calls tools The model does not support tool calling Pick a model whose Ollama page lists tools support, such as the Qwen 3 family
RateLimitError after retries Account rate limit or bursty parallel runs Lower concurrency, read the retry-after header, raise max_retries

FAQ

Do I need a framework like LangChain or LangGraph to build an AI agent?

No. The loop is about 100 lines with the official SDK, and writing it yourself means you can see every prompt, response and tool call when something goes wrong. Frameworks earn their place for durable state, checkpoints and human approval flows; my comparison is in AI agent frameworks compared.

Which Claude model should I use for tool calling?

Start with claude-sonnet-5-5 for most agents and measure. Move to claude-opus-5-5 if your evals show it finishes more tasks, and consider claude-haiku-5-5 for high-volume routing or extraction steps. Check the models overview for current IDs, because they change several times a year.

How do I stop an AI agent from looping forever?

Combine an iteration cap, a token budget and a wall-clock timeout, and log why each run ended. Make tool errors instructive so the model can change course. If many runs hit the cap, fix the tool descriptions or split the task, rather than raising the limit.

Is it safe to let the agent run shell commands?

Not on your laptop or a shared server. Run any tool that executes code or shell commands inside an isolated sandbox with no credentials it does not need, and treat tool results from the web or files as untrusted input that may contain prompt injection. My overview of the options is in AI agent sandboxes explained.

When I review an agent before it goes anywhere near production, I check four things first: that every tool call goes through one executor that cannot raise, that there is a hard cap on iterations and tokens with a logged reason for every stop, that each tool has the narrowest permission that works, and that code-executing tools run in an isolated sandbox. (That last one is the problem PandaStack, my company, exists to solve, but any microVM or gVisor sandbox is far better than none.) The model is the part you do not control; the loop, the tools and the logs are entirely yours. Official references: Tool use overview, Define tools, Handle tool calls, Handling stop reasons and anthropic-sdk-python.

Related: AI Agent Sandboxes: What They Are and How to Choose One, Policy as Code for AI Agents in 2026: Cedar at the Gateway, Rego in the Hook, and the 11.2% the Model Still Lets Through and It's 2 AM. Do You Know What Your AI Agent Is Doing?.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related