Build an AI Agent from Scratch — No Framework, Just Python
If you can read a while loop, you can understand every line of an agent.
Frameworks like LangChain, CrewAI and AutoGen are great for shipping but terrible for learning. When you write Agent(executor=…).run(), a black box makes a hundred design decisions for you and hides every single one. The surprising truth? There is no deep machinery inside that box. The whole “agent” idea fits in a single while loop.
This article builds that loop from scratch: a real, working AI agent you can talk to, that calculates, reads files and searches the web — and you will understand every line. No abstractions. No frameworks. Just Python, the OpenAI API, and ~300 lines of code.
By the end you will also see how two of these agents, each with a different personality, can negotiate against each other in a game. Everything is in github.com/juustesout/python-agents-example.
1. Installatie & Setup
The agent uses the OpenAI Chat Completions API, duckduckgo-search for web lookups, and python-dotenv to load secrets. That is the entire dependency list.
# requirements.txt
openai>=1.40.0
python-dotenv>=1.0.0
ddgs>=9.0.0
Clone the repo and create a virtual environment:
git clone https://github.com/juustesout/python-agents-example.git
cd python-agents-example
python -m venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
pip install -r requirements.txt
API keys in .env
Copy the example env file and add your OpenAI key:
cp .env.example .env
Open .env and set:
OPENAI_API_KEY=sk-proj-your-key-here
OPENAI_MODEL=gpt-4o-mini # optional, this is the default
AGENT_VERBOSE=1 # set to 0 to silence trace output
The .gitignore already has **/.env so your key stays local. python-dotenv loads the file automatically when the agent starts — you never have to export anything.
2. Project Structure
The repo is deliberately flat. Two examples, one shared core:
python-agents-examples/
├── 01_mini_agent/
│ ├── agent.py # The core loop + 3 built-in tools
│ ├── demo.py # Entry point with example prompts
│ └── sample.txt # A test file for the file reader tool
├── 02_agent_vs_agent/
│ └── game.py # Two agents with opposing personalities negotiate
├── .env.example # Copy to .env, add your key
└── requirements.txt
01_mini_agent is a complete, single-file agent (~190 lines of real code wrapped in ~500 lines of comments). You can read it in one sitting and understand every line.
02_agent_vs_agent instantiates the exact same MiniAgent class twice — the only difference is the system prompt — and drops them into a turn-based negotiation game over $100.
3. The Core Loop — How Every Agent Works
Here is the whole trick, in four steps:
- The model receives the full conversation history plus a list of tool schemas (name, description, expected JSON arguments).
- The model replies with either plain text or a structured tool call like
{"name": "calculator", "arguments": {"expression": "2 ** 10"}}. - If it is a tool call, you — not the framework, not the model — run the matching Python function and append the result as a
role="tool"message. - Repeat from step 1 until the model returns plain text. That text is the final answer.
That is it. The loop model → tool → result → model → … → answer is the entire idea behind every agent framework ever built.
The MiniAgent class
The agent is built around three things: a list of messages, a registry of tools, and a lazy OpenAI client. No hidden state, no bookkeeping.
class MiniAgent:
def __init__(self, system_prompt, model=None,
temperature=0.7, max_tool_iters=10, verbose=True):
self.messages = [] # the only "memory" — a list of JSON dicts
self.tools = {} # name -> Tool registry
self._client = None # lazy OpenAI client
...
The run() method — the heart
This is the loop that makes the agent “agentic”:
def run(self, user_prompt, *, reset=True):
if reset or not self.messages:
# Fresh conversation: system prompt + user message
self.messages = [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": user_prompt},
]
else:
# Continue existing conversation (the "memory" trick)
self.messages.append({"role": "user", "content": user_prompt})
for iteration in range(1, self.max_tool_iters + 1):
reply = self._call_model() # send history + tool schemas
if reply.tool_calls:
self._handle_tool_calls(reply.tool_calls) # execute tools
continue # loop back
# No tool calls → final answer
self.messages.append({"role": "assistant", "content": reply.content})
return reply.content
raise RuntimeError(f"No answer after {self.max_tool_iters} iterations.")
Every iteration prints a trace in color so you can literally watch the agent think, call a tool, read the result, and decide what to do next — in real time.
How _call_model() works
The agent converts its tool registry into OpenAI’s JSON schema format and sends it alongside the conversation:
def _call_model(self):
tool_schemas = [tool.to_openai_schema()
for tool in self.tools.values()]
response = self._client.chat.completions.create(
model=self.model,
messages=self.messages,
tools=tool_schemas or None,
tool_choice="auto" if tool_schemas else None,
)
return response.choices[0].message
Key insight: a “tool schema” is just a plain JSON object describing a Python function. The model does not run anything — it only requests a call. You run it.
How tool results get back into context
This is where the API contract gets specific. The assistant message carrying the tool call must be appended before the results, and every result must include the matching tool_call_id:
def _handle_tool_calls(self, tool_calls):
# 1. Record the assistant message with the tool call requests
self.messages.append({
"role": "assistant",
"content": None,
"tool_calls": [
{"id": call.id, "type": "function",
"function": {"name": call.function.name,
"arguments": call.function.arguments}}
for call in tool_calls
],
})
# 2. Execute each tool and append its result
for call in tool_calls:
result = self._execute_tool(call.function.name,
call.function.arguments)
self.messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
This is where most home-grown agents fail: the API will reject your next request unless role="tool" messages are correctly paired with the tool_call_id from the assistant message.
4. Tools — Just Python Functions with a JSON Schema
A tool is nothing more than a Python function plus metadata. The Tool class bundles them together:
@dataclass
class Tool:
name: str
description: str
parameters: dict # JSON Schema
func: Callable[..., str]
def to_openai_schema(self):
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
When the model calls a tool, _execute_tool looks it up in the registry, parses the JSON arguments and calls the function:
def _execute_tool(self, name, arguments_json):
tool = self.tools.get(name)
args = json.loads(arguments_json or "{}")
result = tool.func(**args) # Just a Python call!
return str(result)
That is it. Swapping a tool for a different API is a one-line change. Adding a new tool is one function, one schema and one call to agent.add_tool().
Tool 1: Calculator (safe evaluation with ast)
The calculator is a great example of why eval() is dangerous and ast is safe. The model could trick eval("__import__('os').system('rm -rf /')") but ast only allows whitelisted node types:
def calculator(expression: str) -> str:
bin_ops = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
ast.Pow: operator.pow,
}
constants = {"pi": math.pi, "e": math.e}
def evaluate(node):
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return node.value
raise ValueError("only numbers allowed")
if isinstance(node, ast.Name):
if node.id in constants:
return constants[node.id]
raise ValueError(f"unknown constant '{node.id}'")
if isinstance(node, ast.BinOp):
op = bin_ops.get(type(node.op))
return op(evaluate(node.left), evaluate(node.right))
if isinstance(node, ast.UnaryOp):
op = unary_ops.get(type(node.op))
return op(evaluate(node.operand))
raise ValueError(f"construct {type(node).__name__} disallowed")
tree = ast.parse(expression, mode="eval")
return str(evaluate(tree.body))
The whitelist is the safety mechanism. The AST can only contain node types that have a handler in evaluate(). Any other construct — function calls, imports, attribute access — raises before it can execute.
Tool 2: Safe file reader (path traversal defence)
Reading a file seems simple until the model tries ../../etc/passwd. The guard is Path.resolve() combined with is_relative_to():
def read_local_file(filepath: str) -> str:
base = _DEFAULT_BASE_DIR.resolve() # e.g. /home/user/project/
target = (base / filepath).resolve() # resolve .. and symlinks
if not target.is_relative_to(base): # escaped the sandbox?
return "Error: path traversal blocked."
if not target.is_file():
return "Error: file not found."
if target.stat().st_size > _MAX_FILE_BYTES:
return "Error: file too large."
return target.read_text(encoding="utf-8", errors="replace")
is_relative_to() is the key. Even if the model constructs a path like ../../../../etc/passwd, after resolve() the absolute path falls outside base and the tool refuses.
Tool 3: Web search (zero-API-key via DuckDuckGo)
DuckDuckGo’s search API requires no key at all. The agent tries two import paths (the package was renamed from duckduckgo_search to ddgs):
try:
from ddgs import DDGS
except ImportError:
try:
from duckduckgo_search import DDGS
except ImportError:
DDGS = None
def web_search(query: str, max_results: int = 5) -> str:
if DDGS is None:
return "Error: install duckduckgo-search package."
results = list(DDGS().text(query, max_results=max_results))
return "\n\n".join(
f"{i}. {r['title']}\n {r['href']}\n {(r.get('body') or '')[:200]}"
for i, r in enumerate(results, start=1)
)
Swap DuckDuckGo for SerpAPI, Tavily or Google Search in one line — the tool interface never changes, only the implementation.
Registering tools on the agent
Every tool gets added through a single method that expects a name, description, JSON schema and the Python function:
agent.add_tool(
name="calculator",
description="Evaluate a math expression. Supports + - * / // % ** pi e.",
parameters={
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The expression to evaluate.",
}
},
"required": ["expression"],
},
func=calculator,
)
Three tools, three calls. The pattern is identical for every tool you will ever add.
5. Running the Agent
The demo runner at 01_mini_agent/demo.py exercises all three tools with built-in prompts:
# Default: runs the four built-in demo prompts
python 01_mini_agent/demo.py
# Custom prompt
python 01_mini_agent/demo.py "What is the speed of light times 3600?"
Example session (real output, real agent):
USER: What is 2 ** 10 + 1000? Use the calculator.
[agent] iteration 1: calling the model...
[agent] -> calculator({"expression": "2 ** 10 + 1000"})
[agent] <- result: '2024'
[agent] iteration 2: calling the model...
[agent] final answer received.
FINAL ANSWER: The result of 2^10 + 1000 is 2024.
---
USER: Search the web for the speed of light, then
calculate how far it travels in one hour.
[agent] iteration 1: calling the model...
[agent] -> web_search({"query": "speed of light"})
[agent] <- result: '1. Speed of light\n ...
299,792,458 m/s'
[agent] iteration 2: calling the model...
[agent] -> calculator({"expression": "299792458 * 3600"})
[agent] <- result: '1079252848800'
[agent] iteration 3: calling the model...
[agent] final answer received.
FINAL ANSWER: Light travels approximately 1.08 trillion meters
(1,079,252,848,800 m) in one hour.
The trace is the loop. You see every model call, every tool execution and every intermediate result. That transparency is the whole point of building from scratch — no black box, no surprises.
6. Two Agents, One Conversation: Multi-Agent Negotiation
If one agent is a while-loop, multi-agent is just two copies of the same loop talking to each other. The “agent vs. agent” example at 02_agent_vs_agent/game.py is the cleanest example of that.
Two instances of MiniAgent, one system prompt each, take turns proposing a split of $100:
POT = 100 # dollars to split
MAX_TURNS = 8 # before the game is called off
alice = MiniAgent(
system_prompt="You are ALICE, the Rational Negotiator. ...",
temperature=0.6, verbose=False)
bob = MiniAgent(
system_prompt="You are BOB, the Bold Trader. ...",
temperature=0.6, verbose=False)
The two prompts are the only difference. Alice is instructed to be fair, evidence-driven and principled. Bob is told to bluff, use pressure tactics and maximise his share. Same code, different personality — which tells you everything about what “personality” really is in an AI agent: just text.
The game loop
The game is a simple ultimatum game. The first proposer (Alice) puts a split on the table. The other player can accept (deal done), reject (both get $0) or counter (roles swap). Each agent’s move is parsed from free-form model output with a regex:
_ACTION_RE = re.compile(r"(OFFER|ACCEPT|COUNTER|REJECT)", re.IGNORECASE)
def parse_move(text):
match = _ACTION_RE.search(text)
if match is None: return None
action = match.group(1).upper()
if action in ("ACCEPT", "REJECT"):
return {"action": action}
numbers = _NUMBERS_RE.findall(text)
keep = int(numbers[0]) if numbers else None
return {"action": action, "keep": keep}
Running the game
python 02_agent_vs_agent/game.py # verbose: shows raw model text
python 02_agent_vs_agent/game.py --quiet # only parsed moves
Example session (2 turns, quiet mode):
Alice opens the negotiation...
Alice -> Alice keeps $50, gives $50 # fair start
Bob -> Bob keeps $70, gives $30 # aggressive counter
Alice -> Alice keeps $40, gives $60 # concedes but no deal
No deal after 2 turns. Impasse.
With --verbose you see the raw model text, which is often more entertaining:
Bob says: "OFFER 70 30. Take it or leave it, Alice.
I've got other deals lined up."
What makes this example powerful is the lesson: multi-agent “collaboration” is not a framework feature. It is two single-agent loops passing messages through a shared state. The protocol (OFFER/ACCEPT/COUNTER/REJECT) is just text matching. You can build any multi-agent system on the same pattern.
7. Putting It All Together
Here is the complete checklist to build your own agent from scratch:
- One API client — OpenAI, Anthropic, whatever. The loop does not care which SDK you use; only the wire format of tool calls differs.
- A message list — system prompt, user messages, assistant replies, tool results. That list is the agent’s memory.
- A tool registry — a dict mapping names to Python functions. Each function is described by a JSON schema the model can read.
- A while-loop — send the message list + tool schemas to the model. If the model requests a tool call, execute it, append the result, loop. If it returns text, stop and return it.
- Multi-agent — run two copies of the same loop with different system prompts. Define a message protocol. Let them talk.
That is all of it. Frameworks add caching, streaming, retries, GUIs and marketing — but underneath every single one is this same loop. Once you have written it by hand, every framework becomes obvious (and often visibly over-engineered).
Going Further
- Swap the model — change
OPENAI_MODELor themodelparameter togpt-4o,gpt-4.1-mini, or any model that supports function calling. - Add your own tools — write a Python function, write a JSON Schema, call
agent.add_tool(). That is three lines. - Use Anthropic instead of OpenAI — the same loop works with
anthropicSDK. The only difference is that Claude uses atool_use/tool_resultcontent block format instead oftool_calls. - Persist conversations — save
agent.messagesto a JSON file and reload it. That is all “memory” is. - Extend the negotiation — more players, different rules, a marketplace. You own the game logic.
The repo is at github.com/juustesout/python-agents-example. Clone it, open agent.py, and read the whole thing in one sitting. You already know enough.