{"id":21657,"date":"2026-08-14T00:35:16","date_gmt":"2026-08-13T22:35:16","guid":{"rendered":"https:\/\/www.juust.org\/?p=21657"},"modified":"2026-08-14T00:35:23","modified_gmt":"2026-08-13T22:35:23","slug":"an-example-agent-in-python","status":"publish","type":"post","link":"https:\/\/www.juust.org\/index.php\/an-example-agent-in-python\/2026\/08\/","title":{"rendered":"An Example Agent in Python"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Build an AI Agent from Scratch \u2014 No Framework, Just Python<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>If you can read a <code>while<\/code> loop, you can understand every line of an agent.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Frameworks like LangChain, CrewAI and AutoGen are great for shipping but terrible for <em>learning<\/em>. When you write <code>Agent(executor=\u2026).run()<\/code>, a black box makes a hundred design decisions for you and hides every single one. The surprising truth? There is <strong>no deep machinery inside that box<\/strong>. The whole &#8220;agent&#8221; idea fits in a single <code>while<\/code> loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This article builds that loop from scratch: a real, working AI agent you can talk to, that calculates, reads files and searches the web \u2014 and you will understand <em>every<\/em> line. No abstractions. No frameworks. Just Python, the OpenAI API, and ~300 lines of code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/github.com\/juustesout\/python-agents-example\" target=\"_blank\" rel=\"noopener\">github.com\/juustesout\/python-agents-example<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">1. Installatie &amp; Setup<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The agent uses the OpenAI Chat Completions API, duckduckgo-search for web lookups, and <code>python-dotenv<\/code> to load secrets. That is the <em>entire<\/em> dependency list.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># requirements.txt\nopenai&gt;=1.40.0\npython-dotenv&gt;=1.0.0\nddgs&gt;=9.0.0\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Clone the repo and create a virtual environment:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>git clone https:\/\/github.com\/juustesout\/python-agents-example.git\ncd python-agents-example\npython -m venv .venv\nsource .venv\/bin\/activate       # Linux\/macOS\n# .venv\\Scripts\\activate        # Windows\npip install -r requirements.txt\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">API keys in .env<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Copy the example env file and add your OpenAI key:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cp .env.example .env\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open <code>.env<\/code> and set:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>OPENAI_API_KEY=sk-proj-your-key-here\nOPENAI_MODEL=gpt-4o-mini        # optional, this is the default\nAGENT_VERBOSE=1                 # set to 0 to silence trace output\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>.gitignore<\/code> already has <code>**\/.env<\/code> so your key stays local. <code>python-dotenv<\/code> loads the file automatically when the agent starts \u2014 you never have to <code>export<\/code> anything.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">2. Project Structure<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The repo is deliberately flat. Two examples, one shared core:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>python-agents-examples\/\n\u251c\u2500\u2500 01_mini_agent\/\n\u2502   \u251c\u2500\u2500 agent.py          # The core loop + 3 built-in tools\n\u2502   \u251c\u2500\u2500 demo.py           # Entry point with example prompts\n\u2502   \u2514\u2500\u2500 sample.txt        # A test file for the file reader tool\n\u251c\u2500\u2500 02_agent_vs_agent\/\n\u2502   \u2514\u2500\u2500 game.py           # Two agents with opposing personalities negotiate\n\u251c\u2500\u2500 .env.example          # Copy to .env, add your key\n\u2514\u2500\u2500 requirements.txt\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>01_mini_agent<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>02_agent_vs_agent<\/strong> instantiates the exact same <code>MiniAgent<\/code> class twice \u2014 the only difference is the system prompt \u2014 and drops them into a turn-based negotiation game over $100.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">3. The Core Loop \u2014 How Every Agent Works<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the whole trick, in four steps:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The model receives the <strong>full conversation history<\/strong> plus a list of <strong>tool schemas<\/strong> (name, description, expected JSON arguments).<\/li>\n\n\n\n<li>The model replies with either plain text <em>or<\/em> a structured tool call like <code>{\"name\": \"calculator\", \"arguments\": {\"expression\": \"2 ** 10\"}}<\/code>.<\/li>\n\n\n\n<li>If it is a tool call, <strong>you<\/strong> \u2014 not the framework, not the model \u2014 run the matching Python function and append the result as a <code>role=\"tool\"<\/code> message.<\/li>\n\n\n\n<li>Repeat from step 1 until the model returns plain text. That text is the final answer.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">That is it. The loop <strong>model \u2192 tool \u2192 result \u2192 model \u2192 \u2026 \u2192 answer<\/strong> is the entire idea behind every agent framework ever built.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The MiniAgent class<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The agent is built around three things: a list of <code>messages<\/code>, a registry of <code>tools<\/code>, and a lazy OpenAI client. No hidden state, no bookkeeping.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class MiniAgent:\n    def __init__(self, system_prompt, model=None,\n                 temperature=0.7, max_tool_iters=10, verbose=True):\n        self.messages = &#91;]           # the only \"memory\" \u2014 a list of JSON dicts\n        self.tools = {}              # name -&gt; Tool registry\n        self._client = None          # lazy OpenAI client\n        ...\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">The <code>run()<\/code> method \u2014 the heart<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the loop that makes the agent &#8220;agentic&#8221;:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def run(self, user_prompt, *, reset=True):\n    if reset or not self.messages:\n        # Fresh conversation: system prompt + user message\n        self.messages = &#91;\n            {\"role\": \"system\", \"content\": self.system_prompt},\n            {\"role\": \"user\", \"content\": user_prompt},\n        ]\n    else:\n        # Continue existing conversation (the \"memory\" trick)\n        self.messages.append({\"role\": \"user\", \"content\": user_prompt})\n\n    for iteration in range(1, self.max_tool_iters + 1):\n        reply = self._call_model()           # send history + tool schemas\n\n        if reply.tool_calls:\n            self._handle_tool_calls(reply.tool_calls)  # execute tools\n            continue                                   # loop back\n\n        # No tool calls \u2192 final answer\n        self.messages.append({\"role\": \"assistant\", \"content\": reply.content})\n        return reply.content\n\n    raise RuntimeError(f\"No answer after {self.max_tool_iters} iterations.\")\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 in real time.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How <code>_call_model()<\/code> works<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The agent converts its tool registry into OpenAI&#8217;s JSON schema format and sends it alongside the conversation:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def _call_model(self):\n    tool_schemas = &#91;tool.to_openai_schema()\n                    for tool in self.tools.values()]\n    response = self._client.chat.completions.create(\n        model=self.model,\n        messages=self.messages,\n        tools=tool_schemas or None,\n        tool_choice=\"auto\" if tool_schemas else None,\n    )\n    return response.choices&#91;0].message\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Key insight:<\/strong> a &#8220;tool schema&#8221; is just a plain JSON object describing a Python function. The model does <em>not<\/em> run anything \u2014 it only <strong>requests<\/strong> a call. You run it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How tool results get back into context<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is where the API contract gets specific. The assistant message carrying the tool call must be appended <em>before<\/em> the results, and every result must include the matching <code>tool_call_id<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def _handle_tool_calls(self, tool_calls):\n    # 1. Record the assistant message with the tool call requests\n    self.messages.append({\n        \"role\": \"assistant\",\n        \"content\": None,\n        \"tool_calls\": &#91;\n            {\"id\": call.id, \"type\": \"function\",\n             \"function\": {\"name\": call.function.name,\n                          \"arguments\": call.function.arguments}}\n            for call in tool_calls\n        ],\n    })\n\n    # 2. Execute each tool and append its result\n    for call in tool_calls:\n        result = self._execute_tool(call.function.name,\n                                    call.function.arguments)\n        self.messages.append({\n            \"role\": \"tool\",\n            \"tool_call_id\": call.id,\n            \"content\": result,\n        })\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is where most home-grown agents fail: the API will reject your next request unless <code>role=\"tool\"<\/code> messages are correctly paired with the <code>tool_call_id<\/code> from the assistant message.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">4. Tools \u2014 Just Python Functions with a JSON Schema<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A tool is nothing more than a Python function plus metadata. The <code>Tool<\/code> class bundles them together:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@dataclass\nclass Tool:\n    name: str\n    description: str\n    parameters: dict       # JSON Schema\n    func: Callable&#91;..., str]\n\n    def to_openai_schema(self):\n        return {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": self.name,\n                \"description\": self.description,\n                \"parameters\": self.parameters,\n            },\n        }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">When the model calls a tool, <code>_execute_tool<\/code> looks it up in the registry, parses the JSON arguments and calls the function:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def _execute_tool(self, name, arguments_json):\n    tool = self.tools.get(name)\n    args = json.loads(arguments_json or \"{}\")\n    result = tool.func(**args)      # Just a Python call!\n    return str(result)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>That is it.<\/strong> 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 <code>agent.add_tool()<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 1: Calculator (safe evaluation with <code>ast<\/code>)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The calculator is a great example of why <code>eval()<\/code> is dangerous and <code>ast<\/code> is safe. The model could trick <code>eval(\"__import__('os').system('rm -rf \/')\")<\/code> but <code>ast<\/code> only allows whitelisted node types:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def calculator(expression: str) -&gt; str:\n    bin_ops = {\n        ast.Add: operator.add, ast.Sub: operator.sub,\n        ast.Mult: operator.mul, ast.Div: operator.truediv,\n        ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,\n        ast.Pow: operator.pow,\n    }\n    constants = {\"pi\": math.pi, \"e\": math.e}\n\n    def evaluate(node):\n        if isinstance(node, ast.Constant):\n            if isinstance(node.value, (int, float)):\n                return node.value\n            raise ValueError(\"only numbers allowed\")\n        if isinstance(node, ast.Name):\n            if node.id in constants:\n                return constants&#91;node.id]\n            raise ValueError(f\"unknown constant '{node.id}'\")\n        if isinstance(node, ast.BinOp):\n            op = bin_ops.get(type(node.op))\n            return op(evaluate(node.left), evaluate(node.right))\n        if isinstance(node, ast.UnaryOp):\n            op = unary_ops.get(type(node.op))\n            return op(evaluate(node.operand))\n        raise ValueError(f\"construct {type(node).__name__} disallowed\")\n\n    tree = ast.parse(expression, mode=\"eval\")\n    return str(evaluate(tree.body))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The whitelist is the safety mechanism. The AST can only contain node types that have a handler in <code>evaluate()<\/code>. Any other construct \u2014 function calls, imports, attribute access \u2014 raises before it can execute.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 2: Safe file reader (path traversal defence)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Reading a file seems simple until the model tries <code>..\/..\/etc\/passwd<\/code>. The guard is <code>Path.resolve()<\/code> combined with <code>is_relative_to()<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def read_local_file(filepath: str) -&gt; str:\n    base = _DEFAULT_BASE_DIR.resolve()          # e.g. \/home\/user\/project\/\n    target = (base \/ filepath).resolve()        # resolve .. and symlinks\n\n    if not target.is_relative_to(base):         # escaped the sandbox?\n        return \"Error: path traversal blocked.\"\n    if not target.is_file():\n        return \"Error: file not found.\"\n    if target.stat().st_size &gt; _MAX_FILE_BYTES:\n        return \"Error: file too large.\"\n\n    return target.read_text(encoding=\"utf-8\", errors=\"replace\")\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>is_relative_to()<\/code> is the key. Even if the model constructs a path like <code>..\/..\/..\/..\/etc\/passwd<\/code>, after <code>resolve()<\/code> the absolute path falls <em>outside<\/em> <code>base<\/code> and the tool refuses.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 3: Web search (zero-API-key via DuckDuckGo)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">DuckDuckGo&#8217;s search API requires no key at all. The agent tries two import paths (the package was renamed from <code>duckduckgo_search<\/code> to <code>ddgs<\/code>):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>try:\n    from ddgs import DDGS\nexcept ImportError:\n    try:\n        from duckduckgo_search import DDGS\n    except ImportError:\n        DDGS = None\n\ndef web_search(query: str, max_results: int = 5) -&gt; str:\n    if DDGS is None:\n        return \"Error: install duckduckgo-search package.\"\n\n    results = list(DDGS().text(query, max_results=max_results))\n    return \"\\n\\n\".join(\n        f\"{i}. {r&#91;'title']}\\n   {r&#91;'href']}\\n   {(r.get('body') or '')&#91;:200]}\"\n        for i, r in enumerate(results, start=1)\n    )\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Swap DuckDuckGo for SerpAPI, Tavily or Google Search in one line \u2014 the tool interface never changes, only the implementation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Registering tools on the agent<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Every tool gets added through a single method that expects a name, description, JSON schema and the Python function:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>agent.add_tool(\n    name=\"calculator\",\n    description=\"Evaluate a math expression. Supports + - * \/ \/\/ % ** pi e.\",\n    parameters={\n        \"type\": \"object\",\n        \"properties\": {\n            \"expression\": {\n                \"type\": \"string\",\n                \"description\": \"The expression to evaluate.\",\n            }\n        },\n        \"required\": &#91;\"expression\"],\n    },\n    func=calculator,\n)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three tools, three calls. The pattern is identical for every tool you will ever add.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">5. Running the Agent<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The demo runner at <code>01_mini_agent\/demo.py<\/code> exercises all three tools with built-in prompts:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Default: runs the four built-in demo prompts\npython 01_mini_agent\/demo.py\n\n# Custom prompt\npython 01_mini_agent\/demo.py \"What is the speed of light times 3600?\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Example session (real output, real agent):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>USER: What is 2 ** 10 + 1000? Use the calculator.\n\n&#91;agent] iteration 1: calling the model...\n&#91;agent]   -&gt; calculator({\"expression\": \"2 ** 10 + 1000\"})\n&#91;agent]   &lt;- result: '2024'\n&#91;agent] iteration 2: calling the model...\n&#91;agent] final answer received.\n\nFINAL ANSWER: The result of 2^10 + 1000 is 2024.\n\n---\n\nUSER: Search the web for the speed of light, then\n       calculate how far it travels in one hour.\n\n&#91;agent] iteration 1: calling the model...\n&#91;agent]   -&gt; web_search({\"query\": \"speed of light\"})\n&#91;agent]   &lt;- result: '1. Speed of light\\n ...\n                       299,792,458 m\/s'\n&#91;agent] iteration 2: calling the model...\n&#91;agent]   -&gt; calculator({\"expression\": \"299792458 * 3600\"})\n&#91;agent]   &lt;- result: '1079252848800'\n&#91;agent] iteration 3: calling the model...\n&#91;agent] final answer received.\n\nFINAL ANSWER: Light travels approximately 1.08 trillion meters\n             (1,079,252,848,800 m) in one hour.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 no black box, no surprises.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">6. Two Agents, One Conversation: Multi-Agent Negotiation<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.juust.org\/wp-content\/uploads\/2026\/08\/bob-and-alice.jpg\"><img fetchpriority=\"high\" decoding=\"async\" width=\"1024\" height=\"1024\" src=\"https:\/\/www.juust.org\/wp-content\/uploads\/2026\/08\/bob-and-alice.jpg\" alt=\"\" class=\"wp-image-21660\" srcset=\"https:\/\/www.juust.org\/wp-content\/uploads\/2026\/08\/bob-and-alice.jpg 1024w, https:\/\/www.juust.org\/wp-content\/uploads\/2026\/08\/bob-and-alice-300x300.jpg 300w, https:\/\/www.juust.org\/wp-content\/uploads\/2026\/08\/bob-and-alice-150x150.jpg 150w, https:\/\/www.juust.org\/wp-content\/uploads\/2026\/08\/bob-and-alice-768x768.jpg 768w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">If one agent is a while-loop, multi-agent is just <strong>two copies of the same loop<\/strong> talking to each other. The &#8220;agent vs. agent&#8221; example at <code>02_agent_vs_agent\/game.py<\/code> is the cleanest example of that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two instances of <code>MiniAgent<\/code>, one system prompt each, take turns proposing a split of $100:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>POT = 100              # dollars to split\nMAX_TURNS = 8          # before the game is called off\n\nalice = MiniAgent(\n    system_prompt=\"You are ALICE, <strong>the Rational Negotiator<\/strong>. ...\",\n    temperature=0.6, verbose=False)\n\nbob = MiniAgent(\n    system_prompt=\"You are BOB, <strong>the Bold Trader<\/strong>. ...\",\n    temperature=0.6, verbose=False)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The two prompts are the <em>only<\/em> 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 \u2014 which tells you everything about what &#8220;personality&#8221; really is in an AI agent: just text.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The game loop<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The game is a simple ultimatum game. The first proposer (Alice) puts a split on the table. The other player can <strong>accept<\/strong> (deal done), <strong>reject<\/strong> (both get $0) or <strong>counter<\/strong> (roles swap). Each agent&#8217;s move is parsed from free-form model output with a regex:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>_ACTION_RE = re.compile(r\"(OFFER|ACCEPT|COUNTER|REJECT)\", re.IGNORECASE)\n\ndef parse_move(text):\n    match = _ACTION_RE.search(text)\n    if match is None: return None\n    action = match.group(1).upper()\n    if action in (\"ACCEPT\", \"REJECT\"):\n        return {\"action\": action}\n    numbers = _NUMBERS_RE.findall(text)\n    keep = int(numbers&#91;0]) if numbers else None\n    return {\"action\": action, \"keep\": keep}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Running the game<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>python 02_agent_vs_agent\/game.py           # verbose: shows raw model text\npython 02_agent_vs_agent\/game.py --quiet   # only parsed moves\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Example session (2 turns, quiet mode):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Alice opens the negotiation...\nAlice -&gt; Alice keeps $50, gives $50          # fair start\nBob   -&gt; Bob keeps $70, gives $30            # aggressive counter\nAlice -&gt; Alice keeps $40, gives $60          # concedes but no deal\n\nNo deal after 2 turns. Impasse.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">With <code>--verbose<\/code> you see the raw model text, which is often more entertaining:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Bob says: \"OFFER 70 30. Take it or leave it, Alice.\n           I've got other deals lined up.\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What makes this example powerful is the lesson: <strong>multi-agent &#8220;collaboration&#8221; is not a framework feature<\/strong>. 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">7. Putting It All Together<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the complete checklist to build your own agent from scratch:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>One API client<\/strong> \u2014 OpenAI, Anthropic, whatever. The loop does not care which SDK you use; only the wire format of tool calls differs.<\/li>\n\n\n\n<li><strong>A message list<\/strong> \u2014 system prompt, user messages, assistant replies, tool results. That list <em>is<\/em> the agent&#8217;s memory.<\/li>\n\n\n\n<li><strong>A tool registry<\/strong> \u2014 a dict mapping names to Python functions. Each function is described by a JSON schema the model can read.<\/li>\n\n\n\n<li><strong>A while-loop<\/strong> \u2014 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.<\/li>\n\n\n\n<li><strong>Multi-agent<\/strong> \u2014 run two copies of the same loop with different system prompts. Define a message protocol. Let them talk.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">That is all of it. Frameworks add caching, streaming, retries, GUIs and marketing \u2014 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).<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Going Further<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Swap the model<\/strong> \u2014 change <code>OPENAI_MODEL<\/code> or the <code>model<\/code> parameter to <code>gpt-4o<\/code>, <code>gpt-4.1-mini<\/code>, or any model that supports function calling.<\/li>\n\n\n\n<li><strong>Add your own tools<\/strong> \u2014 write a Python function, write a JSON Schema, call <code>agent.add_tool()<\/code>. That is three lines.<\/li>\n\n\n\n<li><strong>Use Anthropic instead of OpenAI<\/strong> \u2014 the same loop works with <code>anthropic<\/code> SDK. The only difference is that Claude uses a <code>tool_use<\/code> \/ <code>tool_result<\/code> content block format instead of <code>tool_calls<\/code>.<\/li>\n\n\n\n<li><strong>Persist conversations<\/strong> \u2014 save <code>agent.messages<\/code> to a JSON file and reload it. That is all &#8220;memory&#8221; is.<\/li>\n\n\n\n<li><strong>Extend the negotiation<\/strong> \u2014 more players, different rules, a marketplace. You own the game logic.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The repo is at <a href=\"https:\/\/github.com\/juustesout\/python-agents-example\" target=\"_blank\" rel=\"noopener\">github.com\/juustesout\/python-agents-example<\/a>. Clone it, open <code>agent.py<\/code>, and read the whole thing in one sitting. You already know enough.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Build an AI Agent from Scratch \u2014 No Framework, Just Python<\/p>\n","protected":false},"author":5796,"featured_media":21660,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_sitemap_exclude":false,"_sitemap_priority":"","_sitemap_frequency":"","site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"set","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[4],"tags":[],"class_list":["post-21657","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-juust"],"_links":{"self":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21657","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/users\/5796"}],"replies":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/comments?post=21657"}],"version-history":[{"count":3,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21657\/revisions"}],"predecessor-version":[{"id":21662,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21657\/revisions\/21662"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/media\/21660"}],"wp:attachment":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/media?parent=21657"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/categories?post=21657"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/tags?post=21657"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}