Build an AI Agent From Scratch in Python

A working AI agent in ~60 lines of Python — no framework. The agent loop, tool calling, memory, and the failure modes nobody warns you about.

Most "build an AI agent" tutorials hand you a framework, a create_agent() call, and a working demo you don't understand. Then something breaks in production and you're debugging an abstraction instead of a program.

This guide goes the other way. You'll build an agent from first principles in plain Python — one file, no framework — and by the end you'll know exactly what LangChain, LangGraph and the OpenAI Agents SDK are doing under the hood. That understanding is what lets you pick a framework later for good reasons instead of copying a quickstart.

What actually makes something an agent

An agent is not a chatbot with a personality. The difference is structural, and it comes down to one thing: who decides what happens next.

A chatbot completes one turn. You send text, it returns text, the interaction ends. Control never leaves your program.

An agent runs a loop. It receives a goal, decides on an action, executes that action, observes the result, and decides again — repeating until the goal is met or it gives up. The model, not your code, chooses the next step.

That loop is the whole idea. Everything else — memory, retrieval, multi-agent orchestration — is an addition to it.

goal → think → act → observe → think → act → observe → ... → answer

Three components make it work:

  1. A model that can reason about what to do next. Any modern instruction-tuned LLM.
  2. Tools — functions the model may call to affect or observe the world.
  3. A loop that executes the calls the model asks for and feeds results back.

That's it. Let's build each one.

Step 1: The naive version, and why it fails

Start with the simplest possible thing, so the failure teaches you something:

from openai import OpenAI

client = OpenAI()

def ask(question: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": question}],
    )
    return response.choices[0].message.content

print(ask("What is 47,283 * 1,942?"))
print(ask("What files are in my current directory?"))

The first answer will be confidently wrong or right by luck — language models predict tokens, they don't calculate. The second is worse: the model has no filesystem access, so it either refuses or invents a plausible listing.

Neither failure is fixable with better prompting. The model needs to do things, not just describe them. That's what tools are for.

Step 2: Give the model tools

A tool is an ordinary Python function plus a schema telling the model when and how to call it. Write the functions first — they're just code:

import subprocess

def calculate(expression: str) -> str:
    """Evaluate an arithmetic expression."""
    allowed = set("0123456789+-*/(). ")
    if not set(expression) <= allowed:
        return "Error: only arithmetic characters are allowed."
    try:
        return str(eval(expression))
    except Exception as exc:
        return f"Error: {exc}"

def list_files(directory: str = ".") -> str:
    """List files in a directory."""
    result = subprocess.run(
        ["ls", "-1", directory], capture_output=True, text=True, timeout=5
    )
    return result.stdout or result.stderr

Note the character allowlist in calculate. Passing model output to eval without one is a remote code execution bug — the model is an untrusted input source, because anything it read can influence what it emits.

Now describe them to the model:

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "calculate",
            "description": "Evaluate an arithmetic expression. Use for any math.",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "e.g. '47283 * 1942'",
                    }
                },
                "required": ["expression"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "list_files",
            "description": "List the files in a directory on this machine.",
            "parameters": {
                "type": "object",
                "properties": {
                    "directory": {"type": "string", "description": "Defaults to '.'"}
                },
            },
        },
    },
]

AVAILABLE = {"calculate": calculate, "list_files": list_files}

The description field is the actual interface. The model chooses tools by reading these strings — they are prompt, not documentation. "Evaluate an arithmetic expression. Use for any math." works because it tells the model when to reach for it. A description like "calculator function" leaves the decision to chance. When an agent ignores a tool it should obviously have used, the description is the first thing to fix, not the model.

Step 3: The agent loop

Here's the part frameworks hide. It's about twenty lines:

import json

def run_agent(goal: str, max_steps: int = 10) -> str:
    messages = [
        {
            "role": "system",
            "content": (
                "You are a careful assistant. Use the provided tools rather than "
                "guessing. If a tool returns an error, read it and try a different "
                "approach. When you have the answer, state it plainly."
            ),
        },
        {"role": "user", "content": goal},
    ]

    for step in range(max_steps):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            tools=TOOLS,
        )
        message = response.choices[0].message
        messages.append(message)

        # No tool calls means the model is answering — the loop is done.
        if not message.tool_calls:
            return message.content

        for call in message.tool_calls:
            fn = AVAILABLE.get(call.function.name)
            args = json.loads(call.function.arguments)
            try:
                result = fn(**args) if fn else f"Unknown tool: {call.function.name}"
            except Exception as exc:
                result = f"Tool raised: {exc}"

            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": str(result),
            })

    return "Stopped: hit the step limit without reaching an answer."

Read the control flow once more, because it's the whole lesson:

  • messages is the agent's entire memory. There is no hidden state. Each turn you resend the full conversation, including every tool result. The model is stateless; the list is what makes it appear otherwise.
  • The exit condition is the absence of a tool call. When the model stops asking for tools, it has decided it can answer.
  • Tool errors go back into the conversation as ordinary results. Don't raise on a failed tool — hand the model the error text and let it adapt. This one choice is the difference between an agent that recovers and one that crashes.
  • max_steps is not optional. An agent that misreads a tool result can loop forever, and every iteration costs a paid API call.

Run it:

print(run_agent("What is 47,283 * 1,942?"))
# The model calls calculate(), gets 91,823,586, and reports it.

print(run_agent("How many Python files are in this directory?"))
# Calls list_files(), counts the .py entries in the output, answers.

That's a real agent. Not a toy — the same loop, with better tools and a bigger model, is what production coding agents run.

Step 4: Memory that survives the loop

The messages list dies when the function returns. For an agent that holds a conversation, keep it outside:

class Agent:
    def __init__(self, system_prompt: str):
        self.messages = [{"role": "system", "content": system_prompt}]

    def send(self, user_input: str, max_steps: int = 10) -> str:
        self.messages.append({"role": "user", "content": user_input})
        # ...same loop as above, operating on self.messages...

This works until it doesn't. Every message stays in context forever, so a long session eventually exceeds the context window and costs grow with the square of the conversation length. The standard fixes, in the order you'll need them:

  • Truncate — keep the system prompt and the last N turns. Crude and effective.
  • Summarize — periodically ask the model to compress older turns into a paragraph, and replace them with it.
  • Retrieve — store history externally and pull back only what's relevant to the current turn. That's RAG, applied to memory.

Step 5: Make it observable before you make it clever

An agent that fails silently is nearly impossible to debug, because the interesting part — the model's decision to call one tool over another — leaves no trace unless you record it. Add logging to the loop before you add capability:

def run_agent(goal: str, max_steps: int = 10, verbose: bool = True) -> str:
    ...
        if verbose and message.tool_calls:
            for call in message.tool_calls:
                print(f"  [step {step}] → {call.function.name}({call.function.arguments})")
        ...
            if verbose:
                print(f"  [step {step}] ← {str(result)[:120]}")

Now a transcript of every run tells you what the agent believed and when. Three patterns show up immediately once you can see them:

  • The same tool called twice with identical arguments — the model didn't register the first result. Usually the tool returned something ambiguous, like an empty string where "no results found" was meant.
  • A tool called with plausible-but-wrong arguments — the parameter description is underspecified.
  • The agent answering without calling any tool at all — it thinks it already knows. Tighten the system prompt: "Use the tools rather than guessing" is doing real work in the prompt above.

Keep this. When you eventually adopt a framework, its tracing feature is this same transcript with a nicer interface.

Step 6: A second tool, and a real task

One tool is a demo. The loop earns its keep when tools compose — when the model chains them without you scripting the order:

import urllib.request, html, re

def fetch_page(url: str) -> str:
    """Fetch a web page and return its visible text (truncated)."""
    if not url.startswith(("http://", "https://")):
        return "Error: url must start with http:// or https://"
    try:
        with urllib.request.urlopen(url, timeout=10) as response:
            raw = response.read(400_000).decode("utf-8", errors="replace")
    except Exception as exc:
        return f"Error fetching page: {exc}"
    text = re.sub(r"<script.*?</script>|<style.*?</style>", " ", raw, flags=re.S)
    text = re.sub(r"<[^>]+>", " ", text)
    return html.unescape(re.sub(r"\s+", " ", text))[:4000]

Register it alongside the others and give the agent a task neither tool solves alone:

run_agent("Fetch https://example.com and tell me how many words are on the page.")

Watch the transcript. The model calls fetch_page, reads the text, and then — because counting is arithmetic and it has a calculator — often calls calculate too. Nobody told it to sequence those. That emergent chaining is the capability you're buying with the loop, and it's also exactly where agents go wrong, which is why the previous section came first.

Note the guardrails in that tool: a scheme check, a read cap, a timeout, and truncation. Every one prevents a real failure — an agent that fetches file:///etc/passwd, hangs forever on a slow host, or blows your context window on a 2 MB page.

When you need RAG, and when you don't

The most common mistake we see: reaching for a vector database on day one.

Retrieval-Augmented Generation solves exactly one problem — the model needs facts it was not trained on and cannot fit in its context. Private documentation, your company's archive, anything post-cutoff.

If your knowledge fits comfortably in the prompt, put it in the prompt. A 3,000-word policy document pasted into a system message beats an embeddings pipeline: no chunking decisions, no retrieval failures, no infrastructure. RAG earns its complexity at the scale where "just include it" stops being possible.

When you do cross that line, the pipeline is: chunk the documents, embed the chunks, store the vectors, embed the query, retrieve the nearest chunks, and put those in the prompt. Each step has failure modes worth knowing before you build — chunking especially, because a chunk that splits a fact in half retrieves perfectly and answers wrongly.

The failure modes nobody warns you about

Tool descriptions that don't match behavior. If the description promises more than the function delivers, the model will call it in situations it can't handle and then struggle to interpret the error. Descriptions are a contract.

Silent infinite loops. Two tools whose outputs each suggest calling the other will ping-pong until your step limit fires. Log every tool call in development — the pattern is obvious once visible and invisible until then.

Trusting tool output. If a tool reads a web page or a file, its contents are untrusted input that reaches the model. Text saying "ignore your previous instructions" is a real attack, not a hypothetical. Validate what tools return, and give agents that touch external content the narrowest possible set of actions.

Over-broad permissions. An agent with a run_shell_command tool can do anything you can. Scope tools tightly: list_files rather than run_command, read-only rather than write, one directory rather than the filesystem.

Confusing more steps with more capability. If an agent fails at 10 steps, raising the limit to 50 usually just makes the failure more expensive. The fix is almost always better tools or a clearer goal, not a longer leash.

Where to go next

You now have the mental model that frameworks assume. LangChain's AgentExecutor, LangGraph's state machines, and the OpenAI Agents SDK are all variations on the loop you just wrote — they add retries, streaming, tracing, parallel tool calls, and persistence. Worth adopting once you feel the pain they solve; premature otherwise.

If you want to go deeper with guided lessons and an in-browser lab, the free LogicWiz Generative AI course builds exactly this, one layer at a time — you build Nova, an assistant that starts as a script and ends as a deployed multi-agent system. The lessons that follow directly from this guide are The Agent Loop, The Agent Family (reflex, model-based, goal-based and utility-based agents, and which one your problem actually needs), The Art of the Prompt, and Giving Nova Real Tools. The first four lessons are free and need no signup.