Gil Allouche
← All writing
TutorialAutonomous agents

An autonomous agent is a while loop with a step cap

Build a working agent in one Python file: model call, tool dispatch, a human gate on the tool that writes, and a cap enforced outside the model.

November 10, 2026·Gil Allouche·13 min read
A build-along. Every step was run start to finish; commands and outputs are what the machine actually printed.

The outcome, and what you need installed

About 150 lines of Python, most of it JSON schema. It reads a local ledger, does its arithmetic with a tool instead of guessing, asks you before it writes to disk, and stops dead at eight steps whether or not it finished. Half an hour, if Python 3.10 or newer is already on the machine.

That file is the whole answer to "how do autonomous agents work." There is no other mechanism underneath it.

You need Python 3.10+ (released October 2021), a terminal, and an OpenAI API key from platform.openai.com/api-keys on an account with credit — billing is per input and output token (pricing), and this is a handful of calls to a small model. The default task below finishes in four model calls and roughly 15,000 billed tokens end to end, which at current small-model rates is under two cents. Install the official SDK, openai-python. Nothing else. No framework, no vector store, no orchestration layer.

The loop has four parts

Strip the marketing off and an agent is a model called in a loop with tools, which is how Anthropic's engineering team described it in Building effective agents in December 2024. Interleaving a reasoning step with an action and an observation was published as ReAct by Yao et al. in October 2022. The code below is that paper's loop with error handling bolted on.

Four moving parts. People skip the fourth.

  1. A message list that grows. Every model output and every tool result gets appended. The model has no memory; the list is the memory.
  2. A model call that is given a list of tool schemas and may return tool_calls instead of prose.
  3. A dispatch table — a plain dict from tool name to Python function — plus an allow-list for anything with a side effect.
  4. A counter in Python that ends the loop. Not an instruction in the system prompt. A for loop with a bound.
The agent loop: task history feeds a model call, which either dispatches a tool and appends the result, or exits with a final answer; a step cap wraps the loop outside the modelStep cap = 8 (Python)Task + historyModel calltool calls?Dispatchallow-listyesresult appendednoFinal answer

Everything else in the agent ecosystem — planners, memory stores, sub-agent routing, MCP servers — changes what goes into part 1 or what is registered in part 3. The loop does not change.

The two files

Save this as invoices.json. The addresses use reserved documentation domains on purpose: RFC 2606 reserves .example, so nothing in this file can resolve to a real mailbox if a tool ever grows a send capability.

[
  {"id": "INV-1001", "customer": "ops@northwind.example", "amount_cents": 42000, "status": "overdue", "days_late": 41},
  {"id": "INV-1002", "customer": "ap@contoso.example", "amount_cents": 18500, "status": "paid", "days_late": 0},
  {"id": "INV-1003", "customer": "billing@fabrikam.example", "amount_cents": 96000, "status": "overdue", "days_late": 12},
  {"id": "INV-1004", "customer": "ops@northwind.example", "amount_cents": 7500, "status": "open", "days_late": 0}
]

Save this as agent.py, in the same directory.

#!/usr/bin/env python3
"""A minimal autonomous agent: one model, three tools, a hard step cap."""

import json
import sys

from openai import OpenAI

MODEL = "gpt-4.1-mini"            # any tool-capable chat model works
MAX_STEPS = 8                     # enforced here, not in the prompt
GATED_TOOLS = {"write_report"}    # needs a human yes

client = OpenAI()                 # reads OPENAI_API_KEY from the environment

SYSTEM = (
    "You are a billing analyst agent. Use the tools to get facts; "
    "never guess an amount. Money is in integer cents. "
    "When the task is done, reply with a short plain-text summary."
)

# ------------------------------------------------------------------ tools

def read_invoices(status="all"):
    with open("invoices.json", encoding="utf-8") as fh:
        rows = json.load(fh)
    if status != "all":
        rows = [r for r in rows if r["status"] == status]
    return {"count": len(rows), "invoices": rows}


def sum_cents(amounts_cents):
    return {"total_cents": sum(int(a) for a in amounts_cents)}


def write_report(filename, body):
    if not filename.endswith(".md"):
        return {"error": "filename must end in .md"}
    with open(filename, "w", encoding="utf-8") as fh:
        fh.write(body)
    return {"written": filename, "bytes": len(body.encode("utf-8"))}


TOOLS = {
    "read_invoices": read_invoices,
    "sum_cents": sum_cents,
    "write_report": write_report,
}

SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "read_invoices",
            "description": "Read invoices from the local ledger.",
            "parameters": {
                "type": "object",
                "properties": {
                    "status": {
                        "type": "string",
                        "enum": ["all", "paid", "open", "overdue"],
                    }
                },
                "required": ["status"],
                "additionalProperties": False,
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "sum_cents",
            "description": "Add a list of integer cent amounts.",
            "parameters": {
                "type": "object",
                "properties": {
                    "amounts_cents": {
                        "type": "array",
                        "items": {"type": "integer"},
                    }
                },
                "required": ["amounts_cents"],
                "additionalProperties": False,
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "write_report",
            "description": "Write a markdown report to disk.",
            "parameters": {
                "type": "object",
                "properties": {
                    "filename": {"type": "string"},
                    "body": {"type": "string"},
                },
                "required": ["filename", "body"],
                "additionalProperties": False,
            },
        },
    },
]


def approve(name, args):
    print(f"\n  GATE  {name} wants to run with: {json.dumps(args)[:300]}")
    return input("  allow? [y/N] ").strip().lower() == "y"


def run(task):
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": task},
    ]

    for step in range(1, MAX_STEPS + 1):
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=SCHEMAS,
            tool_choice="auto",
        )
        msg = response.choices[0].message

        assistant = {"role": "assistant", "content": msg.content}
        if msg.tool_calls:
            assistant["tool_calls"] = [
                {
                    "id": c.id,
                    "type": "function",
                    "function": {
                        "name": c.function.name,
                        "arguments": c.function.arguments,
                    },
                }
                for c in msg.tool_calls
            ]
        messages.append(assistant)

        if not msg.tool_calls:
            print(f"[{step}/{MAX_STEPS}] done")
            return msg.content or ""

        for call in msg.tool_calls:
            name = call.function.name
            try:
                args = json.loads(call.function.arguments or "{}")
            except json.JSONDecodeError as exc:
                args, result = {}, {"error": f"bad arguments JSON: {exc}"}
            else:
                if name not in TOOLS:
                    result = {"error": f"no such tool: {name}"}
                elif name in GATED_TOOLS and not approve(name, args):
                    result = {"error": "denied by human operator"}
                else:
                    try:
                        result = TOOLS[name](**args)
                    except Exception as exc:
                        result = {"error": f"{type(exc).__name__}: {exc}"}

            print(f"[{step}/{MAX_STEPS}] -> {name} {json.dumps(args)[:90]}")
            print(f"[{step}/{MAX_STEPS}] <- {json.dumps(result)[:90]}")

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

    return f"STOPPED: hit the {MAX_STEPS}-step cap without finishing"


if __name__ == "__main__":
    task = " ".join(sys.argv[1:]) or (
        "Total every overdue invoice and write the figure, with the "
        "invoice ids, to overdue.md"
    )
    print(run(task))

Two lines carry the weight. messages.append(assistant) goes before anything else, because a tool message is only valid as a reply to a preceding assistant message that contains tool_calls — that pairing by tool_call_id is specified in the function calling guide. And for step in range(1, MAX_STEPS + 1), because that is the only thing in the file guaranteed to stop it.

Three tools is the smallest set that covers the three trust classes any real build runs into: read_invoices touches the outside world but changes nothing, sum_cents is pure, and write_report has a side effect and therefore sits in GATED_TOOLS. Sorting each new tool into one of those three buckets is most of the security design.

Money moves in integer cents for the reason ledgers do. Python floats are IEEE 754 binary64, so 0.1 + 0.2 evaluates to 0.30000000000000004 and a column of dollar amounts drifts. sum_cents also means the model never adds anything itself: it picks which numbers to add, and Python does the addition.

Every tool returns a dict, including on failure. Exceptions become {"error": ...} and go back to the model as an observation. An agent that can read its own stack trace corrects the argument and retries; one that crashes on the first bad call is a script.

Run it

  1. Make a directory and a virtualenv: mkdir agent-loop && cd agent-loop && python3 -m venv .venv && source .venv/bin/activate. Your prompt should now be prefixed with (.venv).
  2. Install the SDK: pip install "openai>=1.40". The last line should read Successfully installed openai-1.x.x along with httpx, pydantic and a few others.
  3. Export your key from platform.openai.com/api-keys: export OPENAI_API_KEY=sk-.... Confirm with python3 -c "import os; print(len(os.environ['OPENAI_API_KEY']))" — you want a number, not a KeyError.
  4. Save the two files above as invoices.json and agent.py. ls should show both. If invoices.json is missing, the first tool call fails with FileNotFoundError and the agent will tell you so in plain English.
  5. Run it: python3 agent.py. The trace prints one -> line per tool call and one <- line per result, each tagged with the step number out of the cap.
  6. At the allow? [y/N] prompt, type y and press enter. Typing anything else returns {"error": "denied by human operator"} to the model, which then reports that it could not write the file — that is the gate working, not a bug.
  7. Check the artifact: cat overdue.md. It should contain INV-1001 and INV-1003 and a total of 138000 cents, which is $1,380.00.
  8. Now break it on purpose. Change MAX_STEPS = 8 to MAX_STEPS = 1 and run again. The loop makes one model call, dispatches one tool, and the function returns STOPPED: hit the 1-step cap without finishing.
  9. Put the cap back to 8. Then run a task it cannot complete: python3 agent.py "Find every disputed invoice and reconcile it against the bank feed". Watch which of the two it does — answers honestly that no such status or tool exists, or burns steps probing. Either answer tells you something about the model you picked.

The run looks like this. Wording from the model will differ; the shape will not, and the bytes figure depends on what it wrote.

[1/8] -> read_invoices {"status": "overdue"}
[1/8] <- {"count": 2, "invoices": [{"id": "INV-1001", "customer": "ops@north
[2/8] -> sum_cents {"amounts_cents": [42000, 96000]}
[2/8] <- {"total_cents": 138000}

  GATE  write_report wants to run with: {"filename": "overdue.md", "body": "#
  allow? [y/N] y
[3/8] -> write_report {"filename": "overdue.md", "body": "# Overdue invoices
[3/8] <- {"written": "overdue.md", "bytes": 142}
[4/8] done
Two invoices are overdue — INV-1001 ($420.00, 41 days late) and INV-1003
($960.00, 12 days late) — totalling 138000 cents. Written to overdue.md.

Step 4 is a model call that returns no tool calls. That is how the loop ends normally: the absence of tool_calls, not a magic "done" token.

When it doesn't work

Error textCauseFix
OpenAIError: The api_key client option must be setThe key is not in this shell. A new terminal tab does not inherit the export.Re-run export OPENAI_API_KEY=sk-..., or put it in a .env and load it.
RateLimitError: Error code: 429 ... insufficient_quotaValid key, unfunded account.Add credit on the billing page. 429 with insufficient_quota is not a rate limit, it is an empty wallet.
BadRequestError: ... messages with role 'tool' must be a response to a preceding message with 'tool_calls'You appended the tool result without first appending the assistant message that requested it — the single most common mistake when people rewrite this loop.Append the assistant message, with its tool_calls array intact, before any tool message.
NotFoundError: The model 'gpt-4.1-mini' does not exist or you do not have access to itModel name misspelled, or the key belongs to a project without access to that model.Swap MODEL for any tool-capable chat model the account can list. The loop itself is model-agnostic.
APIRemovedInV1: You tried to access openai.ChatCompletionCode pasted in from a pre-2023 tutorial. Version 1.0.0 of the Python SDK, released in November 2023, moved every call onto a client object.Use client.chat.completions.create(...) as above, and check that pip show openai reports 1.40 or newer.
FileNotFoundError: [Errno 2] No such file or directory: 'invoices.json'Running from a different directory.cd into the folder holding both files, or make the path absolute. The agent survives this one — the error is returned to the model as an observation.
TypeError: read_invoices() got an unexpected keyword argument 'customer'The model invented a parameter.Already caught and fed back as {"error": ...}. "additionalProperties": False in the schema reduces how often it happens.

The failure that does not raise an exception is the loop that never terminates: the model calls a tool, dislikes the result, calls it again with a slightly different argument, forever. Unbounded iteration is the standard production failure mode for this pattern, which is why OpenAI's own Agents SDK exposes a max_turns limit on its run loop, defaults it to 10, and raises MaxTurnsExceeded when the bound is crossed. Enforce the bound in code, outside the model. A sentence in the system prompt asking it to be efficient is not a bound.

Why the cap goes in Python, not the prompt

The system prompt is advice. The for loop is physics.

Two reasons to keep the limit out of the model's reach. Cost is the first. Every step resends the whole message list, so step 6 pays for the input tokens of steps 1 through 5 plus their outputs, and token billing is per call on the full context (pricing) — spend grows superlinearly with step count, not linearly. Put numbers on it: if each round trip adds roughly 400 tokens of assistant message plus tool result, call n carries about 400n input tokens, so an eight-step run bills 400 × (1+2+…+8) = 14,400 input tokens rather than the 3,200 a linear guess predicts. Double the cap to 16 and that figure quadruples to 54,400. A loop with no bound has no bounded bill. I wrote about how to instrument that before you charge anyone for it in build a margin meter before you price your AI feature.

Trust boundaries are the second. The content coming back on a tool message is input, and in this build it comes from a JSON file you control. Point read_invoices at a shared inbox or a web page and it becomes attacker-controlled text arriving inside the model's context — prompt injection, the first-listed risk in the OWASP Top 10 for LLM Applications. An instruction embedded in a tool result can ask for more tool calls. A cap the model cannot edit limits how far that goes.

Keep GATED_TOOLS small and make membership obvious. Read-only tools run free; anything that writes, sends, pays or deletes goes through approve().

What not to run unattended

Published guidance on agent deployment converges on the same division of labour: read-only tools run without supervision, and anything that spends, sends or deletes sits behind a human approval step. The same OWASP list that ranks prompt injection first also names excessive agency — granting an agent broader permissions than the task requires — as a separate entry, and Anthropic's write-up makes the matching recommendation to keep the permission surface as small as the task allows. A payments API, a send-email tool pointed at a real recipient list, write access to a production database: none of those belong behind this loop unattended — not because the loop is fragile, but because the gate is the cheapest piece of the system and removing it buys nothing.

Three honest limits of what you just built:

  • It has no durable state. Kill the process and the message list is gone. Persistence means writing messages to disk or a database each step — one JSONL line per appended message, keyed by a run id — and replaying the file to resume. The message list is the only thing worth persisting, because nothing else in the file is stateful.
  • It retries blindly. There is no backoff on a 429 and no circuit breaker on a tool that fails twice with the same argument. The SDK retries transport errors; it does not retry your business logic sensibly.
  • It cannot see a screen. Every tool here is an API. When there is no API, the fallback is driving a browser or a desktop, which is slower and more brittle — I've argued that computer use agents are a last resort, and if you need that loop, here is one in a single Python file.

If your task is a fixed sequence — read, transform, write, every time, same order — do not use this loop. Write the sequence. The model deciding which tool comes next is the expensive part, and you only pay for it when the order genuinely varies. That distinction is the whole argument in AI automation means two different things.

Sources

  1. OpenAI — Function calling guide — Shape of tool_calls, the id/name/arguments fields, and the requirement to reply with a tool message carrying the matching tool_call_id
  2. OpenAI — API keys — Where the reader gets OPENAI_API_KEY
  3. OpenAI — API pricing — Token-based billing: cost scales with input plus output tokens per call
  4. openai/openai-python — Official Python SDK, install and client usage
  5. ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2022) — The interleaved reason/act/observe loop this code implements
  6. Anthropic — Building effective agents — Definition of an agent as a model using tools in a loop, and when a workflow is the better shape
  7. OWASP Top 10 for Large Language Model Applications — Prompt injection as the first-listed LLM application risk; tool output is untrusted input
  8. Model Context Protocol — Open standard for exposing tools to models over a server interface
  9. openai/openai-agents-python — Framework alternative that ships a max_turns limit on the run loop

Related