How to Build an AI Agent With Tool Calling in Python (2026)
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.
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
How to Use MLflow 3: Tracking, Model Registry and GenAI Tracing (2026)
Step-by-step MLflow 3.17 guide: run a tracking server on PostgreSQL and S3, log runs and LoggedModels, register models with aliases instead of stages, trace and evaluate a GenAI app with mlflow.genai, serve the champion model, and fix the errors you will actually hit.
12 minOct 20, 2026MLOps vs LLMOps vs AgentOps in 2026: What Carries Over, What's New
MLOps vs LLMOps vs AgentOps explained for 2026: how the lifecycle shifts from trained models to prompts to tool-using agents, what carries over (versioning, registries, CI gates, monitoring), what is genuinely new (evals, traces, tool permissions), and a current tool map.
13 minOct 20, 2026How to Write a Blameless Postmortem: Template and Examples (2026)
A step-by-step guide to writing a blameless postmortem: when one is required, building the timeline from evidence, contributing factors instead of one root cause, why the 5 Whys misleads, owned action items, the review meeting, and a copy-paste markdown template.
15 min