- What is an AI agent? It is a program that calls a language model in a loop, executes the tool calls it returns, and stops on a defined condition.
- The loop on this page is 55 lines of TypeScript with no dependencies, tested on Node.js 26.12.1. It ran offline against a scripted fake model, so no hosted model was called.
- A working loop needs four stops: no tool calls, an input-token budget, repeated-call detection, and a turn limit. The traces in this article show what each one catches.
- Input cost grows faster than turn count because every call resends the history. Eight scripted turns used 15,382 estimated input tokens, and capping tool output at 200 characters cut that to 2,726.
- Use a workflow when you can write the steps down, and an agent only when the next step depends on what a tool returned.
An AI agent is a program that calls a language model in a loop, executes the tool calls it returns, and stops on a defined condition. Most answers to what is an AI agent end there, with a list of agent types. This page runs the loop as code and breaks it on purpose, using a scripted fake model and no API key.
#What is an AI agent
An AI agent is software in which a language model's output sets the next step. Your code sends a task and tools to the model, runs the tools named in the reply, and repeats until a reply has no tool calls or a limit fires. A chatbot, by contrast, makes one model call per message.
An agent has three parts: a model, tools, and a loop. You call the model through an API, and the tools and the loop are code you own. Anthropic's tool-use docs state the boundary directly: the model runs nothing itself, and it emits a structured request that your application executes.
Vendors draw the same line. Anthropic describes agents as models using tools in a loop based on feedback from the environment, and LangChain contrasts them with workflows that follow predetermined code paths. The phrase agentic AI is used more loosely, so apply a mechanical test: does code execute tool calls and feed the results back?
Explainers such as IBM's overview also sort agents into types like simple reflex, goal-based, and learning. That taxonomy does not change the code you write, so this page skips it.
The ReAct paper from 2022 interleaved reasoning traces with actions. Its authors reported absolute success-rate gains of 34 and 10 percentage points on ALFWorld and WebShop over imitation and reinforcement-learning baselines. Current APIs return the action as a structured block, so your code no longer parses it from prose.
#How AI agents work: the agent loop
An agent loop sends the full message list to the model, checks the reply for tool calls, runs them, appends the results, and calls the model again. It ends when a reply contains no tool calls or a limit your code set fires. Every other feature in an agent framework sits around that cycle.
The wire formats differ, and the loop stays the same. The names below come from the Anthropic docs and the OpenAI function-calling guide, as of November 8, 2026:
| Step | Anthropic Messages API | OpenAI Responses API |
|---|---|---|
| Tool call in the reply | tool_use block with stop_reason: "tool_use" | function_call item |
| Your code answers | tool_result block | function_call_output item |
| Marking a failure | is_error: true | No dedicated field, so the text carries it |
| Loop continues while | stop_reason is "tool_use" | The response contains tool calls |
Frameworks wrap the same cycle. The OpenAI Agents SDK treats a reply with no tool calls as final output, runs any tool calls it finds, and raises MaxTurnsExceeded when a run passes max_turns.
#AI agent architecture in 55 lines
The core of an AI agent architecture is one loop, a map of tools, and a set of limits. The TypeScript below has no dependencies and takes the model as a parameter, so the same loop can run against a hosted API or a script. It ran on Node.js 26.12.1, which executes TypeScript files directly.
export type ToolCall = { id: string; name: string; input: Record<string, unknown> };
export type Reply = { text: string; toolCalls: ToolCall[]; inputTokens: number };
export type Message =
| { role: "user"; content: string }
| { role: "assistant"; text: string; toolCalls: ToolCall[] }
| { role: "tool"; toolCallId: string; content: string; isError: boolean };
export type Model = { complete(messages: Message[]): Promise<Reply> };
export type Tool = (input: Record<string, unknown>) => string | Promise<string>;
export type Limits = { maxTurns: number; maxTotalInputTokens: number; maxRepeats: number; maxToolChars: number };
export type Result = { status: "done" | "max_turns" | "budget" | "repeated_call"; answer: string; turns: number; totalInputTokens: number };
async function runTool(tools: Record<string, Tool>, call: ToolCall) {
const tool = tools[call.name];
if (!tool) return { content: `Unknown tool ${call.name}. Available: ${Object.keys(tools).join(", ")}.`, isError: true };
try {
return { content: await tool(call.input), isError: false };
} catch (err) {
return { content: err instanceof Error ? err.message : String(err), isError: true };
}
}
export async function runAgent(task: string, model: Model, tools: Record<string, Tool>, limits: Limits): Promise<Result> {
const messages: Message[] = [{ role: "user", content: task }];
const repeats = new Map<string, number>();
let totalInputTokens = 0;
for (let turn = 1; turn <= limits.maxTurns; turn++) {
const reply = await model.complete(messages);
totalInputTokens += reply.inputTokens;
messages.push({ role: "assistant", text: reply.text, toolCalls: reply.toolCalls });
console.log(`turn ${turn}: input ${reply.inputTokens}, total ${totalInputTokens}`);
// Stop 1: the model returned no tool calls, so this reply is the answer.
if (reply.toolCalls.length === 0) return { status: "done", answer: reply.text, turns: turn, totalInputTokens };
// Stop 2: the run has spent its input-token budget.
if (totalInputTokens >= limits.maxTotalInputTokens) return { status: "budget", answer: "", turns: turn, totalInputTokens };
for (const call of reply.toolCalls) {
const key = `${call.name}:${JSON.stringify(call.input)}`;
const seen = (repeats.get(key) ?? 0) + 1;
repeats.set(key, seen);
// Stop 3: the same call with the same arguments came back too many times.
if (seen > limits.maxRepeats) return { status: "repeated_call", answer: "", turns: turn, totalInputTokens };
const result = await runTool(tools, call);
const content = result.content.slice(0, limits.maxToolChars);
console.log(` ${call.name} ${JSON.stringify(call.input)} -> ${result.isError ? "ERROR: " : ""}${content.slice(0, 40).replaceAll("\n", " ")}`);
messages.push({ role: "tool", toolCallId: call.id, content, isError: result.isError });
}
}
// Stop 4: the turn limit, for everything the other checks missed.
return { status: "max_turns", answer: "", turns: limits.maxTurns, totalInputTokens };
}The fake model is a stand-in for a hosted API:
import type { Message, Model, ToolCall } from "./agent.ts";
type PlannedCall = Omit<ToolCall, "id">;
type Step = string | PlannedCall[];
export const call = (name: string, input: Record<string, unknown>): PlannedCall => ({ name, input });
// Replays a fixed script. It reads the messages only to estimate their size.
// A string is a final answer. An array is a set of tool calls.
export function scriptedModel(script: Step[] | ((turn: number) => Step)): Model {
let turn = 0;
return {
async complete(messages: Message[]) {
turn += 1;
const step = typeof script === "function" ? script(turn) : script[turn - 1];
if (step === undefined) throw new Error(`Script ran out at turn ${turn}`);
// Rough estimate of 4 characters per token. A real API returns exact counts.
const inputTokens = Math.ceil(JSON.stringify(messages).length / 4);
if (typeof step === "string") return { text: step, toolCalls: [], inputTokens };
return { text: "", toolCalls: step.map((c, i) => ({ id: `call_${turn}_${i}`, ...c })), inputTokens };
},
};
}scriptedModel replays a fixed list of replies, one per call. It does no inference and reads the messages only to estimate their size at four characters per token, so real token counts would differ. No hosted model was called and no network request was made, so these traces test the loop and its stops and say nothing about any model's quality.
The scenarios live in run.ts:
import { runAgent, type Limits, type Tool } from "./agent.ts";
import { call, scriptedModel } from "./fake-model.ts";
const files: Record<string, string> = {
"infra/billing.env": "PORT=8443\nLOG_LEVEL=info\n",
"infra/auth.env": "PORT=9000\n",
};
const tools: Record<string, Tool> = {
search_files: ({ query }) => Object.keys(files).filter((p) => p.includes(String(query))).join("\n") || "No matches.",
read_file: ({ path }) => {
const text = files[String(path)];
if (text === undefined) throw new Error(`No file named ${path}. Call search_files to find the path.`);
return text;
},
read_log: ({ page }) => `page ${page}: ` + "x".repeat(2000),
};
const limits: Limits = { maxTurns: 8, maxTotalInputTokens: 20_000, maxRepeats: 2, maxToolChars: 10_000 };
const scenario = process.argv[2];
const task = "Which port does the billing service use?";
if (scenario === "recover") {
const model = scriptedModel([
[call("read_file", { path: "billing.env" })],
[call("search_files", { query: "billing" })],
[call("read_file", { path: "infra/billing.env" })],
"The billing service uses port 8443.",
]);
console.log(await runAgent(task, model, tools, limits));
}
if (scenario === "repeat") {
const model = scriptedModel(() => [call("read_file", { path: "billing.env" })]);
console.log(await runAgent(task, model, tools, limits));
}
if (scenario === "drift") {
const model = scriptedModel((turn) => [call("read_log", { page: turn })]);
console.log(await runAgent(task, model, tools, limits));
}
if (scenario === "budget") {
const model = scriptedModel((turn) => [call("read_log", { page: turn })]);
console.log(await runAgent(task, model, tools, { ...limits, maxTurns: 50, maxTotalInputTokens: 8_000 }));
}
if (scenario === "trim") {
const model = scriptedModel((turn) => [call("read_log", { page: turn })]);
console.log(await runAgent(task, model, tools, { ...limits, maxToolChars: 200 }));
}Here is the normal path from node run.ts recover. The script's first tool call fails on purpose:
turn 1: input 18, total 18
read_file {"path":"billing.env"} -> ERROR: No file named billing.env. Call search_f
turn 2: input 79, total 97
search_files {"query":"billing"} -> infra/billing.env
turn 3: input 128, total 225
read_file {"path":"infra/billing.env"} -> PORT=8443 LOG_LEVEL=info
turn 4: input 182, total 407
{
status: 'done',
answer: 'The billing service uses port 8443.',
turns: 4,
totalInputTokens: 407
}#Workflows, agents, and multi-agent systems
A workflow runs steps your code fixed in advance, an agent lets model output set each next step, and a multi-agent system has one agent hand work to others. Choose by how much of the path you can write down, because each move along that list adds cost and new ways to fail.
| Workflow | Agent | Multi-agent system | |
|---|---|---|---|
| Next step set by | Your code | Model output, inside your limits | A lead agent's output, then each worker's |
| Number of steps | Known in advance | Unknown until the run ends | Unknown, often in parallel |
| Main control | Branches and retries | Stop conditions and tool design | Delegation rules and shared state |
| Typical failure | A branch you did not write | Loops and context growth | Over-delegation and duplicated work |
| Tokens versus a chat, per Anthropic | Not reported | About 4 times | About 15 times |
| Choose it when | You can list the steps | The next step depends on a tool result | Subtasks are independent and can run at once |
Anthropic's guidance on building agents recommends the simplest solution that works, and it says agentic systems often trade latency and cost for task performance. Take that as the default. If your feature has a known path, a workflow is cheaper to run and easier to test.
The token multiples come from Anthropic's June 2025 account of its research system, which uses a lead agent that delegates to subagents working in parallel. They are one vendor's measurements of its own product, and no independent measurement exists. The same post reports early versions spawning 50 subagents for simple queries.
#Unbounded loops need two kinds of stop
An unbounded loop happens when the model keeps returning tool calls and nothing in your code ends the run. A repeat detector catches the same call sent again with the same arguments, and a turn limit catches everything else. You need both, because a run that changes one argument each turn never trips the repeat check.
The repeat scenario returns the same failing call forever. The loop stops on turn 3, before it runs the tool a third time:
turn 1: input 18, total 18
read_file {"path":"billing.env"} -> ERROR: No file named billing.env. Call search_f
turn 2: input 79, total 97
read_file {"path":"billing.env"} -> ERROR: No file named billing.env. Call search_f
turn 3: input 139, total 236
{
status: 'repeated_call',
answer: '',
turns: 3,
totalInputTokens: 236
}The drift scenario asks for page 1, page 2, page 3, and so on. Every call is new, so the repeat check stays silent, and only the turn limit ends the run. This is node run.ts drift | grep -v read_log:
turn 1: input 18, total 18
turn 2: input 562, total 580
turn 3: input 1106, total 1686
turn 4: input 1651, total 3337
turn 5: input 2195, total 5532
turn 6: input 2739, total 8271
turn 7: input 3283, total 11554
turn 8: input 3828, total 15382
{ status: 'max_turns', answer: '', turns: 8, totalInputTokens: 15382 }Anthropic reports the same failure mode in its own early agents, which searched endlessly for sources that did not exist. Set the turn limit from observed runs of the task, and treat max_turns as an error to log. A run that hits it has no answer, and the caller needs to know that.
#Tool errors are input for the next turn
Send a tool failure back to the model as a tool result. An uncaught exception ends the run, and a readable message gives the next model call something to act on. In the recover trace above, the first read fails, the error names search_files, and the run finishes on turn 4.
runTool catches every exception and returns it with isError: true.
Anthropic's docs advise instructive error text that says what went wrong and what to try next. They also say a call with invalid parameters typically gets 2 to 3 corrected attempts. The MCP tools spec has a similar split: isError results are meant to reach the model, and clients may or may not forward protocol errors.
Three tool-level problems need code that the sample leaves out:
- Timeouts: wrap each call with
AbortSignal.timeout, since one hung tool blocks the whole loop. - Repeated side effects: a retried write can run twice, so use idempotency keys and put a confirmation step on anything that changes data.
- Injected instructions: tool results can carry indirect prompt injection, as Anthropic's docs warn, so treat them as untrusted data and enforce guardrails in code.
#Context growth makes every turn larger
Context grows because each model call resends the whole message list, including every earlier tool result. In the scripted run, each 2,000-character tool result added about 544 estimated tokens, and input climbed from 18 tokens on turn 1 to 3,828 on turn 8. The 200-character cap kept turn 8 at 664.
The budget scenario caps total input at 8,000 tokens. The check runs after each model call, so the run overshoots by one turn and stops at 8,271. This is node run.ts budget | grep -v read_log, trimmed to its last lines:
turn 5: input 2195, total 5532
turn 6: input 2739, total 8271
{ status: 'budget', answer: '', turns: 6, totalInputTokens: 8271 }A hosted model has a hard ceiling too. Anthropic's API returns model_context_window_exceeded when a response fills the context window. Anthropic's context engineering guide names compaction and tool result clearing as the standard responses, and context engineering covers the budget in detail.
Truncation has a price. A 200-character cap keeps the loop cheap and can cut the one line the next step needed. Pair a cap with pagination, or with a tool that fetches more on request.
#What the loop costs
Agent cost follows total input tokens, and total input grows roughly with the square of the turn count. Doubling the turns in the table below roughly quadruples the input tokens, so a turn limit is also a spending limit. Output tokens and any per-call tool fees come on top of these numbers.
| Turns | Total input tokens | Last call's input | Uncached | With prompt caching |
|---|---|---|---|---|
| 5 | 5,532 | 2,195 | $0.0111 | $0.0058 |
| 10 | 24,670 | 4,916 | $0.0493 | $0.0143 |
| 25 | 163,842 | 13,095 | $0.3277 | $0.0478 |
| 50 | 668,420 | 26,726 | $1.3368 | $0.1310 |
| 100 | 2,699,920 | 53,989 | $5.3998 | $0.3996 |
The token columns come from the same loop and fake model, with every tool returning about 2,000 characters. The dollars use Claude Sonnet 5.5 rates as of November 8, 2026: $2 per million input tokens, $2.50 for 5-minute cache writes, and $0.10 for cache hits. They are arithmetic on estimated tokens, and no API was billed.
The caching column assumes every call lands inside the cache window and hits the full prefix from the call before it. Under that assumption, prompt caching makes the 100-turn run 13.5 times cheaper, and the cost still rises faster than the turn count. See Claude API pricing and Gemini API pricing for current rates, and rerun the arithmetic with your own tool output sizes.
#Start with a workflow, then earn the loop
Build an agent only after you can name the step a fixed workflow cannot handle. If you can list the steps, write a workflow. If the next step depends on what a tool returned, that test is the useful answer to what is an AI agent in your own codebase.
Take these steps in order:
- Copy the loop above and write all four stops before you tune a single prompt.
- Log turns and input tokens per run, then set limits from the distribution of real tasks.
- Add tools through a tested MCP server when more than one host needs them, and browse MCP guides for the rest of the protocol.
- Score finished runs with LLM evaluation and an LLM judge, since a completed loop says nothing about a correct answer.
- Compare models for your task with the best LLM for coding and the platforms directory, then read more in the coding agents and agents hubs.
#Questions
What is an AI agent in simple terms?
An AI agent is software that repeatedly sends a task to a language model, runs the tools named in the reply, and feeds the results back until the job ends or a limit fires. A chatbot makes one model call per message. An agent can make many model calls for one request.
What is the difference between an AI agent and a chatbot?
A chatbot generates a reply to each message and stops. An AI agent runs a loop: model output names a tool, your code runs it, and the result goes back for another model call. The difference is that code executes actions and returns their results before anything is shown to the user as finished.
Is ChatGPT an AI agent?
Apply a mechanical test instead of relying on the product name. A session that only generates text makes one model call per message, so it does not meet the definition used here. A session that runs tools in a loop and feeds the results back to the model does meet it. The same test works for any product.
What are the main components of an AI agent?
An AI agent has a model that returns text or tool calls, a set of tools with schemas, and a loop that runs those tools and resends the results. Production agents add stop conditions, a token budget, logging, and permission checks. The loop and its limits are code you write, and the model API does not supply them.
What is the difference between an AI agent and a workflow?
In a workflow, your code fixes the order of steps in advance, and a model may fill in some of them. In an AI agent, the model's output sets each next step at run time, inside limits your code enforces. Choose a workflow when you can write the steps down, because it is cheaper to run and easier to test.
How do you stop an AI agent from looping forever?
Give the loop several independent stops. End when a reply has no tool calls, cap the turn count, cap total input tokens, and stop when the same tool call repeats with the same arguments. Repeat detection alone is not enough, because a run that changes one argument each turn never trips it.
How does MCP relate to AI agents?
MCP is a standard way to connect an agent's host application to tools and data. The agent loop still runs in the host: model output names a tool, and the host sends that call to an MCP server. You can build an agent with no MCP at all.