Agents, With Just Python
Every piece, in one place, no framework. A from-scratch, example-driven walkthrough of what an "AI agent" actually is under the hood — a loop, a tool schema, and a model that decides what to call next — built with nothing but plain Python and an API client.
The Starting Point
Strip away the branding and an "agent" is a function you call in a loop: send some text to a model, get
text back, decide what to do with it, repeat. Nothing about that requires a framework — it requires a
while loop and a client for whichever model API you're calling.
Why No Framework First
Agent frameworks are useful once you know what they're automating. Learned in the wrong order, they hide the one thing worth understanding: that "the agent decided to call a tool" is just an if-statement reading a field out of the model's JSON response. Build the loop by hand once, and every framework afterward reads as a shortcut instead of a black box.
Calling a Real Model
Everything starts with a single request-response call — no tools, no loop, just a prompt in and text out.
from anthropic import Anthropic
client = Anthropic()
def ask(prompt: str) -> str:
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
return resp.content[0].text
print(ask("Convert 100 USD to INR"))
Right now the model can only guess at the exchange rate — it has no way to actually look one up. That gap is exactly what tools exist to close.
What Is a Tool
A tool is nothing more than a Python function the model is told about, in a format it can request by name. The model never executes your code — it only ever asks for it, by returning structured JSON that names a function and its arguments. Your code is the one that actually runs it.
A menu, not the kitchen
The model reads the tool list like a menu — a name, a description, and what arguments it takes. It never sees or touches the implementation behind it.
Just a function, nothing more mysterious
Under any agent framework's "tool" abstraction is a regular function with a docstring. That's the whole trick.
The Tool Schema
To let the model choose a tool, you describe it in a schema: a name, a plain-language description of when to use it, and the shape of its expected input.
convert_currency_tool = {
"name": "convert_currency",
"description": "Use whenever an amount needs converting from one currency to another.",
"input_schema": {
"type": "object",
"properties": {
"amount": {"type": "number"},
"from_currency": {"type": "string"},
"to_currency": {"type": "string"},
},
"required": ["amount", "from_currency", "to_currency"],
},
}
def convert_currency(amount, from_currency, to_currency):
rates = {"USD_INR": 87.4, "INR_USD": 1 / 87.4}
rate = rates.get(f"{from_currency}_{to_currency}")
return {"converted": round(amount * rate, 2)} if rate else {"error": "unsupported pair"}
Manual Tool Calling
Before wiring up the loop, call the tool by hand once — turning the idea into one real function call makes the next step (letting the model trigger it) far less abstract.
result = convert_currency(100, "USD", "INR")
print(result) # {'converted': 8740.0}
The Agent Loop
Decide, act, observe, repeat. The model is given the tool list on every turn; if it asks for a tool call, you run it and feed the result back in as part of the conversation, then call the model again. The loop ends when the model responds with a final answer instead of a tool request.
def run_agent(user_message: str, tools: list, tool_impls: dict, max_steps: int = 5) -> str:
messages = [{"role": "user", "content": user_message}]
for step in range(max_steps):
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
tools=tools,
messages=messages,
)
if resp.stop_reason != "tool_use":
return resp.content[0].text # final answer
messages.append({"role": "assistant", "content": resp.content})
tool_results = []
for block in resp.content:
if block.type == "tool_use":
output = tool_impls[block.name](**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": tool_results})
raise RuntimeError("Failed after max retries")
Letting It Decide
The model, not your code, chooses whether a tool is needed and which one — your job is only to describe the tools honestly and execute whatever it asks for. "Convert 100 USD to INR" triggers a tool call; "what's the capital of Japan" does not, because the model already knows the answer without reaching for a phone.
Memory
An agent's "memory" is nothing more than a growing list — the same messages
array from the loop above, replayed in full on every turn. There's no hidden state; the entire
conversation, including every tool call and its result, is just accumulated in that list and sent back
each time.
Structured Output
The same discipline that makes tool arguments trustworthy applies to the agent's final answer: ask for a form, not a paragraph. Constrain the response to a known JSON shape and validate it before your code acts on it — the same idea covered in a Pydantic-based reference guide, applied here to the agent's own output instead of a single API request.
Failing Loudly
An agent stuck in a bad loop — calling the same tool with the same bad arguments — should stop and raise,
not spin forever. A capped max_steps, plus a validation error surfaced back to
the model as its next input, turns silent failure into a visible, debuggable one.
Try a Mini Agent
A tiny, client-side version of the loop above — two toy tools, no live model. Ask it to convert a currency or do basic math and watch it "decide" which tool to reach for.
The Complete Agent
Putting it together: a model call, a tool schema, a loop that feeds results back in, and validation at every boundary. That's the whole mechanism — everything an agent framework adds on top is convenience, not a different idea. Once you've built one by hand, "agentic" stops being a buzzword and starts being a design pattern you can reason about.
More on RAG pipelines, self-correcting agentic workflows, and MCP tooling is linked below.