Build an AI Agent in Python From Scratch, No Framework (Tool Calling Loop)

By Pooja Goenka · Published 2026-10-17 · Updated 2026-10-21

An AI agent is a loop where the model picks a tool, your code runs it, and the result goes back to the model, over and over until the model says it is done. Everything else people call an agent, from memory to planning to multi-agent teams, is built on that loop.

Below I build one with three tools (a calculator, today's date, and a search over a small list of notes), a guard that stops runaway loops, message history as memory, and error handling for a bad tool call. The runnable version works offline, because the "model" is a scripted function that returns the same JSON a real model would, and I paste the real transcript. After that, in clearly marked snippets I did not run, the same loop is written with the OpenAI Python SDK. If you want the wider path first, the AI agents roadmap for beginners puts this in order, and free AI agents courses compared lists where to learn more.

How does the tool calling loop work?

OpenAI's function calling guide describes the flow in five steps:

  1. You send a request to the model along with the tools it is allowed to call.
  2. The model replies with a tool call, which is a tool name and some arguments.
  3. Your application runs that function with those arguments.
  4. You send the tool's output back to the model in a second request.
  5. The model returns a final answer, or asks for another tool, and you go around again.

The loop in this post is steps two to five repeated until the model produces a final answer. Step three is the one beginners get wrong, so I will say it plainly: the model never executes anything. It emits text that says "please call calculator with 4 + 12", and your Python decides whether to do it. That gap is where all the safety, validation and logging live.

Step 1: what tools does the agent need?

A tool is an ordinary Python function that takes arguments and returns a string. Here are three. The calculator walks the syntax tree and allows only numbers and + - * /, because calling eval() on text a model wrote is how you hand it your machine. The date is fixed to 17 October 2026 so the transcript below is repeatable, and the notes search is a keyword match over four strings.

import ast
import datetime
import json
import operator

# ---- Tools: plain Python functions ----
OPS = {ast.Add: operator.add, ast.Sub: operator.sub,
       ast.Mult: operator.mul, ast.Div: operator.truediv}

def calculator(expression):
    """Evaluate + - * / on numbers. No eval(): we walk the syntax tree."""
    def walk(node):
        if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
            return node.value
        if isinstance(node, ast.BinOp) and type(node.op) in OPS:
            return OPS[type(node.op)](walk(node.left), walk(node.right))
        raise ValueError("only numbers and + - * / are allowed")
    return str(walk(ast.parse(expression, mode="eval").body))

def todays_date():
    return datetime.date(2026, 10, 17).isoformat()  # fixed so the transcript is repeatable

NOTES = [
    "Dentist appointment on Thursday at 4 pm",
    "Buy milk, eggs and rice",
    "Send the quarterly report to Priya by Friday",
    "Python study group meets on Saturday morning",
]

def search_notes(query):
    words = query.lower().split()
    hits = [n for n in NOTES if any(w in n.lower() for w in words)]
    return json.dumps(hits) if hits else "no matching notes"

TOOLS = {"calculator": calculator, "todays_date": todays_date, "search_notes": search_notes}

Every tool returns a string, including errors. The model reads whatever comes back, so a clear error message is useful input for its next move.

Step 2: how do you fake the model so it runs offline?

A real model decides what to call from the conversation. For learning the loop you can replace it with a function that replays a script, one JSON reply per call. The loop cannot tell the difference, which is the point: the loop is independent of which model sits behind it.

My script includes two mistakes on purpose, a call to a tool called weather that does not exist, and a calculator call that divides by zero. Real models make both kinds of mistake, and your loop has to survive them.

# ---- The "model": a scripted stand-in that returns the JSON a real model would ----
def scripted_model(script):
    replies = iter(script)
    def model(messages):
        return json.dumps(next(replies))
    return model

SCRIPT = [
    {"tool": "search_notes", "args": {"query": "dentist"}},
    {"tool": "calculator", "args": {"expression": "4 + 12"}},
    {"tool": "weather", "args": {"city": "Pune"}},          # a tool that does not exist
    {"tool": "calculator", "args": {"expression": "2 / 0"}},  # a tool that raises
    {"tool": "todays_date", "args": {}},
    {"final": "Your dentist visit is Thursday at 4 pm (16:00). Today is 2026-10-17."},
]

Step 3: how do you write the agent loop with a max-steps guard?

The loop asks the model for its next move, parses the reply, runs the tool, appends the result to the message list, and repeats. Three details do the work.

The message list is the agent's memory. Every model reply and every tool result is appended, and the whole list is passed to the model on the next turn. That is all "memory" means at this level. Nothing is stored anywhere else.

The max_steps guard bounds the loop. Without it, a model that keeps calling tools never returns, and with a paid API that is a bill as well as a hang.

Bad calls turn into results. Invalid JSON, an unknown tool name and an exception inside a tool each turn into an error: string that goes back into the history, so the model can try something else.

# ---- The loop ----
def run_agent(goal, model, max_steps=6):
    messages = [{"role": "user", "content": goal}]          # memory = this list
    for step in range(1, max_steps + 1):
        raw = model(messages)
        messages.append({"role": "assistant", "content": raw})
        print(f"step {step} model  -> {raw}")
        try:
            call = json.loads(raw)
        except json.JSONDecodeError:
            result = "error: reply was not valid JSON"
        else:
            if "final" in call:
                print(f"step {step} FINAL  -> {call['final']}")
                return call["final"]
            name = call.get("tool")
            if name not in TOOLS:
                result = f"error: unknown tool '{name}'. Available: {sorted(TOOLS)}"
            else:
                try:
                    result = TOOLS[name](**call.get("args", {}))
                except Exception as exc:
                    result = f"error: {type(exc).__name__}: {exc}"
        print(f"step {step} result -> {result}")
        messages.append({"role": "tool", "content": result})
    print("stopped: max_steps reached")
    return None

if __name__ == "__main__":
    run_agent("When is my dentist visit in 24-hour time, and what is today's date?",
              scripted_model(SCRIPT), max_steps=8)
    print("\n--- a model that never finishes ---")
    run_agent("loop forever", lambda m: json.dumps({"tool": "todays_date", "args": {}}), max_steps=3)
    print("\n--- a reply that is not JSON ---")
    run_agent("hi", lambda m: "Sure! Let me check.", max_steps=2)

What does the agent transcript look like?

Save the three blocks above as one file, agent.py, and run python3 -I agent.py. This is the real output from my run:

step 1 model  -> {"tool": "search_notes", "args": {"query": "dentist"}}
step 1 result -> ["Dentist appointment on Thursday at 4 pm"]
step 2 model  -> {"tool": "calculator", "args": {"expression": "4 + 12"}}
step 2 result -> 16
step 3 model  -> {"tool": "weather", "args": {"city": "Pune"}}
step 3 result -> error: unknown tool 'weather'. Available: ['calculator', 'search_notes', 'todays_date']
step 4 model  -> {"tool": "calculator", "args": {"expression": "2 / 0"}}
step 4 result -> error: ZeroDivisionError: division by zero
step 5 model  -> {"tool": "todays_date", "args": {}}
step 5 result -> 2026-10-17
step 6 model  -> {"final": "Your dentist visit is Thursday at 4 pm (16:00). Today is 2026-10-17."}
step 6 FINAL  -> Your dentist visit is Thursday at 4 pm (16:00). Today is 2026-10-17.

--- a model that never finishes ---
step 1 model  -> {"tool": "todays_date", "args": {}}
step 1 result -> 2026-10-17
step 2 model  -> {"tool": "todays_date", "args": {}}
step 2 result -> 2026-10-17
step 3 model  -> {"tool": "todays_date", "args": {}}
step 3 result -> 2026-10-17
stopped: max_steps reached

--- a reply that is not JSON ---
step 1 model  -> Sure! Let me check.
step 1 result -> error: reply was not valid JSON
step 2 model  -> Sure! Let me check.
step 2 result -> error: reply was not valid JSON
stopped: max_steps reached

Follow the first run. The agent searches the notes for the dentist visit, asks the calculator for 4 + 12 to convert 4 pm to 16:00, tries the nonexistent weather tool and gets a message listing the tools that do exist, hits the divide-by-zero and gets an error string, then fetches the date and finishes on step six. The second run shows the guard stopping a model that never finishes, and the third shows a reply that is not JSON being reported back instead of crashing the program.

The scripted model does not recover from errors by itself, since it just replays its lines. With a real model, the error text is what it reads before choosing its next call, which is why I keep those messages specific and name the valid tool list.

Now the same loop with the OpenAI SDK (not run here)

I did not run this section, since it needs an API key. The names come from the function calling guide: tools are dictionaries with type, name, description and a JSON Schema parameters object, you call client.responses.create, tool requests appear in response.output as items with type == "function_call" carrying name, arguments (a JSON string) and call_id, and you answer each with an item of type function_call_output that repeats the call_id. The final text is at response.output_text. I left the model name as a placeholder because model names change, so pick one from OpenAI's models page. I also left strict mode out of the tool definitions to keep them short; the guide covers it.

# NOT RUN HERE: needs `pip install openai` and an OPENAI_API_KEY
import json
from openai import OpenAI

client = OpenAI()
MODEL = "pick-a-model-from-the-models-page"

tools = [{
    "type": "function",
    "name": "search_notes",
    "description": "Search the user's personal notes for a keyword. "
                   "Use it for questions about appointments, errands or plans.",
    "parameters": {
        "type": "object",
        "properties": {"query": {"type": "string", "description": "one keyword, e.g. dentist"}},
        "required": ["query"],
        "additionalProperties": False,
    },
}]  # add calculator and todays_date the same way

def run_agent_sdk(goal, max_steps=6):
    history = [{"role": "user", "content": goal}]
    for _ in range(max_steps):
        response = client.responses.create(model=MODEL, tools=tools, input=history)
        history += response.output
        calls = [item for item in response.output if item.type == "function_call"]
        if not calls:
            return response.output_text
        for item in calls:
            try:
                result = TOOLS[item.name](**json.loads(item.arguments))
            except Exception as exc:
                result = f"error: {type(exc).__name__}: {exc}"
            history.append({
                "type": "function_call_output",
                "call_id": item.call_id,
                "output": str(result),
            })
    return None  # hit max_steps

It reuses TOOLS from the offline version. The structure is the same loop: history as memory, a step cap, errors turned into results. Only the model call and the message format changed.

How do you make an agent fail on purpose?

Break the tool description and watch what happens. The model chooses tools by reading the description and parameter descriptions you wrote, so those few lines are your real interface. I have not run a live model for this post, so I will describe the failure rather than paste a transcript. Try it yourself on the SDK version above.

Replace the search_notes description with "Does stuff" and the query description with nothing. Then ask, "When is my dentist visit?" Expect one of three things: the model answers from nothing without calling the tool, it calls the tool with a long sentence where a keyword was needed, or it picks the wrong tool. Which one you get depends on the model.

The fix is to write the description as you would brief a new colleague. Say what the tool does, when to use it, what each argument means, and include an example value, as in the snippet above ("one keyword, e.g. dentist"). If the model keeps sending sentences, make the tool forgiving as well: my search_notes splits the query into words for exactly that reason. Log every tool call with its arguments for a while. Most of the agent bugs I have debugged showed up in that log.

When should you reach for LangGraph?

This hand-written loop is enough for one agent with a handful of tools, and I would build your first one this way so you know what a framework does for you. I would move to LangGraph when the control flow stops being a single loop: several agents handing work to each other, steps that need saving and resuming, branches where a human approves before an action, or state that has to persist between runs. Those are things you can write yourself, but you end up rebuilding a graph runner. Before that point, a framework mostly adds a second thing to debug.

Where to go next

In the LogicWiz GenAI course, the chapter "The Agent Awakens" covers LLMs, prompts, tools, memory and the agent loop, and "Nova Builds a Team" is where LangGraph comes in. The course is completely free at logicwiz.ai/genai, and the Zero to AI Agent line lays out the full sequence. If you are still choosing where to learn, see the best free GenAI courses in 2026, compared.

An agent that can search your own documents is the natural next tool to add. The sibling post, RAG tutorial in Python from scratch, builds exactly that retriever.

FAQ

What is an AI agent in Python?

It is a program with a loop: send the goal and history to a model, read whether it wants a tool, run the tool, add the result to the history and repeat until it gives a final answer. The model decides, and your code acts.

Do I need LangChain or LangGraph to build an AI agent?

No. The loop above is a few dozen lines of plain Python. A framework becomes worth it when you have several agents, saved state or human approval steps.

Why doesn't the model run the tool itself?

Because the model only produces text. Your code receives the requested call, checks it, runs it and reports back, which is where you validate arguments, limit what is allowed and log what happened.

How do I stop an agent from looping forever?

Cap the number of steps, as max_steps does here, and return a clear message when the cap is hit. You can also cap total tokens or time, and track repeated identical calls.

Can I build an agent without an API key?

You can build and test the whole loop offline with a scripted model, as in this post. A real key only matters once you want a live model choosing the tools.

Back to the blog