2026-10-04
An AI agent is just a while loop
What I learned building one from scratch, and a bug that no framework can fix for you.
I've built agents with frameworks for a while. Recently I went back to basics: no LangGraph, no ADK. Just a model API and a loop. This post is everything I learned, in the order I learned it.
In a hurry? Read section 1 and the takeaways at the end.
1. What an agent actually is
That's the whole thing. Memory, planning, multiple agents, human approval: all of it gets added around this loop.
Once you've written the loop yourself, frameworks stop looking like magic. They start looking like structure.
2. Describe your tools
The model never sees your code. It only sees three things: a name, a description, and an input schema.
Two things follow from that.
The description is part of your prompt. The model reads it to decide when to call the tool. Change the description and you change how your agent behaves. So review it and test it like a prompt change.
Keep the schema and the code together. An easy mistake is to put all the JSON schemas in one file and the handler functions in another. They drift apart. Someone renames a parameter in the function and forgets the schema. Instead, define the shape once with Zod and build everything from it:
// tools/types.ts
import { z } from "zod";
export interface Tool<S extends z.ZodTypeAny = z.ZodTypeAny> {
name: string;
description: string;
schema: S;
execute: (input: z.infer<S>) => Promise<unknown>;
}
// tools/getScore.ts
import { z } from "zod";
import type { Tool } from "./types";
const scores: Record<string, number> = { Rahul: 60, Shivam: 90 };
const schema = z.object({
student_name: z.string().describe("The student's name"),
});
export const getScoreTool: Tool<typeof schema> = {
name: "get_score",
description: "Get the score for a student",
schema,
execute: async ({ student_name }) => scores[student_name] ?? "unknown student",
};
// tools/index.ts: one place that decides what the model can use
import type { Tool } from "./types";
import { getScoreTool } from "./getScore";
export const tools: Tool[] = [getScoreTool];
Now the compiler stops the schema and the function from quietly disagreeing. MCP servers use the same idea: name, description, schema and handler travel together as one unit.
3. Write the loop
import Anthropic from "@anthropic-ai/sdk";
import { zodToJsonSchema } from "zod-to-json-schema"; // newer Zod versions ship their own toJSONSchema
import { tools } from "./tools";
const anthropic = new Anthropic();
const MODEL = process.env.CLAUDE_MODEL!; // whichever Claude model you have access to
const MAX_ITERATIONS = 10;
const toolDefinitions: Anthropic.Tool[] = tools.map((t) => ({
name: t.name,
description: t.description,
// the cast just keeps the compiler quiet
input_schema: zodToJsonSchema(t.schema) as unknown as Anthropic.Tool.InputSchema,
}));
async function runAgent(userMessage: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
for (let i = 0; i < MAX_ITERATIONS; i++) {
const response = await anthropic.messages.create({
model: MODEL,
max_tokens: 1024,
messages,
tools: toolDefinitions,
});
messages.push({ role: "assistant", content: response.content });
// Done? Return the text.
if (response.stop_reason !== "tool_use") {
return response.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("\n");
}
// Not done: run every tool the model asked for.
const toolUses = response.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
);
const results = await Promise.all(
toolUses.map(async (block) => {
const tool = tools.find((t) => t.name === block.name);
if (!tool) {
return {
type: "tool_result" as const,
tool_use_id: block.id,
content: `Unknown tool: ${block.name}`,
is_error: true,
};
}
try {
const input = tool.schema.parse(block.input);
const output = await tool.execute(input);
return {
type: "tool_result" as const,
tool_use_id: block.id,
content: String(output),
};
} catch (err) {
return {
type: "tool_result" as const,
tool_use_id: block.id,
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
is_error: true,
};
}
})
);
messages.push({ role: "user", content: results });
}
throw new Error(`Gave up after ${MAX_ITERATIONS} iterations`);
}
(I trimmed the types a little to keep it readable. Check them against your SDK version.)
Four things in this code matter. Don't just copy them. Understand them:
stop_reasonis the real exit. A response can hold some text and a tool call in the same turn. "Did I get text back?" is the wrong question. "Did it stop asking for tools?" is the right one.- Tools run at the same time. If the model asks for two, they can finish in any order. That's why you need the IDs from section 4.
- A failing tool doesn't crash the loop. The error goes back to the model with
is_error: true. The model can retry, change its input, or tell the user. MAX_ITERATIONSis a safety stop. Without it, a model stuck in a call-fail-call-fail cycle runs forever and quietly burns tokens.
4. Many tool calls at once: the coat-check problem
Ask "What is the score of Rahul and Shivam?" The model may call get_score("Rahul") and get_score("Shivam") in the same turn. It's the same tool twice, so the tool name can't tell the two results apart.
Think of a coat check. You hand over two coats and get two numbered tickets. When you come back, they find your coats by ticket number, not by description.
Tool calling works the same way. Every request has a unique ID. Every result carries that ID back (tool_use_id on Anthropic, tool_call_id on OpenAI-style APIs). The model is trained to read that field.
- 1
The model sends two requests. Each gets its own ticket.
call_A→get_score("Rahul")call_B→get_score("Shivam") - 2
The tools finish in any order. Each result brings its ticket back.
call_B→90call_A→60 - 3
The model matches by ticket, not by order.
Rahul = 60, Shivam = 90
One trap when you switch providers. Anthropic wants tool results inside a user message, as tool_result blocks. OpenAI-style APIs, including Ollama's compatible endpoint, use a separate message with role: "tool". Same idea, different shape. Mix them up and you get a 400 error.
5. The bug: my model ignored the IDs
I tested with a small local model: Qwen3 8B, running on Ollama. I hit three problems in a row. Each one taught me something.
Problem 1: Ollama has two APIs, and they're not the same.
| /api/chat | /v1/chat/completions | |
|---|---|---|
| What it is | Ollama's own schema | Ollama imitating OpenAI's API |
| Result matching | tool_name only | tool_call_id |
| Response shape | message, done, timing stats | choices, usage |
I first added tool_call_id to a request on /api/chat. Nothing happened, because that endpoint has no such field.
At the time of writing, Ollama's own docs return tool results by tool_name, and there's an open GitHub issue about exactly this (ollama/ollama#11417). With only a tool_name, two calls to the same tool can't be told apart. The example in Ollama's docs even handles just the first tool call, and says it's recommended for models that return a single one.
Problem 2: a 404. The compatible endpoint is /v1/chat/completions. My path was slightly wrong. Easy to do, and the error doesn't tell you why.
Problem 3: I forgot the assistant message. An ID in a tool result only means something if the conversation also has the assistant turn that created it, the one with the tool_calls array. Without it, the IDs are just strings pointing at nothing.
The experiment. With all three fixed, I ran a test to tell "reads the IDs" apart from "guesses by position". Same two tool calls, but I swapped the results between the IDs on purpose.
What I sent
The results arrive in this order: call_B (60) first, then call_A (90).
A model that reads the IDs says
Rahul 90 · Shivam 60
Claude: followed the IDs
A model that goes by position says
Rahul 60 · Shivam 90
Qwen3 8B: went by position
Here is the request I sent to POST http://localhost:11434/v1/chat/completions:
{
"model": "qwen3:8b",
"stream": false,
"messages": [
{"role": "system", "content": "You are a helpful assistant who works for a school and provides grades scored by students."},
{"role": "user", "content": "What is the score of Rahul and Shivam?"},
{"role": "assistant", "content": "", "tool_calls": [
{"id": "call_A", "type": "function", "function": {"name": "get_score", "arguments": "{\"student_name\":\"Rahul\"}"}},
{"id": "call_B", "type": "function", "function": {"name": "get_score", "arguments": "{\"student_name\":\"Shivam\"}"}}
]},
{"role": "tool", "tool_call_id": "call_B", "content": "60"},
{"role": "tool", "tool_call_id": "call_A", "content": "90"}
],
"tools": [{
"type": "function",
"function": {
"name": "get_score",
"description": "Get the score for a student",
"parameters": {
"type": "object",
"properties": {"student_name": {"type": "string"}},
"required": ["student_name"]
}
}
}]
}
call_A is Rahul's call, and it carries the result 90. call_B is Shivam's call, and it carries 60. A model that reads the IDs answers Rahul 90, Shivam 60. A model that goes by position answers Rahul 60, Shivam 90, because 60 shows up first.
Qwen3 8B answered Rahul 60, Shivam 90. Its own reasoning said the first response was 60 for Rahul. It never mentioned an ID.
The control. I ran the same swapped test on Claude. It followed the IDs.
One honest caveat: this was one run on one small model. Treat it as an observation, not a benchmark. But it shows something real. A request can be perfectly valid, return a clean 200, and still not be used the way the protocol intends.
6. What frameworks can and can't fix
My first thought was that LangGraph or ADK must handle this. After thinking it through, they can't, at least not at the model level. A framework builds the request correctly: the right IDs, in the right order. It can't change what the model does with those IDs when it writes its answer.
(I haven't tested what LangGraph does in this exact case yet. That's the next video.)
Here is what people actually do about it:
- Turn off parallel tool calls for models you don't trust here. OpenAI-style APIs have a
parallel_tool_calls: falseoption. Anthropic hasdisable_parallel_tool_useinsidetool_choice. Support varies, especially for local models. One call at a time means order and identity are the same thing. - Design the tool to avoid the problem.
get_scores(names: string[])is one call with one structured result. There is nothing to match. - Catch it with evals. Write a test that would have caught this before it shipped. A swapped-ID test like the one above is a ten-line regression test.
- Route by capability. Use a stronger model when you need reliable multi-tool handling. Use a small local model for simple single-tool work.
7. What this loop doesn't have yet
It forgets everything when the function returns. No memory across sessions. No saved state. No human approval step. No second agent.
That's on purpose. Each of those gets added around this loop, and I'll build them one at a time in the series.
Takeaways
- An agent is a
whileloop around a model call. - The loop ends on
stop_reason, not on "did I get text back." - Tool descriptions are prompts. Schema and code belong together.
- Parallel tool calls need IDs, and not every model honors them.
- A valid request that returns 200 is not proof the model used it correctly.
- Cap your iterations, and send tool errors back to the model instead of crashing.
- Verify behavior independently. Don't take the model's word for it.
Next up: rebuilding this exact agent in LangGraph, and seeing what the framework really does for me.