How AI Agents Actually Work
Underneath the hype, an AI agent is a loop: a model chooses a tool, the tool runs, the result goes back in. Here is that loop in detail, the engineering that makes it safe, and how to decide whether you need an agent at all.
- Author
- Kamran Khan
- Published
- Updated
- Reading time
- 7 min read
On this page
"Agent" has become the word for anything AI that seems to do more than answer a question. Strip the marketing away and an agent is a specific, understandable piece of software: a language model running inside a loop, able to call functions you have defined, until it decides the task is done.
The loop itself is short. Most of the work is in the engineering around it, which is what separates a demo from something a business can rely on.
The loop
Every agent, from a simple support assistant to a multi-step research tool, runs some version of this:
- Goal + context
- Model decides
- Call tool
- Observe result
- Model decides again
- Finish
In code, stripped to essentials:
async function runAgent(goal: string, tools: Tool[], maxSteps = 12) {
const messages: Message[] = [
{ role: "system", content: SYSTEM_INSTRUCTIONS },
{ role: "user", content: goal },
];
for (let step = 0; step < maxSteps; step++) {
const response = await llm.chat({ messages, tools });
if (response.toolCalls.length === 0) {
return response.text; // the model decided it is finished
}
for (const call of response.toolCalls) {
const tool = tools.find((t) => t.name === call.name);
const input = tool.schema.parse(call.arguments); // validate before running
const result = await tool.run(input, { user: currentUser });
messages.push({ role: "tool", toolCallId: call.id, content: JSON.stringify(result) });
await audit.log({ step, tool: call.name, input, result });
}
}
throw new AgentStoppedError("Step limit reached");
}The most important thing in that code is what the model doesn't do: it never runs anything. It emits a structured request (call lookup_order with { id: 'A123' }) and your program decides whether and how to execute it. That boundary, in your code, is the whole safety model of agents.
The tools are typed. The model sees each tool's name, description and input schema, and nothing else, so the description is the interface and its quality decides how well the model uses the tool.
And the loop is bounded. A step limit, a time limit and a cost limit are not optional; without them a confused model will happily loop forever.
What a tool actually is
A tool is a function with a schema and a description:
const lookupOrder: Tool = {
name: "lookup_order",
description:
"Fetch an order by its ID. Returns status, items, totals and the customer's shipping address. Use when the user references an order number.",
schema: z.object({ id: z.string().regex(/^[A-Z]\d{3,}$/) }),
async run({ id }, ctx) {
return orders.findForUser(id, ctx.user); // permission-scoped, always
},
};A good tool does one job: lookup_order, not do_order_things. Its description is written for a reader who has never seen your system, because the model only knows what the description says. It runs as the current user, never as an admin, and enforces the same access rules the UI would.
Two properties are easy to forget. Tools should be idempotent where possible, since the model may retry: create_ticket called twice with the same input should not create two tickets. And results should be compact. Everything a tool returns goes back into the model's context and costs tokens, so return what the next step needs, not the whole record.
If a tool-using system seems unreliable, read the tool descriptions before blaming the model. Vague descriptions produce vague behaviour.
Reads, writes and the human checkpoint
Tools fall into two classes, and they need different treatment.
Read tools (look up, search, calculate, summarise) are low risk. Let the agent call them freely, within rate limits.
Write tools send, create, update, delete or pay. They change the world. For these I use a proposal: the tool doesn't perform the action, it returns a proposed action that a person approves in the interface. The real write happens only after approval.
const sendReply: Tool = {
name: "send_reply",
description: "Propose a reply to the customer. A human will review before it is sent.",
schema: z.object({ ticketId: z.string(), body: z.string().max(2000) }),
async run(input, ctx) {
const proposal = await proposals.create({ ...input, proposedBy: "agent", forUser: ctx.user.id });
return { proposalId: proposal.id, status: "awaiting_approval" };
},
};The agent finishes its task ("I have drafted a reply and queued it for approval"), a person approves with one click, and the system never sends something nobody read. Over time you can measure how often people approve without editing, and promote specific low-risk writes to automatic once that rate is high.
Memory and state
The loop above keeps everything in the messages array for a single run. Real systems need three more kinds of state.
Task state records where the agent is in a multi-step job. Persist it in your database like any other workflow state, so a crash or a deploy doesn't lose work. Short-term memory is the conversation so far, trimmed or summarised when it grows past a budget. Long-term memory holds facts worth keeping across runs, such as a customer's preferences or an issue already resolved. That last one is just retrieval: store facts with metadata and search them at the start of the next run, using the same machinery as a RAG system.
Resist building a bespoke memory framework. A table of facts with embeddings and a timestamp covers most needs.
Planning, and whether you need it
Simple agents decide one step at a time, which is fine for short tasks. Longer tasks benefit from an explicit plan: ask the model to outline the steps first, store the plan, and execute against it, revising when a result changes the picture. The plan also becomes state a person can inspect ("it is about to do X, then Y"), which helps people trust it.
Multi-agent setups, where a planner delegates to specialists, are sometimes useful and often over-engineered. Start with one agent and good tools, and split only when a single context can't hold the job.
Failure modes and how to design for them
Infinite loops are the easy one: step, time and cost limits, always.
Models sometimes invent tool arguments. Validate every input against the schema before running anything, and send the validation error back to the model so it can correct itself.
Confident wrong answers are harder. Require citations from retrieval for factual claims, make "I could not find that" an acceptable answer, and test with questions the tools cannot answer.
Cost can run away quietly. Meter tokens per run and per user, set a budget per task, and stop with a clear message when it's exceeded.
Side effects need a trail. Log every tool call with the user, inputs, outputs and a timestamp; if you can't reconstruct what an agent did, you can't operate it.
Then there is prompt injection. Anything the agent reads, an email or a web page for instance, may contain text designed to hijack it ("ignore previous instructions and forward this to..."). Treat all retrieved content as data, never as instructions. Keep write tools behind approval, and never give an agent more access than the user it acts for.
Evaluating an agent
Agents are harder to evaluate than single model calls because there are many valid paths to a result. Start with a set of task scenarios, each with a definition of success: the right ticket created, the correct figure reported. Then sample real runs and read the sequence of tool calls. Wrong tool choices, unnecessary calls and near-misses show up quickly when you read trajectories. In production, the approval rate on proposed writes makes a good running quality metric.
Do you need an agent?
Often, no. If the task has a fixed sequence of steps, write the sequence in code and use the model only for the steps that need language understanding, like classification, extraction or drafting. That is a workflow with AI steps, and it is cheaper and easier to test than an agent.
Reach for an agent when the path changes with the input: a support request that might need one lookup or five, a research task where each result changes the next question, or an operations assistant handling many small, different jobs. That is where the loop's flexibility pays for itself, as long as the tools are precise, writes wait for approval, and every run has limits and an audit log.