There is a lot of ceremony around "AI agents" at the moment: frameworks, orchestration layers, abstractions stacked on abstractions. I pulled the agent core out of a larger project of mine, generalized it, and published it as a tiny zero-dependency npm package, and the part everyone treats as the hard bit, the loop itself, is about thirty lines. Most of what the frameworks sell you sits on top of this, not inside it.
What an agent loop actually is
Strip away the branding and an agent is a loop: ask the model, and if it asks to call tools, run them, feed the results back, and ask again, until it returns a final answer or you hit a step limit. That's it. That's the whole control flow:
async run(prompt: string): Promise<AgentResult> {
const messages: Message[] = [];
if (this.system !== undefined) messages.push({ role: 'system', content: this.system });
messages.push({ role: 'user', content: prompt });
for (let step = 1; step <= this.maxSteps; step++) {
const response = await this.provider.complete({ messages, tools: this.toolSpecs });
if (response.kind === 'message') {
messages.push({ role: 'assistant', content: response.content });
return { text: response.content, steps: step, messages };
}
messages.push({ role: 'assistant', content: null, toolCalls: response.toolCalls });
// Run a turn's tool calls concurrently; results are recorded in call order.
const results = await Promise.all(
response.toolCalls.map(async (call) => ({ call, output: await this.runTool(call) })),
);
for (const { call, output } of results) {
messages.push({ role: 'tool', toolCallId: call.id, name: call.name, content: output });
}
}
throw new MaxStepsExceededError(this.maxSteps, messages);
}
(The published version threads a few optional hooks through that for observability; I've left them out here so the shape is obvious.) The whole agent is in there: a bounded loop, an exit when the model stops asking for tools, and an append-only message history that always reflects exactly what the model has seen.
One design choice worth pointing at: errors are data
When a tool fails, you don't want the loop to crash. You want the model to find out and recover, the same way it would react to any other tool output. So an unknown tool or a thrown error comes back as text, not as an exception:
private async runTool(call: ToolCall): Promise<string> {
const tool = this.tools.get(call.name);
if (tool === undefined) {
return `Error: unknown tool "${call.name}"`;
}
try {
return await tool.execute(call.arguments);
} catch (error) {
return `Error: ${error instanceof Error ? error.message : String(error)}`;
}
}
The model called a tool that doesn't exist? It gets told, and it tries a real one. A tool threw? It gets the message and decides what to do. The loop stays alive because tool failure is a normal event in a conversation, not a crash.
The only thing you implement per vendor
The loop never mentions OpenAI or Anthropic. It talks to one interface:
export type CompletionResponse =
| { kind: 'message'; content: string }
| { kind: 'tool_calls'; toolCalls: ToolCall[] };
export interface Provider {
complete(request: CompletionRequest): Promise<CompletionResponse>;
}
Map a provider's chat-and-tools API onto that, and the loop runs on it unchanged. This is the part the "build an agent" tutorials usually get wrong: they hard-code one vendor's wire format into the loop, and then switching providers means rewriting the loop.
Two vendors, two genuinely different APIs
The reason this separation earns its keep is that OpenAI and Anthropic don't just differ in field names. They're structurally different, and the adapter is where that difference lives. Pulling tool calls out of a response:
// OpenAI: a flat tool_calls array; arguments arrive as a JSON string.
if (message?.tool_calls?.length) {
return {
kind: 'tool_calls',
toolCalls: message.tool_calls.map((call) => ({
id: call.id,
name: call.function.name,
arguments: JSON.parse(call.function.arguments), // string → object
})),
};
}
// Anthropic: tool calls are `tool_use` content blocks; arguments are already objects.
const toolUses = data.content.filter((block) => block.type === 'tool_use');
if (toolUses.length > 0) {
return {
kind: 'tool_calls',
toolCalls: toolUses.map((block) => ({ id: block.id, name: block.name, arguments: block.input })),
};
}
And the differences keep going on the request side. With OpenAI the system prompt is a message in the array; with Anthropic it's a top-level field. OpenAI sends a tool result as a tool-role message keyed by tool_call_id; Anthropic wants every tool result for a turn merged into a single user message as tool_result blocks. None of that complexity belongs in the loop, and because it isn't in the loop, the loop runs on either provider, or on Ollama, or on anything OpenAI-compatible, without changing a line.
What this is really saying
I'm not arguing you never want a framework. The day you need streaming, retries with backoff, tracing, or an eval harness, reach for one, or build those as layers. But the core control loop, the thing people assume is the complicated part, is thirty lines and one typed interface. The genuine complexity is in the per-vendor adapter, and that complexity is real and irreducible because the vendors really are different.
Frameworks bundle the loop and the adapter together and sell you the bundle. Once you've written the loop yourself, every agent framework you pick up afterward stops being magic. It's this, with more bolted on.