"Agent" has become the word for any program that calls an LLM more than once, which makes it a word worth being precise about. An agent, in the sense this post uses, is a loop: the model decides which tool to call next, your code executes it, and the result feeds back in — repeating until the model decides it's done. That's a genuinely different (and riskier) shape than a single request/response call.
This post builds on Building Reliable LLM Applications in Python: everything said there about retries, structured output, and evaluation still applies once you add a loop — it just applies to every iteration, and now the model is also choosing which side effects to trigger. We'll cover when an agent is actually warranted, the loop itself (manual and SDK-assisted), and the safety controls that make handing a model the wheel defensible.
Reach for an agent only when the task is genuinely multi-step and open-ended: the number and order of actions can't be known ahead of time, so a fixed pipeline can't express it. Most tasks that feel agentic are actually better served by something simpler and more debuggable. There's a ladder, and you should stop climbing it the moment the task is satisfied:
Before building step 3, run the task past four checks. If any answer is "no," stay at step 1 or 2:
An agent is a deliberate escalation, not a default. Most production LLM features never need one.
Once an agent is warranted, the shape is the same regardless of the tools involved: call the model with a list of available tools; if it responds asking to use one (stop_reason == "tool_use"
), execute that tool in your own code and send the result back as a tool_result
; repeat until the model responds with end_turn
. Two ways to run that loop in Python — write it by hand for full control, or let the SDK's tool runner drive it for you.
Writing the loop yourself means every tool call passes through your code before it executes, which is where you validate arguments, log the decision, and gate anything irreversible:
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env — never hardcode
MAX_ITERATIONS = 10
messages = [{"role": "user", "content": user_input}]
iterations = 0
while True:
iterations += 1
if iterations > MAX_ITERATIONS:
raise RuntimeError("Agent exceeded iteration cap — stopping")
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
thinking={"type": "adaptive"},
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
break
tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for tool in tool_use_blocks:
result = execute_validated_tool(tool.name, tool.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
final_text = next(b.text for b in response.content if b.type == "text")
Two things earn their keep here that a convenience runner would hide: the MAX_ITERATIONS
cap, and the log point right before the tool result round-trip. Both are cheap to add and expensive to retrofit after an agent has looped in production for an hour.
When you don't need to intercept every call — a low-stakes, read-only agent, or a prototype — the beta tool runner drives the same loop for you. Decorate a plain function with @beta_tool
; its docstring becomes the tool description the model sees:
from anthropic import beta_tool
@beta_tool
def get_weather(location: str) -> str:
"""Get current weather for a location.
Args:
location: City and state, e.g. San Francisco, CA.
"""
return f"Sunny, 72°F in {location}"
runner = client.beta.messages.tool_runner(
model="claude-opus-4-8",
max_tokens=16000,
tools=[get_weather],
messages=[{"role": "user", "content": "Weather in Paris?"}],
)
for message in runner:
... # each iteration is a BetaMessage; loop ends when Claude is done
The trade-off is explicit: the runner is fewer lines, but your validation and approval logic has to live inside the tool function rather than at a single choke point between the model and execution. For anything past a read-only demo, the manual loop's explicit checkpoint is worth the extra code.
The loop's shape — how many iterations are allowed, what counts as done, how a failed tool call is retried — belongs in Python, not in a system prompt asking the model to "keep trying until it works." As covered in Building Reliable LLM Applications in Python, use the model for judgment (which tool, with what arguments, when to stop) and code for bookkeeping (the loop, the retry policy, the cap, the audit log). An agent that reasons its own way through retry logic in natural language is slower, more expensive, and less predictable than an except
block that already knows what to do with a transient failure.
Free-text hand-offs between agent steps are where errors compound silently — a slightly malformed field from step two becomes a wrong argument in step three's tool call. Where a step's output needs to be used by the next step (not just displayed to a person), get it back as a validated, typed object instead of prose to re-parse:
from pydantic import BaseModel
class PlanStep(BaseModel):
action: str
done: bool
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=16000,
messages=[{"role": "user", "content": "What is the next step, and are we done?"}],
output_format=PlanStep,
)
step = response.parsed_output # a validated PlanStep, not a string to parse
if step.done:
... # stop the loop deterministically — no guessing from prose
A validated PlanStep
either parses or raises; there's no regex trying to guess whether the model meant "done" or "we're basically done."
An agent is a program that decides, at runtime, which of your functions to call and with what arguments — based on text it read. Treat every tool as an attack surface accordingly:
tool.input
(or a tool function's arguments) is model-provided data and must be treated as untrusted, exactly like a request body from the network. Whitelist allowed values, bound numeric ranges, and reject anything that doesn't fit the tool's contract MAX_ITERATIONS
(or a wall-clock timeout). Without one, a confused model can loop indefinitely, burning tokens and possibly retrying a failing tool call forever.response.usage
per turn and alert on runaway loops the same way you'd alert on a runaway retry storm.ANTHROPIC_API_KEY
via anthropic.Anthropic()
— no key ever appears in source, config committed to version control, or logs.