Build an AI Agent From Scratch: The Loop, Tools, and Memory, in Python
Quick answer
Every agent framework hides the same handful of lines: send tools, run what the model asks for, feed results back, repeat. Build that loop yourself in raw Python and you'll never look at a framework's internals as a black box again.
- Step 1: Write the Tools
- Step 2: Describe the Tools for the Model
- Step 3: Write the Loop
- Step 4: Run It
- A Note on Memory
intermediate · 21 min
Before you begin
- Python 3.10+ with `pip install openai`
- An OpenAI-compatible chat completions endpoint — either a local model via Ollama (see Run an LLM Locally with Ollama) or an OpenAI API key
- Basic familiarity with Python functions and JSON
A chatbot takes one message and returns one response. An agent does something structurally different: it runs a loop where the model can choose to call a tool, look at what that tool returned, and decide — on its own — whether it needs to call another tool or is ready to answer. That loop, not the model itself, is what "agent" actually means. Everything else — memory, planning, multi-agent coordination — is built on top of it.
Every agent framework you've heard of wraps this same loop in convenience code. This tutorial strips the convenience away and builds it directly: no LangChain, no LangGraph, just the OpenAI-compatible chat completions API and about sixty lines of Python. Once you've built it once by hand, you'll be able to read any framework's source and recognize exactly what it's doing for you. This is also the foundational pattern the rest of this site's applied agent tutorials — troubleshooting agents, multi-agent coordinators — build on top of.
What You'll Build
- Two tools,
list_filesandread_file, scoped to a sandboxed directory with real path-safety checks - JSON tool schemas describing those functions to the model
- A tool-calling loop with a hard iteration cap, run against a local Ollama model
- A working end-to-end trace: the agent deciding on its own to list a directory, read the right file, and answer a question about its contents
Step 1: Write the Tools
The tools are plain Python functions. The only part worth taking seriously is path safety — a tool that accepts a path from a model and doesn't check where it resolves to will happily read (or later, write) files outside the directory you meant to expose:
1# tools.py
2from pathlib import Path
3
4SANDBOX_ROOT = Path("./agent_sandbox").resolve()
5MAX_CHARS = 8_000
6
7
8def _safe_path(relative_path: str) -> Path:
9 candidate = (SANDBOX_ROOT / relative_path).resolve()
10 if not candidate.is_relative_to(SANDBOX_ROOT):
11 raise ValueError(f"'{relative_path}' resolves outside the sandbox")
12 return candidate
13
14
15def list_files(subdir: str = ".") -> str:
16 target = _safe_path(subdir)
17 if not target.is_dir():
18 return f"'{subdir}' is not a directory"
19 entries = sorted(p.name for p in target.iterdir())
20 return "\n".join(entries) if entries else "(empty directory)"
21
22
23def read_file(path: str) -> str:
24 target = _safe_path(path)
25 if not target.is_file():
26 return f"'{path}' does not exist or is not a file"
27 text = target.read_text(encoding="utf-8", errors="replace")
28 if len(text) > MAX_CHARS:
29 text = text[:MAX_CHARS] + "\n...[truncated]..."
30 return textPath.is_relative_to is doing the real work: _safe_path resolves the requested path against the sandbox root and rejects anything — ../../etc/passwd, an absolute path, a symlink pointing outward — that resolves outside it. This check has to run on the resolved path, after symlinks and .. segments are collapsed, or it's trivial to bypass.
Set up a sandbox to point it at:
mkdir -p agent_sandbox
cat > agent_sandbox/config.py << 'EOF'
DEBUG = False
TIMEOUT_SECONDS = 45
MAX_RETRIES = 3
EOFStep 2: Describe the Tools for the Model
Tool calling works because the model is given a JSON Schema description of each function alongside the conversation — it never sees your Python source, only this:
1# schema.py
2TOOLS = [
3 {
4 "type": "function",
5 "function": {
6 "name": "list_files",
7 "description": "List files and directories inside the sandbox, optionally under a subdirectory.",
8 "parameters": {
9 "type": "object",
10 "properties": {
11 "subdir": {
12 "type": "string",
13 "description": "Subdirectory relative to the sandbox root. Defaults to the root itself.",
14 },
15 },
16 "required": [],
17 },
18 },
19 },
20 {
21 "type": "function",
22 "function": {
23 "name": "read_file",
24 "description": "Read the full text contents of a file inside the sandbox.",
25 "parameters": {
26 "type": "object",
27 "properties": {
28 "path": {
29 "type": "string",
30 "description": "File path relative to the sandbox root.",
31 },
32 },
33 "required": ["path"],
34 },
35 },
36 },
37]The description fields aren't decoration — they're the only information the model has about when and how to use each tool. Vague descriptions produce a model that either never calls the tool or calls it with the wrong arguments.
Step 3: Write the Loop
This is the whole agent. Send the message history and the tool list, check whether the response contains tool calls, and either execute them and loop again or treat plain text as the final answer:
1# agent.py
2import json
3import sys
4
5from openai import OpenAI
6
7from schema import TOOLS
8from tools import list_files, read_file
9
10client = OpenAI(base_url="http://localhost:11434/v1/", api_key="ollama")
11MODEL = "qwen3:8b"
12
13DISPATCH = {"list_files": list_files, "read_file": read_file}
14
15SYSTEM_PROMPT = (
16 "You are a file search assistant. You have two tools: list_files and "
17 "read_file, both scoped to a sandbox directory. Use them to find the "
18 "answer before responding. When you're confident, answer in plain text."
19)
20
21MAX_ITERATIONS = 8
22
23
24def run(question: str) -> str:
25 messages = [
26 {"role": "system", "content": SYSTEM_PROMPT},
27 {"role": "user", "content": question},
28 ]
29
30 for _ in range(MAX_ITERATIONS):
31 response = client.chat.completions.create(
32 model=MODEL, messages=messages, tools=TOOLS,
33 )
34 msg = response.choices[0].message
35
36 if not msg.tool_calls:
37 return msg.content
38
39 messages.append(msg.model_dump(exclude_none=True))
40 for call in msg.tool_calls:
41 try:
42 args = json.loads(call.function.arguments)
43 result = DISPATCH[call.function.name](**args)
44 except (json.JSONDecodeError, TypeError, ValueError) as e:
45 result = f"Error: {e}"
46
47 print(f"[tool] {call.function.name}({args})", file=sys.stderr)
48 messages.append({
49 "role": "tool",
50 "tool_call_id": call.id,
51 "content": str(result),
52 })
53
54 return "Gave up after 8 tool-call rounds without a final answer."
55
56
57if __name__ == "__main__":
58 print(run(sys.argv[1]))Three details matter more than they look:
msg.model_dump(exclude_none=True), not the rawmsgobject. The SDK'smessagesparameter expects plain dicts, not theChatCompletionMessageobject the API handed back — passing the object straight through works by accident on some SDK versions and throws a validation error on others. Dumping it to a dict first, withNonefields stripped, is the version that's safe to depend on.- The
try/exceptaround tool execution is not optional. The model can call a tool with arguments that don't match the schema, reference a file that doesn't exist, or send malformed JSON. Any of those should become a tool-result message the model can react to — "Error: 'foo.py' does not exist" — not an unhandled exception that kills the whole process. MAX_ITERATIONSis a hard stop, not a suggestion. Nothing about the model guarantees it will eventually stop calling tools. Without a cap, a model that gets confused about whether it already has enough information will happily loop until you hit a rate limit or a bill.
Step 4: Run It
python agent.py "What does config.py set the timeout to?"Watch stderr for the tool calls as they happen — this is the loop from Step 3 doing its job, not something hidden inside a library:
[tool] list_files({})
[tool] read_file({'path': 'config.py'})
config.py sets TIMEOUT_SECONDS to 45.
Nothing told the model to call list_files before read_file — it decided that on its own, because it didn't know the filename in advance and the system prompt told it tools were available. That decision, made turn by turn from the results of the previous tool call, is the entire difference between this and a single chat completion.
A Note on Memory
There's no separate "memory" system in this example — the messages list is the memory. Every tool call and its result gets appended to the same list that's resent to the model on every iteration, which is also why the model can reference something a tool returned three calls ago without you doing anything extra.
The tradeoff shows up over longer conversations: the message list grows on every turn, and eventually it either exceeds the model's context window or gets expensive to resend. Production agents handle this by trimming old messages, summarizing them into a shorter form, or moving older context into a separate retrieval step — none of which changes the loop itself, only what goes into messages before each call.
Common Issues
The model calls a tool with arguments that don't match the schema — validate and catch, as in Step 3. Never eval() or exec() anything derived from call.function.arguments; treat it as untrusted input, because it is.
The loop never terminates — you skipped MAX_ITERATIONS, or a bug means the tool result never actually answers what the model asked. Log every tool call (as agent.py does to stderr) so you can see why it kept going instead of just that it did.
The conversation "forgets" a tool result — almost always means the tool-result message was appended to a variable that then got shadowed or reset before the next API call, rather than a genuine model limitation. Print messages before each chat.completions.create call while debugging.
A tool can do something destructive — the two tools here are read-only by construction. The moment a tool can write, delete, or execute anything — a run_shell_command tool being the obvious next thing people reach for — path validation like _safe_path is necessary but not sufficient. That needs process-level isolation, which is its own follow-up, not something to bolt on casually.
Frequently Asked Questions
How is this different from using LangChain or LangGraph, and when is a framework worth it?
Frameworks give you this same loop plus retries, streaming, structured memory backends, and pre-built integrations, in exchange for a dependency and some indirection when something goes wrong. For one or two tools and a simple loop, raw Python like this is often less code than wiring up a framework. Once you need many tools, persistent memory across sessions, or multi-agent coordination with built-in primitives, a framework starts paying for itself.
How do you stop an agent from calling the same tool over and over pointlessly?
The iteration cap in Step 3 is the blunt version. A sharper fix is improving the system prompt and tool descriptions so the model has less reason to re-call a tool it already has results from, and — for tools that are safe to do so — deduplicating identical calls within a run before executing them again.
Can this pattern coordinate more than one agent?
Yes — that's the natural next step once a single agent's loop makes sense to you. A coordinator agent's "tools" can themselves be functions that run a whole sub-agent loop like this one and return its final answer, which is how multi-agent delegation is usually built underneath the abstraction.
How do you add memory that survives across separate runs, not just within one loop?
Within a single run() call, the messages list is the memory, but it disappears when the process exits. Persisting it means writing messages to a file, database, or vector store between runs and loading it back in before the next one — the loop itself doesn't change, only where the message history comes from before the first API call.
Official References
- OpenAI function calling guide — the tool schema format this tutorial uses
- Ollama OpenAI compatibility — which parts of this API Ollama supports locally
- Python
pathlib.Path.is_relative_to— the method behind the sandbox check in Step 1
Where to Go Next
- Don't give this pattern a destructive tool without reading this first — How to Sandbox AI Agents covers isolating tool execution with process limits and containers, for the moment a tool needs to do more than read files.
- Coordinate more than one of these loops together — How to Orchestrate Multiple AI Agents builds a coordinator pattern directly on top of the single-agent loop from this tutorial.
- Run this against a local model from scratch — Run an LLM Locally with Ollama covers installing Ollama and picking a tool-calling-capable model if you haven't already.
Next in Building AI Agents
How to Sandbox AI Agents: Isolating Tool Execution From Your Host
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.