Build an AI Agent From Scratch: 7 Steps, No Framework Needed

The agent loop is fifteen lines. Everything that makes an agent worth deploying is in the other 185. A working AI agent from scratch, in plain Python.

Table of Contents

AI agent from scratch written in plain Python on a laptop screen
Two hundred lines of Python. That is the whole build.

Building an AI agent from scratch takes about two hundred lines of Python. Not two thousand. The number surprises people because the frameworks have done such a thorough job of making this look complicated.

I want to be careful about what I am claiming. Two hundred lines of an AI agent from scratch gets you a working one: one that plans, calls tools, reads the results, and keeps going until the job is done or it gives up. It does not get you retries with exponential backoff across six providers, or a tracing dashboard, or a team of agents negotiating with each other. Those things are real and frameworks are good at them. But you should understand what they are wrapping before you let them wrap it.

Why Build an AI Agent From Scratch at All

The honest case against doing this: if you need something in production next week, use LangGraph or the Claude Agent SDK and skip the rest of this article. You will ship faster and the result will be more reliable than your first attempt at hand-rolling.

The case for it is that agents fail in production at a rate that should worry you, and almost none of those failures are model failures. Teams report 15 to 20 percent failure rates on early deployments. Gartner expects more than 40 percent of agentic projects to be scrapped by 2027. When you dig into individual post-mortems, the pattern repeats: a tool returned a 500, the error got swallowed, and the agent carried on as though the call had worked.

That is a harness problem, not a model problem. And you cannot debug a harness you have never looked inside. Building an AI agent from scratch once, properly, changes how you read framework documentation forever. You stop asking “what does this library do” and start asking “which of the six things I know an agent needs is this handling, and how”.

There is a second reason, less philosophical. Frameworks make assumptions about your control flow. When your requirements do not match those assumptions, you end up fighting the abstraction, and the fight costs more than the code you avoided writing.

What an AI Agent Actually Is (Three Parts)

Strip away the marketing and an agent is three things.

A loop. You call a model. The model either answers or asks to use a tool. If it asks for a tool, you run the tool, hand back the result, and call the model again. Repeat until it answers or you stop it.

A set of tools. Ordinary functions in your codebase, described to the model in JSON Schema so it knows they exist and what arguments they take.

A growing message list. Every turn appends to it. This list is the agent’s entire memory, and managing it well is most of the difficulty.

That is the whole idea. A chatbot is this loop with the tools removed and the iteration count set to one. Everything labelled “agentic” in the last two years is a variation on those three components, plus opinions about how to arrange them.

What You Need Before You Build an AI Agent From Scratch

Python 3.10 or newer, an API key from a provider whose models handle tool calling well, and roughly two dollars of credit to get through the examples. I will use the Anthropic SDK in the code below because its tool-calling format is clean, but the shape is close to identical with OpenAI, Gemini or a local model served through Ollama.

If you would rather run this on your own hardware with no API bill at all, the loop is the same and the model is the only thing that changes. That path has its own article, and its own hardware requirements worth understanding before you commit.

pip install anthropic
export ANTHROPIC_API_KEY=sk-ant-...

One thing worth deciding now: pick a real task. Pointing an AI agent from scratch at a toy problem teaches you the mechanics and hides every interesting failure. Use something you actually want automated, ideally involving at least two tools and a decision between them.

Step 1: The Agent Loop

Every AI agent from scratch starts here, and most of them never need anything more elaborate.

Here is the core, with everything else stripped out. If you understand these fifteen lines, you understand agents.

import anthropic

client = anthropic.Anthropic()
messages = [{"role": "user", "content": "What is in my downloads folder?"}]

while True:
    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=2000,
        tools=TOOLS,
        messages=messages,
    )
    messages.append({"role": "assistant", "content": response.content})

    if response.stop_reason != "tool_use":
        break

    results = run_tools(response.content)
    messages.append({"role": "user", "content": results})

print(messages[-1])

Read the exit condition carefully, because it is the part people get wrong. The loop continues while the model says it wants a tool. The moment it stops asking, the loop ends and whatever the model said last is your answer.

Notice what is absent. There is no planner, no reasoning module, no orchestrator. The model does the planning implicitly by choosing which tool to call next. Frameworks that advertise a separate planning stage are usually adding a second model call that writes a plan into the context before the loop starts. Sometimes that helps. Often it is ceremony.

Step 2: Tools Are Functions With a Schema

Tools are where an AI agent from scratch earns its keep, because they are the only way the model touches anything real.

A tool is a normal Python function plus a description the model can read. The description matters more than the code. The model has never seen your function; all it has is your prose.

import os, json

def list_files(path: str) -> str:
    try:
        entries = os.listdir(os.path.expanduser(path))
    except FileNotFoundError:
        return json.dumps({"error": f"No such directory: {path}"})
    except PermissionError:
        return json.dumps({"error": f"Permission denied: {path}"})
    return json.dumps({"path": path, "entries": entries[:200]})

TOOLS = [{
    "name": "list_files",
    "description": (
        "List the files in a directory on the local machine. "
        "Use this when the user asks what is in a folder. "
        "Returns JSON with an entries array, truncated at 200 items. "
        "Returns an error key if the path does not exist or is unreadable."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "Absolute path or a path starting with ~",
            }
        },
        "required": ["path"],
    },
}]

Three habits that separate tools which work from tools which almost work.

Describe the failure modes in the description. The model handles an error far better when it was warned the error exists. Telling it that an error key can come back is the difference between a graceful retry and a confident hallucination.

Return structured data, not prose. JSON strings parse consistently. Sentences invite the model to misread them.

Truncate inside the tool. If a directory holds forty thousand files, you do not want all of them in your context window. Cap it in the function and say so in the description.

AI agent from scratch calling a tool and reading the result back
The model asks. Your code decides. That handoff is where a chatbot becomes an agent.

Step 3: Feeding Tool Results Back

The model does not run anything. It emits a request, and your code decides what to do about it. This is the step where an agent stops being a chatbot.

REGISTRY = {"list_files": list_files}

def run_tools(content_blocks):
    results = []
    for block in content_blocks:
        if block.type != "tool_use":
            continue
        fn = REGISTRY.get(block.name)
        if fn is None:
            output, failed = f"Unknown tool: {block.name}", True
        else:
            try:
                output, failed = fn(**block.input), False
            except TypeError as e:
                output, failed = f"Bad arguments: {e}", True
            except Exception as e:
                output, failed = f"Tool raised {type(e).__name__}: {e}", True
        results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": output,
            "is_error": failed,
        })
    return results

Two details are doing real work here.

The tool_use_id has to match what the model sent. A single turn can contain several tool calls, and the ids are how results get paired with requests. Mismatch them and you get behaviour that looks like the model losing its mind.

The is_error flag tells the model the call failed. This is the single highest-value line in the file. Without it, a failed call and a successful one look identical from the model’s side, and it will build its next step on data it never received. Most silent agent failures trace back to exactly this.

Wrapping every call in a bare except Exception feels sloppy and is correct here. An unhandled exception kills the loop. A caught one becomes information the model can act on, which is usually a retry with different arguments.

Step 4: Memory and the Context Problem

Memory is the step where an AI agent from scratch quietly gets expensive.

Your messages list grows every turn, and it grows faster than people expect. A tool that returns a 30 KB JSON blob has spent perhaps eight thousand tokens of your budget on one call.

The uncomfortable finding from 2026 research is that accuracy degrades long before you hit the context limit. Frontier models with 200,000-token windows show measurable accuracy loss by around 50,000 tokens of input. A window is not a promise of quality across its whole length. Treating it as one is how agents get vague and start ignoring instructions from earlier in the conversation.

Three mitigations, in the order I would apply them.

Trim at the source. Truncate in the tool, before the data ever enters the context. A tool that returns the ten most relevant rows beats one that returns all four thousand and hopes the model copes.

Summarise old turns. Once you pass a threshold, replace the oldest exchanges with a compact summary and keep the recent ones intact.

def compact(messages, keep=6):
    if len(messages) <= keep + 2:
        return messages
    head, tail = messages[1:-keep], messages[-keep:]
    summary = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=800,
        messages=[{
            "role": "user",
            "content": "Summarise these agent steps. Keep every fact, "
                       "file path, id and error. Drop the phrasing.\n\n"
                       + str(head),
        }],
    ).content[0].text
    return [messages[0], {"role": "user", "content": f"Earlier: {summary}"}] + tail

Use a small, cheap model for the summary. It is a compression job, not a reasoning one.

Keep a scratchpad outside the context. Let the agent write notes to a file and read them back with a tool. Facts it needs later live on disk instead of consuming tokens on every single turn. This is the trick that makes long-running agents viable, and it is why file access shows up in every serious agent SDK.

Step 5: Error Handling

If you skip one section of this build, do not make it this one. Error handling is what separates an AI agent from scratch that works on your laptop from one you can leave running.

Errors arrive in three flavours and they want different treatment.

Tool errors go back to the model, as covered above. A missing file is information, not a crash.

API errors are yours to handle. Rate limits and overload responses are routine at any volume and deserve a retry with backoff.

import time, anthropic

def call_model(**kwargs):
    delay = 1
    for attempt in range(5):
        try:
            return client.messages.create(**kwargs)
        except (anthropic.RateLimitError, anthropic.InternalServerError):
            if attempt == 4:
                raise
            time.sleep(delay)
            delay *= 2

Logic errors are the hard ones: the agent completes every step successfully and produces the wrong answer. No exception fires. The only defence is a check that does not depend on the agent’s own judgement, which usually means a validation function you wrote, or a second model asked to review the output against the original request.

That last pattern is worth the extra call for anything consequential. An agent grading its own work in the same conversation will grade generously.

Step 6: Stopping Conditions and Cost Control

An AI agent from scratch has no built-in sense of when to give up. You have to supply one.

An unbounded loop plus a metered API is a bad combination. I have watched an agent spend eleven dollars rereading the same file because a tool kept returning an empty string and the model kept assuming it had mistyped the path.

MAX_TURNS = 15
MAX_TOKENS_TOTAL = 200_000

turns, spent = 0, 0
while turns < MAX_TURNS and spent < MAX_TOKENS_TOTAL:
    turns += 1
    response = call_model(model=MODEL, max_tokens=2000,
                          tools=TOOLS, messages=messages)
    spent += response.usage.input_tokens + response.usage.output_tokens
    ...

Set both ceilings. Turn count catches loops. Token count catches a single turn that balloons because a tool returned something enormous.

Add one more guard: detect repetition. If the agent calls the same tool with the same arguments twice in a row, it is stuck. Say so in the context rather than letting it discover this on its own, which it generally will not.

if (block.name, str(block.input)) == last_call:
    output = ("You already called this tool with these exact arguments "
              "and got the same result. Try a different approach or "
              "tell the user what is blocking you.")

Step 7: Observability

You cannot fix what you cannot see, and an agent that fails silently in production will fail silently for weeks.

Log four things per turn: which tool was called, with what arguments, what came back (truncated), and how many tokens the turn cost. Structured JSON lines, one per turn, are enough to start. Tracing platforms are useful later; a log file is useful today.

import json, sys

def trace(turn, name, args, result, tokens):
    sys.stderr.write(json.dumps({
        "turn": turn, "tool": name, "args": args,
        "result": str(result)[:300], "tokens": tokens,
    }) + "\n")

When something goes wrong, this file answers the only question that matters: at which turn did the agent start working from bad information? That is almost always several steps before the output looked wrong.

The Whole AI Agent From Scratch, Assembled

Put the seven steps together and you have a working AI agent from scratch in a single file.

import os, json, time, sys, anthropic

client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-5"
MAX_TURNS, MAX_TOKENS = 15, 200_000

def run(task: str) -> str:
    messages = [{"role": "user", "content": task}]
    turns, spent, last = 0, 0, None

    while turns < MAX_TURNS and spent < MAX_TOKENS:
        turns += 1
        r = call_model(model=MODEL, max_tokens=2000,
                       tools=TOOLS, messages=messages)
        spent += r.usage.input_tokens + r.usage.output_tokens
        messages.append({"role": "assistant", "content": r.content})

        if r.stop_reason != "tool_use":
            return "".join(b.text for b in r.content if b.type == "text")

        results = []
        for b in r.content:
            if b.type != "tool_use":
                continue
            sig = (b.name, json.dumps(b.input, sort_keys=True))
            if sig == last:
                out, err = "Repeat call, same arguments, same result. "\
                           "Change approach or explain what is blocking you.", True
            else:
                fn = REGISTRY.get(b.name)
                try:
                    out, err = (fn(**b.input), False) if fn else \
                               (f"Unknown tool {b.name}", True)
                except Exception as e:
                    out, err = f"{type(e).__name__}: {e}", True
            last = sig
            trace(turns, b.name, b.input, out, spent)
            results.append({"type": "tool_result", "tool_use_id": b.id,
                            "content": out, "is_error": err})

        messages.append({"role": "user", "content": results})
        messages = compact(messages)

    return "Stopped: turn or token ceiling reached."

if __name__ == "__main__":
    print(run(sys.argv[1]))

Around a hundred and forty lines with the tools and helpers included. Give it three or four tools and a real task and it will surprise you.

What Breaks First in Production

Assume the AI agent from scratch you just built will fail. The useful question is which part goes first.

In roughly the order I have seen them bite.

Tool descriptions that were fine in testing. You wrote them while thinking about the happy path. The model meets an edge case, misreads the description, and picks the wrong tool. Rewriting descriptions fixes more agent bugs than changing models does.

Compounding step failure. This one is arithmetic and it is brutal. A ten-step workflow at 85 percent per-step reliability succeeds end to end about twenty percent of the time. Four users in five hit a failure. If your agent needs ten steps, either raise per-step reliability into the high nineties or cut the number of steps.

Context degradation on long runs. The agent is sharp for fifteen turns and mushy by forty. Compaction, applied earlier than feels necessary.

Cost drift. Every turn resends the full history. Turn twenty costs many times what turn two cost, and nobody notices until the invoice arrives. Log tokens per turn from day one.

Nondeterminism in testing. The same input takes a different path on Tuesday. Test the tools deterministically with ordinary unit tests, and test the agent on outcomes rather than exact sequences.

When to Stop Building an AI Agent From Scratch

Building an AI agent from scratch is a learning exercise and, for narrow single-purpose agents, a legitimate production choice. There are four points at which I would stop hand-rolling.

Multiple agents handing work to each other. Coordination, shared state and handoff protocols get ugly quickly. This is what CrewAI and the Microsoft Agent Framework exist for.

Branching and human approval steps. Once your control flow is a graph rather than a loop, LangGraph is expressing something you would otherwise reinvent badly.

You want the harness someone else already hardened. The Claude Agent SDK ships the loop, file access, subagents, permissions and compaction as one package. If your agent looks like a coding or research assistant, that is a large head start.

You need serious observability. Tracing, evaluation and replay are substantial products in their own right. Do not build one.

None of that invalidates the exercise. You will read those tools’ docs faster and configure them better for having built the thing once yourself.

Frequently Asked Questions About Building an AI Agent From Scratch

How long does it take to build an AI agent from scratch? An afternoon for the loop and one tool. A week to make it reliable enough to leave running. The gap between those two is where the real work lives.

Which model should I use? Any current frontier model handles tool calling well. Differences show up in long multi-step runs, where instruction adherence over many turns matters more than benchmark scores. Test on your actual task.

Can I run this without paying for an API? Yes. Ollama serves local models with native tool calling, and the loop is unchanged. Expect more retries and stricter tool descriptions with smaller models.

How many tools is too many? Past fifteen or twenty, selection accuracy drops. Group related actions behind one tool with a mode argument, or split into subagents with narrower toolsets.

Do I need a vector database? Only if the agent needs to search a corpus it cannot fit in context. Retrieval is a tool the agent calls, not part of the loop itself.

Is this the same as an MCP server? Related but not identical. MCP standardises how tools are exposed so any compatible client can use them. You can build an agent from scratch and give it MCP tools, and that combination is a good one.

Verdict

An AI agent from scratch is not a hard build. The loop is fifteen lines. Everything that makes an agent worth deploying sits in the other one hundred and eighty five: error signalling, context management, ceilings, repetition detection, and a log that tells you where things went wrong.

Frameworks handle all of that, and handle it better than a first attempt will. The reason to build an AI agent from scratch anyway is that agent failures are harness failures, and the teams who debug them quickly are the ones who know what the harness is doing. Spend an afternoon on it. Then pick your framework with your eyes open.

Start with one tool and a task you genuinely want done. Add the second tool only once the first is boring. Most agents that never ship were three tools wide before they were one tool deep.

Sources and Further Reading

The failure-rate and context-degradation figures quoted above come from published 2026 reporting rather than our own testing. Primary sources below.

Related reading on this site: what MCP actually is, building your own MCP server, and the best local LLMs if you would rather not pay per token.