{"slug": "there-is-no-magic-an-ai-agent-in-60-lines-of-python", "title": "There Is No Magic: An AI Agent in 60 Lines of Python", "summary": "An AI agent is not a new type of model but rather a small amount of plumbing around an LLM, consisting of Model, Instructions, Memory, Tools, and an Execution Loop. A minimal agent can be built from scratch in 60 lines of Python, demonstrating that the core logic is simple code, not magic.", "body_md": "# There Is No Magic: An AI Agent in 60 Lines of Python\n\n*Building agentic AI? I co-run a 6-week cohort where you ship a production-ready agent, not another API wrapper.*\n\nEverybody talks about agents, and a lot of people assume they're some new kind of model. They aren't. An agent is a small amount of plumbing around an LLM you already understand. Let's build one from scratch in Python and see exactly what that plumbing is.\n\n## The formula\n\nAn agent is: Model + Instructions + Memory + Tools + Execution Loop.\n\nFive parts. None of them is magic. The model is a brain in a jar: useful, fast, but stateless. It generates text; the code around it decides what to do with that text. That second half is the entire job and it's code we can reason about.\n\nI made [the same argument about the control layer](/blog/control-layer-is-the-product/) being the real product. Here it is as a program.\n\nStart with the model. A real one calls an LLM API; we use a fake one that satisfies the same interface:\n\n``` python\nfrom dataclasses import dataclass\nfrom typing import Protocol\n\n@dataclass(frozen=True)\nclass Say:\n    text: str\n\n@dataclass(frozen=True)\nclass Call:\n    tool: str\n    arg: str\n\nReply = Say | Call\n\nclass Model(Protocol):\n    def respond(self, system: str, history: list[str]) -> Reply: ...\n```\n\nThe `Model`\n\nprotocol has a single method, `respond`\n\n, which takes the system prompt and the conversation history and returns a `Reply`\n\n. It's a `Protocol`\n\n, so any object with a matching `respond`\n\nmethod counts as a `Model`\n\n, no inheritance required.\n\nFor this minimal agent, the `Reply`\n\ntype captures the two actions we support: say something to the user, or call a tool with an argument. The model is free to return either one, and the agent will execute it. (Real models can also emit plans, ask clarifying questions, or request several tool calls at once; we keep it to two to stay legible.)\n\nThe agent's entire decision space is those two variants. The `match`\n\nin the loop below reads as a clean two-way branch, one case per reply, instead of a tangle of flags.\n\n``` python\nfrom dataclasses import dataclass, field\nfrom typing import Callable\n\nTool = Callable[[str], str]\n\n@dataclass\nclass Agent:\n    model: Model                                          # 1. Model\n    system: str                                           # 2. Instructions\n    history: list[str] = field(default_factory=list)      # 3. Memory\n    tools: dict[str, Tool] = field(default_factory=dict)  # 4. Tools\n```\n\nIn this example, a tool is a function taking a string and returning a string. The agent holds the other four parts as plain fields:\n\n- The model is any object satisfying the\n`Model`\n\nprotocol: a fake model goes in for testing and a real one for production. - The system prompt is a string that tells the model what to do.\n- The history is the agent's working memory: the conversation and tool outputs that get replayed back into the model. Real agents often add retrieval, summarization, or external state on top, because context windows are finite.\n- The tools field is a mapping of tool names to functions that implement them.\n\n## The loop is the agent\n\nThe part that turns a well-instructed chatbot into something agent-like is the fifth piece: an execution loop that lets the model observe outcomes and decide what to do next. Observe, think, act, check, repeat. Greatly simplified, of course, but this is the piece that does the work.\n\nBecause the model is stateless, the agent must keep track of what happened and feed the history back into the model until the model decides the job is done.\n\n``` php\n    def run(self, user_input: str) -> str:\n        self.history.append(f\"user: {user_input}\")\n        while True:  # real agents cap the iterations; see termination guards below\n            match self.model.respond(self.system, self.history):\n                case Say(text):\n                    self.history.append(f\"agent: {text}\")\n                    return text\n                case Call(tool, arg):\n                    fn = self.tools.get(tool)\n                    result = fn(arg) if fn else f\"no such tool: {tool}\"\n                    self.history.append(f\"tool[{tool}]: {result}\")\n                    # loop again: the model sees the result and decides what's next\n```\n\nRead it as the cycle:\n\n- Observe: append the input.\n- Think: ask the model.\n- Act: if it asked for a tool, run the tool.\n- Check and repeat: feed the result back into the history and loop, so the model sees what happened and decides whether it needs another tool or can finally answer.\n\nThere is no separate \"check\" block in the code. The check happens implicitly when the loop restarts and calls `respond`\n\nagain with the new history. That step is the one that matters, because a model has no native sense of when a job is finished, and nothing stops it from asking for one more tool forever. The loop keeps going until the model returns `Say`\n\ninstead of `Call`\n\n.\n\nTo run the whole thing without an API key, swap in a fake model and a real tool:\n\n``` php\nfrom pathlib import Path\n\ndef read_file(path: str) -> str:\n    try:\n        return f\"{len(Path(path).read_text())} bytes\"\n    except OSError as e:\n        return f\"error: {e}\"\n\nclass FakeModel:\n    def respond(self, system: str, history: list[str]) -> Reply:\n        last = history[-1] if history else \"\"\n        if last.startswith(\"tool[\"):\n            return Say(f\"Done: {last}\")\n        if last.startswith(\"user: read \"):\n            return Call(\"read_file\", last.removeprefix(\"user: read \").strip())\n        return Say(\"I can read files. Try: read <path>\")\n```\n\nWire it into a small `main`\n\nthat builds the agent, reads a line, calls `agent.run`\n\n, and prints the reply:\n\n``` php\ndef main() -> None:\n    agent = Agent(\n        model=FakeModel(),\n        system=\"You can read files.\",\n        tools={\"read_file\": read_file},\n    )\n    while True:\n        try:\n            line = input(\"> \")\n        except EOFError:\n            break\n        print(agent.run(line.strip()))\n\nif __name__ == \"__main__\":\n    main()\n```\n\nNow you can talk to it with no API key. Run it with `python agent.py`\n\nand type at the prompt:\n\n```\n> read pyproject.toml\nDone: tool[read_file]: 76 bytes\n```\n\nThat one exchange is a complete agent loop: the model asked for a tool, the loop ran it, fed the byte count back, and the model wrapped up on the second pass. The main thing standing between it and a real one is replacing `FakeModel.respond`\n\nwith an HTTP call that returns the same `Reply`\n\n.\n\nThe whole thing as one runnable file is [here as a GitHub gist](https://gist.github.com/bbelderbos/c295f5269b8d22dc2b75708537f54f00). Save it, run `python agent.py`\n\n, and type at the prompt.\n\n## What this earns you\n\nSure, this is a simplified example, and the hard parts are exactly what `FakeModel`\n\nstubs out: prompt design, retries, tool schemas, context compaction, error recovery, and termination guards that stop the loop when a model keeps hallucinating tools. But the *core* of an agent is 60 lines and easy to reason about. The *engineering* lives in the control layer around the model.\n\nBuild the loop by hand once and frameworks stop feeling magical. LangChain's agent executor, AutoGen's shared memory, a coding agent's plan mode are all variations on these same five parts: engineering tradeoffs, not magic.\n\n## Keep reading\n\nMost AI tutorials end at \"call the API.\" This cohort ends with a deployed agent: function calling, structured outputs, three interfaces, Docker, 95%+ test coverage. Six weeks of real engineering, not notebooks. [Join the next Agentic AI cohort →](https://pythonagenticai.com)", "url": "https://wpnews.pro/news/there-is-no-magic-an-ai-agent-in-60-lines-of-python", "canonical_source": "https://belderbos.dev/blog/build-minimal-ai-agent-python/", "published_at": "2026-06-26 00:00:00+00:00", "updated_at": "2026-06-26 10:42:19.331144+00:00", "lang": "en", "topics": ["ai-agents", "large-language-models", "developer-tools"], "entities": ["Python"], "alternates": {"html": "https://wpnews.pro/news/there-is-no-magic-an-ai-agent-in-60-lines-of-python", "markdown": "https://wpnews.pro/news/there-is-no-magic-an-ai-agent-in-60-lines-of-python.md", "text": "https://wpnews.pro/news/there-is-no-magic-an-ai-agent-in-60-lines-of-python.txt", "jsonld": "https://wpnews.pro/news/there-is-no-magic-an-ai-agent-in-60-lines-of-python.jsonld"}}