Your First Agent, No Framework
Build a complete agent in 100 lines of Python. No framework. No magic. Every line explained, including the ones where it breaks.
You have a system that can call tools, but it calls them once and stops. What if it looked at the result, decided that wasn’t enough, and tried again? That’s an agent — the entire concept is a while loop with an LLM inside it. Let’s build one.
The loop in 20 lines
Here is the skeleton of every agent ever built:
while steps < budget:
response = call_llm(messages)
if response.has_tool_calls:
result = execute_tool(response.tool_calls[0])
messages.append(tool_call + result)
steps += 1
else:
return response.content # Model decided it's done
That’s the core architecture. Every framework, every SDK, every “agent platform” I’ve looked at wraps some variation of it.
Read it line by line:
-
while steps < budgetprevents infinite loops. Without one, a confused model calls tools forever — not theoretical, it happens on your first real task. -
call_llm(messages)sends the full conversation, past tool calls and results included, back to the model each turn. -
if response.has_tool_callsis the decision point: act, or answer. No third option. -
execute_tool()/messages.append()run the model’s requested function locally and feed the result back, so the model “observes” what happened and decides whether to act again. -
return response.contentends the loop cleanly: the model had enough and answered instead of calling again.
The loop implements observe-think-act-repeat, a cycle with a name in the literature — ReAct, Reason + Act (Yao et al., 2022) — that predates the paper. Rao and Georgeff described the same shape as a “main interpreter loop” for belief-desire-intention agents in 1995: generate options, deliberate, update intentions, execute, repeat. A control loop with a language model in the middle, decades older than the model itself.
Building it step by step
The skeleton above is pseudocode. The real thing lives in src/ch00/raw_agent.py, about 100 lines. To run it yourself from the repo root:
export ANTHROPIC_API_KEY="your-key-here"
python -m src.ch00.raw_agent "What is 15 * 7 + 3?"
With a key set, this hits the real API and produces the trace below. No key? It still exits cleanly, replaying a canned response for this question. We’ll walk through the code in pieces first.
The system prompt
The system prompt is how you tell the model what kind of agent it should be. Here’s the one from the companion code:
SYSTEM_PROMPT = (
"You are a research assistant with access to tools. "
"Use the available tools to answer the user's question accurately. "
"When you have enough information to answer fully, respond with plain text. "
"Do not call tools unnecessarily -- stop as soon as you can give a good answer."
)
Notice what it doesn’t say. No tool usage instructions (schemas handle that), no tool list (the registry provides that), no step count (the budget handles that in code). The prompt handles intent. Code handles constraints.
The result type
Before the loop, we need a place to put what comes out of it:
@dataclass
class AgentResult:
"""The outcome of a single agent run."""
answer: str | None
steps: int
total_tokens: int
total_cost_estimate: float
elapsed_ms: float
budget_exhausted: bool
trace: list[dict] = field(default_factory=list)
The agent class
Here is the Agent itself, with the core loop:
class Agent:
def __init__(
self,
client: ModelClient,
registry: ToolRegistry,
max_steps: int = 5,
system_prompt: str = SYSTEM_PROMPT,
) -> None:
self.client = client
self.registry = registry
self.max_steps = max_steps
self.system_prompt = system_prompt
Four dependencies: client talks to the model, registry holds the tools, max_steps is the budget, system_prompt is overridable. No inheritance, no plugin system, just constructor arguments.
Now the run method:
async def run(self, user_query: str) -> AgentResult:
messages: list[Message] = [
Message(role=Role.SYSTEM, content=self.system_prompt),
Message(role=Role.USER, content=user_query),
]
tool_schemas = self.registry.get_schemas()
trace: list[dict] = []
total_tokens = 0
steps = 0
for step in range(self.max_steps):
steps = step + 1
request = CompletionRequest(messages=messages, tools=tool_schemas)
response = await self.client.complete(request)
if response.usage:
total_tokens += response.usage.total_tokens
# A turn can carry several tool calls; execute all, return all
# results in one following message.
if response.tool_calls:
results: list[ToolResult] = []
for tc in response.tool_calls:
tool_result = execute_tool_call(self.registry, tc.name, tc.arguments)
results.append(ToolResult(tool_call_id=tc.id, name=tc.name, content=tool_result))
trace.append({
"type": "tool_call",
"step": steps,
"tool": tc.name,
"arguments": tc.arguments,
"result": tool_result,
})
messages.append(
Message(role=Role.ASSISTANT, content="", tool_calls=list(response.tool_calls))
)
messages.append(
Message(role=Role.TOOL, content="", tool_results=results)
)
continue
# Model returned a text answer.
if response.content:
trace.append({
"type": "response",
"step": steps,
"content": response.content,
})
return AgentResult(
answer=response.content,
steps=steps,
total_tokens=total_tokens,
total_cost_estimate=0.0,
elapsed_ms=elapsed_ms,
budget_exhausted=False,
trace=trace,
)
# Budget exhausted.
return AgentResult(
answer=None,
steps=steps,
total_tokens=total_tokens,
total_cost_estimate=0.0,
elapsed_ms=elapsed_ms,
budget_exhausted=True,
trace=trace,
)
Two things here are easy to get wrong, and both pass against a mock and 400 against a real model.
for tc in response.tool_calls loops over every tool call in the turn, not just the first. A single turn can request more than one tool at once; grab only response.tool_calls[0] and every call after the first is silently dropped.
messages.append() happens twice per turn, not twice per tool call: once for the assistant’s turn with the real tool_calls list, once for a tool-result turn with every result together, matched by ID. Serialize the call as plain text instead, and a mock client accepts it happily; a real model looks for the matching ID and fails when it isn’t there.
The final return is unchanged: budget exhausted, answer is None, budget_exhausted is True.
Run it on a real task
Give it a question needing two tool calls: “What is 15 * 7 + 3?”
It can’t do this in one step — multiply first, then add. Here’s the trace, copied straight from a real run, not retyped:
$ python -m src.ch00.raw_agent "What is 15 * 7 + 3?"
Query: What is 15 * 7 + 3?
Answer: '15 * 7 + 3 = 108.0'
Steps: 3
Total tokens: 195
Budget exhausted:False
Trace (3 entries):
[1] tool_call calculator({'operation': 'multiply', 'a': 15, 'b': 7}) -> '105.0'
[2] tool_call calculator({'operation': 'add', 'a': 105, 'b': 3}) -> '108.0'
[3] response '15 * 7 + 3 = 108.0'
Three steps, three model calls, three decisions: multiply, then add, then synthesize. It could have done this math in its head; it used the calculator because we told it to and gave it one. In production, the tools do things the model genuinely cannot, and the pattern doesn’t change.
Notice how cheap this was: three model calls, 195 tokens, about $0.001. Cost becomes meaningful at scale (10,000 queries a day) or with many more steps per query — both happen in production.
Watch it fail
The demo works. Now break it. These are not edge cases — they’re the default behavior of an unsupervised agent.
Failure 1: The infinite loop
Give it a vague, open-ended task: “Search for everything ever written about artificial intelligence.”
Query: Search for everything ever written about AI.
[Step 1] tool_call search({"query": "AI history"})
result: [{"title": "Result 1 for 'AI history'", ...}]
[Step 2] tool_call search({"query": "AI future predictions"})
result: [{"title": "Result 1 for 'AI future predictions'", ...}]
[Step 3] tool_call search({"query": "AI ethics and safety"})
result: [{"title": "Result 1 for 'AI ethics and safety'", ...}]
Answer: None
Steps: 3
Budget exhausted: True
The model never stopped searching, kept finding new facets, and ran out of budget before synthesizing an answer. Budget of 3, three wasted calls; budget of 50, fifty.
Failure 2: The hallucinated tool call
The model invents a tool that doesn’t exist — its training data includes functions your registry doesn’t have.
Query: What is the weather in London?
[Step 1] tool_call weather({"city": "London"})
result: "Error: unknown tool 'weather'"
[Step 2] response "I'm sorry, I don't have access to a weather
tool. I can't check the current weather."
Answer: "I'm sorry, I don't have access to a weather tool."
Steps: 2
Budget exhausted: False
The model decided it needed a weather API and called weather with reasonable-looking arguments. The function doesn’t exist. execute_tool_call returned a structured error instead of crashing; the model read it and explained the limitation gracefully.
Failure 3: The confident wrong answer
The hardest failure to catch: the model stops early with a wrong answer that sounds confident.
Query: What is the population of the largest city in Australia?
[Step 1] response "The largest city in Australia is Sydney,
with a population of approximately 5.3 million."
Answer: "The largest city in Australia is Sydney, with a
population of approximately 5.3 million."
Steps: 1
Budget exhausted: False
The model didn’t use the search tool. It answered from training data without checking — roughly right, outdated, or wrong, no way to tell from the trace. It made a judgment call (“I already know this”) and skipped verification. Every metric in the trace says success.
These are the default behaviors of an agent without engineering discipline, and what the rest of the book teaches you to prevent.
Add basic guardrails
Ten lines turn a fragile demo into something that fails gracefully — not production-ready, but no longer embarrassing.
Guardrail 1: The iteration budget
You already have this: the max_steps parameter caps the loop:
agent = Agent(client=client, registry=registry, max_steps=5)
Five is reasonable for simple tasks; complex research might need 10 or 15. Above 20 usually signals the task is too vague or the tools too narrow — reconsider the decomposition before raising the budget.
Guardrail 2: Input validation
Validate the user’s query with Pydantic before it enters the loop — the same validation from Section 0b, applied to the agent’s input:
from pydantic import BaseModel, Field
class AgentQuery(BaseModel):
query: str = Field(min_length=1, max_length=2000)
max_steps: int = Field(default=5, ge=1, le=20)
# Validate before running
validated = AgentQuery(query=user_input, max_steps=requested_steps)
result = await agent.run(validated.query)
Guardrail 3: Step logging
Print what happens at each step — minimum viable observability:
for step in range(self.max_steps):
steps = step + 1
response = await self.client.complete(request)
tokens_this_step = response.usage.total_tokens if response.usage else 0
total_tokens += tokens_this_step
print(f"[Step {steps}] tokens={tokens_this_step} total={total_tokens}")
if response.tool_calls:
# simplified to a single call for brevity -- the real loop above iterates all tool calls
tc = response.tool_calls[0]
print(f" -> tool_call: {tc.name}({tc.arguments})")
tool_result = execute_tool_call(self.registry, tc.name, tc.arguments)
print(f" <- result: {tool_result[:100]}")
# ... append to messages and continue
In production, replace print with structured logging — but print beats nothing. When the agent misbehaves at 2am, these logs mean a five-minute diagnosis instead of a blind guess.
This is 10% of production hardening. Chapter 6 gives you the other 90%: evaluation suites, cost tracking, retry policies, circuit breakers, structured observability. These three guardrails are the ones you add on day one.
Guardrail 4: Survive duplicate and slow calls
The loop sometimes calls the same tool twice after an unclear result — a truncated response or a timeout reads to the model like nothing happened, so it tries again. A tool can also hang: no exception, no result, just silence. Two more guardrails cover both.
First, a timeout budget per call. Give every tool call a deadline; if it doesn’t return in time, the loop treats it as a failed step instead of waiting on it forever.
Second, an idempotency key. Before running a tool, check whether this exact call already ran, keyed on the user, the tool name, and its normalized arguments:
key = (user_id, tool_name, normalized_args)
if key in already_done:
return already_done[key] # do not send the email twice
A repeat lookup returns the prior result instead of repeating the side effect. For search, a duplicate call is harmless. For send_email or charge_card, it’s the difference between one email and three.
The third rule isn’t code, it’s policy: if a repeat looks risky and no cached result exists to return, confirm before running it again instead of guessing.
Read-only tools can retry freely; side-effecting tools cannot. Section 0f goes deeper on execution safety.
The stop condition and the gate
The loop stops in exactly one place: if response.content:, when the model returns text instead of a tool call. Nobody verifies the answer or asks permission — it stops because the model said it’s done, and none of this chapter’s guardrails touch that decision. max_steps bounds how long it runs, validation bounds what comes in, logging tells you what happened after the fact. Nothing checks whether the model was right to stop or right to act, in the moment it mattered.
That bites hardest not as a wrong answer but as an unreviewed action. Picture delete_record sitting next to calculator and search: the model calls it with the same unverified confidence it uses to decide it’s done.
The companion registry now has exactly that tool, gated. execute_tool_call() checks a flag before dispatching anything:
if entry.requires_approval:
return (
f"approval_required: {tool_name} is gated; "
"a human or policy must approve this call"
)
Call delete_record and you get that string back, not a deletion. Three lines, not a policy engine, but the shape is real: the loop decides when to stop, the destructive action stops only when something outside the model says yes.
This is the argument FN-004 makes in full: the orchestration around agent loops is genuinely new, the stop condition underneath it is thirty years old and unsolved. Run the gated-versus-ungated contrast yourself, against a real agent, on the Bench.
The whole file, on GitHub
You’ve now seen every piece: system prompt, result type, agent class, run loop (parallel-call fix included), and the gate. Wired together, tool registry included, it’s about 100 lines. Read it top to bottom in ten minutes: src/ch00/raw_agent.py on GitHub. No hidden utilities, no imports doing the hard work for you.
This is the agent you compare against every framework you evaluate. In Section 0d, you rebuild it with Google ADK and LangChain: same tool logic, same system prompt, same failure modes. What changes is that tool registration, the agent loop, conversation state, and tracing all get automated. The hard engineering decisions don’t disappear, they move inside the framework — so when someone shows you a 500-line agent class with plugins and lifecycle hooks, you’ll know exactly what those lines wrap.
What you built, and what comes next
You just built an agent. It works. It also breaks in predictable ways. Guardrails and a gate help, but you still made judgment calls on gut feel: how big the budget, when to stop searching, whether this task needed an agent at all. Fine solo; it doesn’t scale to a team building agent systems on a shared codebase. Chapter 1 gives you the vocabulary to make these calls explicit — five system types, from single LLM calls through multi-agent orchestrations, and a decision framework for when a task needs the loop you just built versus a deterministic workflow. The rest of the book gives you the engineering to build whichever one you pick, for production.
For an expanded version with more tools, proper error handling, and example queries, see the Research Agent project.
Further reading
- ReAct: Synergizing Reasoning and Acting in Language Models — Yao et al., 2022. Named the observe-think-act cycle this chapter’s loop implements.
- “BDI Agents: From Theory to Practice” — Rao and Georgeff, 1995. The original belief-desire-intention loop, and the paper that framed the stop-and-reconsider problem.
- FN-004: Loop engineering is a 30-year-old loop with a new hashtag — Why the stop condition is still unsolved, and what an enforced gate looks like against a real agent, not a toy loop.