How an AI agent actually works
An agent looks like magic until you see the loop inside it. Here is the whole thing, built in under 50 lines.
You ask an AI to check the weather in Pune. It goes quiet for a second, looks something up, and comes back with an answer. It feels like the AI went out and did something.
It didn't. Behind the curtain there is a small loop, and once you see it, every "AI agent" you meet will make more sense. This post shows you that loop, then builds one you can run.
What an agent is
An agent is three things working together:
- A model. The AI itself. It can only read text and write text. It cannot open a website, run code or check the weather.
- Tools. Ordinary functions you write, like
get_weather(city). The model never runs them. It can only ask for one by name. - A loop. A few lines of code that sit between the two. This is the part people forget, and it is the whole trick.
Here is the loop in motion. Press the machine to step through it, or just watch.
Read it left to right: the model decides it needs a tool, your code runs it, the result goes back to the model, and the model decides again. When it has enough, it answers instead of asking.
That is it. An agent is a model in a loop with some tools.
The model asks, your code acts
This is the part that surprises most people. When the model "uses a tool", it just writes a short note: please call get_weather with city = Pune. Nothing has happened yet. Your code reads the note, runs the real function, and hands the result back as a new message.
That split is useful. You decide which tools exist, and your code is the one that runs them.
Build one
We will build the smallest agent that still counts. To keep it runnable anywhere, with no account or API key, we will use a fake model that behaves like a real one. Swapping in a real model comes later.
Step 1: the messages
An agent's memory is a list of messages. Each message says who spoke and what they said.
type Message =
| { role: "user"; content: string }
| { role: "assistant"; content: string; toolCall?: ToolCall }
| { role: "tool"; content: string }
type ToolCall = { name: string; args: Record<string, string> }There are three speakers. The user asks. The assistant is the model, and it may attach a toolCall. The tool message carries a tool's result back.
Step 2: a tool
A tool is a plain function. We keep them in an object so we can look one up by name.
const tools: Record<string, (args: Record<string, string>) => string> = {
get_weather: ({ city }) => `It is 21C and sunny in ${city}.`,
}Step 3: a stand-in model
A real model reads the whole conversation and decides what to do next. Ours does the same, just very simply: if the last message is a tool result, answer. Otherwise, ask for the weather.
function callModel(messages: Message[]): Message {
const last = messages[messages.length - 1]
if (last.role === "tool") {
return { role: "assistant", content: `Good news. ${last.content}` }
}
return {
role: "assistant",
content: "",
toolCall: { name: "get_weather", args: { city: "Pune" } },
}
}Notice that the model never calls get_weather itself. It only returns a toolCall note.
Step 4: the loop
Here is the part that makes it an agent.
function runAgent(task: string, maxSteps = 10): string {
const messages: Message[] = [{ role: "user", content: task }]
for (let step = 0; step < maxSteps; step++) {
const reply = callModel(messages)
messages.push(reply)
if (!reply.toolCall) return reply.content
const tool = tools[reply.toolCall.name]
const result = tool
? tool(reply.toolCall.args)
: `Unknown tool: ${reply.toolCall.name}`
messages.push({ role: "tool", content: result })
}
return "Stopped: too many steps."
}
console.log(runAgent("What is the weather in Pune?"))Run it and you get:
Good news. It is 21C and sunny in Pune.Walk through what happened. The loop asked the model what to do. The model asked for get_weather. The loop ran it and added the result to the messages. It asked the model again, and this time the model had what it needed, so it answered and the loop ended.
The messages are the memory
The model has no memory of its own between calls. Every time the loop asks it something, it sends the entire message list again. That list is everything the agent knows.
Which means it only ever grows.
There is a ceiling on how much the model can read at once, called its context window. A long job with many tool calls can fill it. Real agents have to trim or summarise old messages to keep going. It is one of the first problems you hit when an agent works on something bigger than a weather lookup.
When agents go wrong
A loop is powerful and a little dangerous. Three things to plan for from day one.
It might never stop. If the model keeps asking for tools and never answers, the loop runs forever and your bill grows. That is why our function takes maxSteps. It is one line, and it is the most important line in the file.
It might ask for something that doesn't exist. Models sometimes invent tool names. Our loop handles it by returning an "Unknown tool" message, so the model can try again instead of the program crashing.
It might ask for something you don't want it to do. The model decides which tool to call, but your code decides whether to run it. For anything risky, like deleting files or sending email, ask a human first. This is the real reason the tool step lives in your code and not in the model.
Swapping in a real model
Replace callModel with a call to a real API and the rest of the loop stays almost the same. With Anthropic's SDK it looks like this:
import Anthropic from "@anthropic-ai/sdk"
const client = new Anthropic()
const response = await client.messages.create({
model: "claude-sonnet-5-5",
max_tokens: 1024,
tools: [
{
name: "get_weather",
description: "Get the current weather for a city.",
input_schema: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
],
messages,
})
if (response.stop_reason === "tool_use") {
// The model is asking for a tool. Run it, add the result, call again.
}The shape is the same as ours. The model returns a request for a tool, your code runs it, and the result goes back as the next message. The stop_reason tells you whether the model is asking for a tool or is finished.
What real agents add
Everything you have seen in a coding assistant or a research agent is this loop plus some extras:
- More tools: read a file, run a command, search the web.
- Memory tricks: summaries, notes saved to disk, searching old messages.
- Safety: permission prompts, limits on spending, sandboxes.
- Planning: breaking a big job into smaller tasks, sometimes handing them to other agents.
None of it changes the core idea. It is still a model, some tools, and a loop.
Try it yourself
Copy the four steps above into a file called agent.ts and run it with node agent.ts. Then change things and watch what happens:
- Add a second tool, like
get_time, and teach the stand-in model to use it. - Set
maxStepsto1and see what the agent says. - Make the model ask for a tool that doesn't exist.
When you are ready, plug in a real model and give it a tool that does something useful. That is all an agent is, and now you know how to build one.