How to Create an AI Agent (Step-by-Step, 2026)
Most 'build an AI agent' tutorials stop at a single prompt call and hand-wave the loop, the tools and the guardrails, which is exactly the part that decides whether the thing works unattended.
Some links on this site are affiliate links. If you buy through them, we earn a commission at no extra cost to you. We only recommend tools we would deploy ourselves.
$ ls ./sections
Ask ten engineers how to create an AI agent and you’ll get ten architectures. So here is the question none of them answers out loud: what actually separates an agent from a for loop with an API key stuffed inside it? It has a real answer, and most tutorials sprint past it to a single model call, stamp the word “agent” on the file, and ship.
Hold that question.
The gap between a loop that calls a model and a system that picks its own next move is the whole subject here, and it is exactly what the demos wave away. What follows is the step-by-step I’d hand a competent engineer who has never built one: the loop that makes it autonomous, the two or three tools that make it useful, the memory that keeps it coherent, and the guardrails that keep it from grinding your API budget into a wall overnight. Both roads, too. The one where you write the loop, and the one where you pay a platform that already did.
The anatomy of an agent
Strip an agent to the metal and it is a loop with four moving parts: a goal, a set of tools, a model that decides which tool to reach for, and a condition that ends the run. On each turn the model reads the goal and everything learned so far, plans one step, acts by calling a tool, then observes what came back. That observation becomes the input to the next turn. Plan, act, observe, repeat, until the model says it is finished or you cut it off.
That last clause is the answer to the question I opened on. A for loop with an API key runs a fixed number of times through a fixed script you wrote. An agent writes its own control flow at run time: it reads the result of step three and only then chooses step four, which might be a tool you never expected in an order you never planned. The tempting move is to call this a while loop with a model wedged inside it. That is half right, and it’s the wrong half. The loop itself is four lines of Python. The part that earns the word “agent” is that the branch taken on each pass gets chosen by the model while your code just runs whatever it picked.
Tools are how an agent touches anything beyond its own context window. A tool is a function with a name, a one-line description the model can read, and a typed input: search_web(query), run_sql(statement), send_email(to, subject, body). The model never runs the function itself; it emits a structured request to call run_sql with an argument, your code executes it, and the return value goes back as the next observation. Three good tools are the difference between a chatbot and something that can read a database, verify a claim against the live web, and write a row back.
Memory is what keeps the loop coherent from one turn to the next. The simplest version is the running transcript: every plan and every observation, appended in order and replayed on each pass, so the model on turn six still knows what it learned on turn two. That holds until the transcript outgrows the context window, at which point you summarize the old turns or push them into a vector store and pull back the slice that matters. Begin with the transcript. Reach for the vector store on the day it stops fitting, and let that day arrive on its own.
The stop condition is the piece nobody demos, because a demo always halts on cue.
Real agents don’t.
You need two brakes at minimum: an explicit signal from the model that it has a final answer, and a hard ceiling on the number of turns for when that signal never arrives. Ship without the second one and you have built a machine that can bill you money in an infinite loop.
Build an AI agent in code
Enough theory.
Here is the whole thing in about fifty lines of provider-agnostic Python: a model boundary you can aim at anything, a registry of two small tools, and the loop with its brakes already bolted on. Read the comments. That is where the decisions live.
import json, os, ast, operator as _op
# --- 1. The model boundary ------------------------------------------------
# Provider-agnostic on purpose. Point it at a hosted API, a self-hosted
# inference server, or a local model on the GPU under your desk. The loop
# only cares about the contract: a system prompt and a transcript go in,
# one string comes out.
def call_llm(system: str, transcript: list) -> str:
raise NotImplementedError("wire this to the model of your choice")
# --- 2. The tool registry -------------------------------------------------
# Two small, real tools. Each takes one string and returns one string.
_OPS = {ast.Add: _op.add, ast.Sub: _op.sub, ast.Mult: _op.mul,
ast.Div: _op.truediv, ast.Pow: _op.pow, ast.USub: _op.neg}
def calculator(expr: str) -> str:
# Never eval() model output. Walk a restricted arithmetic grammar.
def ev(node):
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.BinOp):
return _OPS[type(node.op)](ev(node.left), ev(node.right))
if isinstance(node, ast.UnaryOp):
return _OPS[type(node.op)](ev(node.operand))
raise ValueError("unsupported expression")
return str(ev(ast.parse(expr, mode="eval").body))
def read_file(path: str) -> str:
# Allow-list a single directory. No traversal out of the sandbox.
base = os.path.realpath("workspace")
full = os.path.realpath(os.path.join(base, path))
if not (full == base or full.startswith(base + os.sep)):
return "ERROR: path is outside the sandbox"
with open(full, encoding="utf-8") as f:
return f.read(4000)
TOOLS = {"calculator": calculator, "read_file": read_file}
# --- 3. The loop: plan -> act -> observe, with the brakes fitted ----------
SYSTEM = """You are a task-running agent. Reply with ONE JSON object, nothing else.
To use a tool: {"thought": "...", "tool": "calculator", "input": "2 + 2"}
To finish: {"thought": "...", "final": "the answer for the user"}
Tools available: calculator(expr), read_file(path)."""
def run_agent(goal: str, max_steps: int = 6) -> str:
transcript = [{"role": "user", "content": goal}]
for _ in range(max_steps): # the hard ceiling: no unbounded runs
raw = call_llm(SYSTEM, transcript)
try:
action = json.loads(raw)
except json.JSONDecodeError: # model broke the contract; ask again
transcript.append({"role": "user",
"content": "Invalid JSON. Reply with one JSON object."})
continue
if "final" in action: # the stop condition
return action["final"]
name, arg = action.get("tool"), action.get("input", "")
if name not in TOOLS: # hallucinated tool name
observation = f"ERROR: no tool named {name!r}"
else:
try:
observation = TOOLS[name](str(arg))
except Exception as e: # a tool failing is data, not a crash
observation = f"ERROR: {name} raised {type(e).__name__}: {e}"
# 'observe': feed the result back so the next plan can use it
transcript.append({"role": "assistant", "content": raw})
transcript.append({"role": "user", "content": f"OBSERVATION: {observation}"})
return "STOPPED: hit the step ceiling with no final answer"
Four things in that file are worth saying out loud, because they are the four the single-prompt tutorials skip.
The call_llm function is deliberately a stub. The loop does not care whether the text behind it comes from a hosted API, a self-hosted inference server, or a quantized model on your own hardware. Keep that seam thin and you can swap providers the week one of them triples its price, without rewriting a line of agent logic.
The registry is a plain dictionary mapping a name to a function. Two tools live here: a calculator that walks a restricted arithmetic grammar instead of calling eval on model output, and a file reader pinned to one sandbox directory. Both look like paranoia and are load-bearing.
The loop is the agent. It asks the model for one JSON action: either a tool call or a final answer. A tool call gets routed through the registry, its result is appended to the transcript as an observation, and the loop comes around again. A final answer breaks out and returns. Everything that can go wrong in between, malformed JSON, an unknown tool name, a tool that throws mid-call, is caught and handed back to the model as an error string it can read. An agent that dies on the first bad tool call is a party trick; one that reads its own error and adjusts is the actual deliverable.
The brake is max_steps. When the model never emits a final answer, the range runs dry and the function returns a stopped-status instead of spinning until your card is declined. That one integer is the distance between a bounded job and an overnight incident.
Guardrails the demos skip
The fifty-line loop runs. I still would not point it at anything that costs money or touches production, because a demo runs on a few clean, rehearsed inputs, and real traffic is mostly malformed and adversarial. Four guardrails turn the toy into something you can walk away from.
Max steps you already have, and it is the floor of the entire safety story. No agent runs unbounded, ever, however clean the plan looks on turn one.
A cost ceiling is the guardrail people meet through their invoice. Track tokens or dollars as the loop runs and check the total on every turn, before the next call goes out:
if spend.dollars > BUDGET: # checked every turn, before the next model call
return "STOPPED: hit the cost ceiling"
Max steps limits how many times it loops; the cost ceiling limits what a single loop may spend. One step that pulls a huge document into context can outspend twenty cheap ones.
A tool allow-list is why the file reader is chained to a single directory. Sooner or later the model will try to read /etc/passwd or path-traverse out of the sandbox, because some intermediate reasoning step decided that looked relevant. The only defense that holds is to make the dangerous thing impossible at the tool boundary rather than asking the model politely in the prompt. Whitelist the statuses a delete can touch. Scope the directory a reader can see. Assume the model will attempt everything you forgot to forbid.
Human-in-the-loop is the last brake, and for anything irreversible it is not optional. Sending an email, issuing a refund, deleting a record: each passes through a confirmation gate a person clears, never the model.
IRREVERSIBLE = {"send_email", "delete_record", "issue_refund"}
def dispatch(name, arg):
if name in IRREVERSIBLE and not human_approves(name, arg):
return "ERROR: action declined by the human reviewer"
return TOOLS[name](arg)
Wire that gate in before the first unattended run. The alternative is learning where it was needed from the postmortem.
Or skip the code: the no-code path
Everything above assumes you want to own the loop. A lot of the time you shouldn’t, and pretending otherwise would be me selling you engineering hours you have no reason to burn. Owning the loop buys you total control over every branch; buying a platform buys back the weekend you’d spend patching it and the pager you’d carry. My own bias, and the reason most of the stack I actually run is managed services, is that hand-written orchestration earns its keep only when the logic is genuinely strange. Everything else is a solved problem you can rent.
Disclosure: some of the links below are affiliate links. If you sign up through one, I earn a commission at no extra cost to you, and it has zero influence over which tool I tell you to skip.
If the real job is wiring services together, an inbound webhook, a model call, a branch, a write to three APIs, then hand-rolling a Python loop is mostly ego. Make.com’s visual scenarios give you the same plan-act-observe shape as a drag-and-drop graph, with the retries, the error branches, and the scheduling already solved for you. You point it at your APIs and it runs, and nobody gets paged when a container restarts at 4 a.m.
When the logic genuinely needs a code node but you don’t want to babysit a runtime, the middle path is a self-hostable engine. Drop into n8n’s Code node for the two functions that truly need custom logic, let the visual layer carry the boring ninety percent around them, and keep it on a box you control for the day a client contract says the data may not leave your infrastructure. It is the compromise for the engineer who wants the escape hatch without owning the whole vehicle.
And when the work is shaped like several agents dividing one job with a human able to step in and redirect, that orchestration is genuinely miserable to build from nothing. Taskade’s multi-agent workspace hands it over assembled: you assign the roles, the agents work across shared project state, and a person stays in the loop by design. For a small team that wants delegation without hiring someone to wire it, it is the shortest honest path from idea to running.
Ranking these three against each other, with the trade-offs and the live pricing, is a longer argument than a build guide should hold, and I keep it in the 2026 automation-tool rankings. There is a prior question, too, the one most teams skip: whether an agent belongs in the process at all, or whether a deterministic automation would do the identical job cheaper and without bolting a hallucination surface onto it. That call is the entire subject of aiming an AI agent at a real business process.
Edge cases & troubleshooting
The agent loops forever
The classic first incident: the model never emits a final answer and just keeps calling tools, usually because a tool keeps returning the same unhelpful observation and it keeps retrying variations on it. The max_steps ceiling is the hard stop, and it should stay low while you develop, five or six rather than fifty. When you hit the ceiling again and again, the fix is upstream: your tool descriptions are vague, or the final-answer format is ambiguous enough that the model can’t tell it is already done. Tighten the stop instruction in the system prompt before you ever raise the cap.
Hallucinated tool calls
Sooner or later the model will confidently call search_database when your registry only holds run_sql, or pass an argument shape you never defined. A dispatcher that assumes the tool exists turns that into an unhandled KeyError and a dead agent. The skeleton checks membership in the TOOLS dict first and feeds no tool named X back as an observation the model can recover from. Validate the tool name and arguments against the registry’s real signatures every time, and reject any mismatch with an error string the model can read and correct against.
The invoice nobody budgeted for
Runaway cost is the failure that stays silent until billing makes it loud. An agent with no cost ceiling and a max_steps set too high can quietly churn through an alarming amount of money on a task that should have cost cents, especially when one tool returns large documents that inflate the context on every turn. Set the per-run token cap and a daily spend alert on the API key before the first unattended run, and log the token count of each turn so the one expensive step shows up as a spike on a graph. Cap it. Log it. Then step back.
Silent tool failures
The nastiest bug is the tool that fails without ever raising. An HTTP call returns a 200 wrapped around an error page, or a database query succeeds and returns zero rows, and the tool hands that straight back as though it were a real result. The agent believes it, reasons on top of the garbage, and produces a confident final answer built on nothing. Wrapping the call in try/except does nothing for you here, because nothing threw. The defense is to validate the shape of what a tool returns rather than only whether it raised: assert the response is the JSON you expected, treat an empty result as its own signal the model must handle, and make any tool that isn’t sure it succeeded say so out loud. An agent reasoning on a silent failure is worse than one that crashed, because the crash at least left you a stack trace.
Start smaller than feels satisfying. Point the skeleton at one real task, give it exactly two tools, set max_steps=5, and read the full transcript of every plan and observation on the first run. That trace shows you what no architecture diagram can: how the thing behaves the moment the observations get messy and the model has to improvise. When it is ready for real work, the whole loop starts with a single line:
python agent.py "summarize today's signups and flag anything that smells like fraud" Make.com
Visual automation platform. The serious replacement for Zapier.
n8n
Source-available workflow automation. Self-host or cloud.
Taskade
All-in-one workspace for building and running AI agents.
Found the fix? The tool that ends the problem is one click away.
The Stack