Ask a language model to summarise a document and it returns text. Ask it to "find the three overdue invoices in our billing system and email the account owners" and text is useless — something has to query the database, decide which rows qualify, compose the emails, send them, and notice when the mail API returns a 429. That gap between producing text and producing an effect is the entire subject of AI agents.
An AI agent is a language model placed inside a control loop that can call tools, observe the results of those calls, and decide whether to continue or stop. Remove any one of those three parts — the loop, the tools, the stopping rule — and you have something else: a chatbot, a function call, or an infinite loop that burns tokens.
The definition that separates an agent from a chatbot
The word "agent" gets stretched to cover everything from a system prompt with a personality to a fully autonomous deployment pipeline. The useful distinction is about who decides what happens next.
In a conventional LLM feature, control flow belongs to your code. You decide to call the model, you decide what to do with the output, and the model has no say in the sequence. In an agent, the model participates in control flow. It chooses which tool to invoke, in what order, and when the objective has been met. Your code still owns the boundaries — which tools exist, what they are permitted to touch, how many iterations are allowed — but within those boundaries the model steers.
That single shift is what makes agents both powerful and difficult. A system whose control flow is decided at runtime by a probabilistic process cannot be reasoned about the way a state machine can. It has to be constrained, observed and evaluated instead.
The loop, in about forty lines
Strip away frameworks and an agent loop is small enough to read in one sitting. The model is called with a conversation and a set of tool definitions. If it responds with text, you are done. If it responds with tool calls, you execute them, append the results to the conversation, and call the model again.
export async function runAgent({ model, tools, messages, maxSteps = 12 }) {
const registry = new Map(tools.map((tool) => [tool.name, tool]));
const transcript = [...messages];
for (let step = 0; step < maxSteps; step += 1) {
const response = await model.complete({
messages: transcript,
tools: tools.map(({ name, description, parameters }) => ({
name,
description,
parameters,
})),
});
transcript.push(response.message);
if (!response.toolCalls?.length) {
return { status: 'complete', output: response.message.content, transcript };
}
const results = await Promise.all(
response.toolCalls.map(async (call) => {
const tool = registry.get(call.name);
if (!tool) {
return { id: call.id, isError: true, content: `Unknown tool: ${call.name}` };
}
try {
const parsed = tool.schema.parse(call.arguments);
const value = await tool.execute(parsed);
return { id: call.id, isError: false, content: JSON.stringify(value) };
} catch (error) {
return { id: call.id, isError: true, content: error.message };
}
}),
);
transcript.push({ role: 'tool', results });
}
return { status: 'max_steps', output: null, transcript };
}Three lines carry most of the weight. Line 14 appends the model's own message before anything else happens, so the transcript stays a faithful record even if a tool throws. Line 22 turns an unknown tool name into an observation rather than an exception — the model can read that and correct itself, where a thrown error would end the run. Line 31 does the same for validation and execution failures. An agent that cannot see its own mistakes cannot recover from them.
Everything else — planning, memory, multi-agent orchestration — is an elaboration of this loop, not a replacement for it.
Tools are the agent's entire connection to reality
A model has no ability to read a file, query a table or send a request. Every effect it has on the world passes through a tool you wrote. That makes tool design the highest- leverage work in an agent codebase, and the place where most agent projects go wrong.
A tool definition is a contract in three parts
A tool is a name, a natural-language description, and a machine-readable parameter schema. The description is read by the model; the schema is enforced by your code. Both matter, and they fail differently.
import { z } from 'zod';
export const searchInvoices = {
name: 'search_invoices',
description:
'Find invoices for a single customer. Returns at most 50 rows, newest first. ' +
'Use status="overdue" for invoices past their due date that are still unpaid. ' +
'Does not return invoices from archived accounts.',
schema: z.object({
customerId: z.string().describe('Internal customer UUID, not the display name'),
status: z.enum(['draft', 'open', 'paid', 'overdue']).default('open'),
limit: z.number().int().min(1).max(50).default(20),
}),
async execute({ customerId, status, limit }) {
const rows = await db.invoice.findMany({
where: { customerId, status },
orderBy: { issuedAt: 'desc' },
take: limit,
select: { id: true, amountCents: true, currency: true, dueAt: true },
});
return { count: rows.length, invoices: rows };
},
};Notice what the description does. It states the return shape, the cap, the meaning of a non-obvious enum value, and — importantly — one thing the tool does not do. Models fail at tool selection far more often than they fail at tool invocation, and negative statements are what prevent a plausible-looking wrong choice.
Schemas do work that prompting cannot
It is tempting to write "always pass a UUID, never a customer name" into the system prompt. Prompt instructions are advisory; a schema is enforced. When validation fails, the failure comes back as an observation the model can act on, which is a far better outcome than a database query that silently matches nothing.
The practical rule: anything expressible as a type belongs in the schema, anything about when to use the tool belongs in the description, and the system prompt should be left for genuinely global policy.
Memory is a budgeting problem
"Memory" in agent systems is used for two unrelated things, and conflating them causes real bugs.
The first is the transcript — the running list of messages, tool calls and results inside a single run. This is not memory in any interesting sense; it is the model's input, and it grows every step. Every observation you append is paid for on every subsequent turn. A tool that returns a full HTML page instead of extracted fields will consume the context window in three steps.
The second is persistence across runs: facts the agent should still know tomorrow. This is a retrieval problem, and it is closer to RAG than to agent design — you store artefacts, then fetch the relevant ones back into the transcript when a new run starts.
The design questions that matter are about the budget:
| Concern | Failure when ignored | Typical mitigation |
|---|---|---|
| Tool output size | Context exhausted mid-run | Return fields, not documents; paginate |
| Transcript growth | Cost rises superlinearly with steps | Summarise or drop old tool results |
| Stale retrieved facts | Agent acts on outdated state | Re-read before write; timestamp everything |
| Cross-run leakage | Agent "remembers" another user's data | Namespace memory by principal, always |
Planning: explicit, implicit, and the middle
Three planning styles show up in production, and the right one depends on how predictable the task is.
Implicit planning is the loop above with no plan at all. The model decides one step at a time. It is the most robust to surprise and the hardest to audit, because there is no artefact stating what the agent intended.
Explicit planning asks the model to produce a plan first, then execute it step by step. The plan is inspectable, which makes it reviewable by a human before anything irreversible happens. The cost is rigidity: a plan written before the first observation is a plan written with the least information the agent will ever have.
Interleaved planning — plan, execute a step, revise the plan — is the middle ground most mature systems land on. It keeps an auditable artefact while allowing the agent to abandon an approach that observation has invalidated.
The value of an explicit plan is not that the model follows it. It is that a human can read it and stop the run.
Where the loop actually breaks
Agent failures are boringly consistent across codebases.
Looping without progress. The model calls the same tool with the same arguments, gets the same result, and calls it again. The fix is not a better prompt; it is detecting the repeat in your loop and injecting an observation that says so.
Silent tool success. A tool returns { ok: true } when it did nothing useful — a
search with no matches, an update that touched zero rows. The model reads success and
moves on. Tools should distinguish "worked and found nothing" from "worked and did the
thing", explicitly.
Context contamination. Content fetched from the outside world lands in the same transcript as your instructions, and the model has no reliable way to tell them apart. This is the mechanism behind prompt injection, which is an architecture problem rather than a wording problem.
Unbounded blast radius. The agent has a delete_records tool because it needed one
for a rare cleanup path, and now every run can reach it. Tool availability should be
scoped per run, not per application.
Partial completion. The loop hits maxSteps after sending two of five emails.
Without idempotency keys and a durable record of what was done, retrying either
duplicates work or skips it.
Knowing whether it works
You cannot evaluate an agent by reading its final message. The output is downstream of a dozen decisions, and a good-sounding answer can sit on top of a wrong tool call.
Useful evaluation looks at the trajectory: which tools were selected, whether the arguments were well-formed, how many steps the run took, whether the agent recovered from its first error, and whether the end state of the system is correct. That last one is the only ground truth that matters — did the right rows change?
Build the harness before the agent is impressive. A fixed set of scenarios with known correct end states, run on every prompt or tool change, is the difference between tuning and guessing.
When an agent is the wrong answer
If the sequence of steps is known in advance, an agent is a slower and less reliable way to run a script. Deciding between the two is the whole subject of AI agents versus traditional automation, and the honest answer is that most workflows labelled "agentic" are deterministic pipelines with a model doing classification at one step.
Reach for an agent when the branching factor is genuinely high, the inputs are unstructured, and the cost of an occasional wrong step is recoverable. Reach for a workflow engine when the steps are known and the cost of a wrong step is not.
The interesting engineering question is rarely "can a model do this?" It is "what is the smallest amount of autonomy that solves the problem?" Every increment beyond that is a surface you now have to observe, constrain and pay for. Standardising the tool layer — which is what the Model Context Protocol was designed for — narrows one part of that surface, but the loop, the budget and the blast radius remain yours to design.