The Agent Event Loop
The Simple Mechanics Behind AI Reasoning
The Agent Event Loop
The Simple Mechanics Behind AI Reasoning
There is a moment every engineer hits when building their first agent. They write a while loop, call an LLM, parse the response, execute a tool, and then… the loop runs again. And again. And somewhere around the third iteration, the agent does something they didn't explicitly code. It picks the right tool — the unexpected right tool — and the terminal output reads like a thinking mind.
This is the magic. And it is mechanically simple.
flowchart TD
A["👤 User Goal"] --> B["🧠 LLM: Reason + Decide"]
B --> C{"Tool call or
final answer?"}
C -->|"Final answer"| F["✅ Return result"]
C -->|"Tool call"| D["⚙️ Execute tool"]
D --> E["📎 Append result to
conversation history"]
E --> B1. The Magic of a While Loop
Let's start with the rawest possible sketch. At its core, an AI agent is this:
while (true) {
const response = await llm.chat(messages, { tools });
if (response.isDone) break;
const result = await execute(response.toolCall);
messages.push(result);
}That is the entire architecture. A loop. A language model. A list of tools. Three primitives, infinite behavior.
The loop is not the clever part. The clever part is what happens inside the LLM — how a statistical next-token predictor, given a description of tools and a conversation history, decides which tool to call and what arguments to pass, and then, given the result, decides what to do next.
The loop just keeps the conversation going. The LLM does the reasoning. The tools provide the ground truth.
// The agent loop — no abstraction, no framework
type Message = { role: 'user' | 'assistant' | 'tool'; content: string; toolCallId?: string };
async function agentLoop(goal: string, tools: Tool[]) {
const messages: Message[] = [{ role: 'user', content: goal }];
for (let i = 0; i < 25; i++) {
const response = await llm.chat(messages, { tools });
if (response.type === 'final_answer') {
return response.content;
}
const toolResult = await execute(response.toolCall);
messages.push(response.message, toolResult);
}
return 'Max iterations reached.';
}Twenty-five iterations as a safety ceiling. Most tasks resolve in 3–8. The rest of the intelligence is in the prompt and the tool definitions.
2. How LLMs Choose Tools
This is the part that still feels like sleight of hand. Tools are just JSON schemas dropped into the system prompt:
type Tool = {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, { type: string; description: string }>;
required: string[];
};
};The model reads these descriptions alongside the conversation. When it decides it needs a temperature reading, it outputs:
{
"tool": "get_temperature",
"args": { "city": "Tokyo", "unit": "celsius" }
}Your loop parses this, calls the function, and feeds the result back as a tool role message. The model incorporates the tool output into its reasoning and decides the next step.
Why this works: The training data taught the model to match intents to actions. When you say "I have a tool called get_temperature that takes a city name and returns a number", and the user asks "Should I bring a jacket to Tokyo?", the model maps the abstract question to the concrete function call. This is no different from how a human, given a kitchen full of tools, picks the right one for each subtask.
// A real tool definition
const getTemperature: Tool = {
name: 'get_temperature',
description: 'Get the current temperature for a given city',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name (e.g., Tokyo, London)' },
unit: { type: 'string', enum: ['celsius', 'fahrenheit'] }
},
required: ['city']
}
};The model does not run this function. It declares that it should be run. Your code executes it. This separation is the entire architecture.
3. Incremental Reasoning — The ReAct Pattern
A single LLM call can answer "What is the capital of France?" because the answer is in its weights. But an agent needs to handle: "What's the weather in the capital of the country with the largest GDP in Southeast Asia?"
This requires Reasoning + Acting — the ReAct paradigm introduced by Yao et al. (2023)[¹]. The model reasons out loud, then acts, then reasons about the result, then acts again:
Thought: I need to find the country with the largest GDP in Southeast Asia.
I should search for this information first.
Action: search(query="largest GDP country Southeast Asia")
Observation: Indonesia — GDP $1.37 trillion
Thought: Now I need the capital of Indonesia.
Action: get_capital(country="Indonesia")
Observation: Jakarta
Thought: Now I can get the weather in Jakarta.
Action: get_temperature(city="Jakarta", unit="celsius")
Observation: 31°C
Thought: I have all the information. The answer is Jakarta, Indonesia — 31°C.
Final: The capital is Jakarta, Indonesia, where it's currently 31°C.Each step is a complete loop iteration. The model does not need to hold the entire reasoning chain in a single context window burst — it externalizes intermediate state into the conversation history. The loop is the working memory.
// The inner loop — Thought → Action → Observation
async function* reasoningLoop(goal: string, tools: Tool[]) {
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: goal }
];
for (let step = 0; step < 25; step++) {
const response = await llm.chat(messages, { tools });
yield { type: 'thought', content: response.content, step };
if (response.type === 'final_answer') {
yield { type: 'result', content: response.content };
return;
}
const observation = await executeTool(response.toolCall, tools);
messages.push(response.message, {
role: 'tool',
toolCallId: response.toolCall.id,
content: JSON.stringify(observation)
});
yield { type: 'observation', content: observation, step };
}
}The yield here is not incidental — streaming the reasoning process lets you render it in real time. The user sees the agent think.
4. TypeScript Demo — The Agent Loop in ~80 Lines
Here is a complete, working agent runtime. No framework. No dependencies beyond an LLM client.
// types.ts
interface Tool {
name: string;
description: string;
parameters: Record<string, unknown>;
execute: (args: Record<string, unknown>) => Promise<string>;
}
interface LLMResponse {
type: 'tool_call' | 'final_answer';
content: string;
toolCall?: { name: string; args: Record<string, unknown>; id: string };
}
// agent.ts
import { llm } from './llm-client';
import { tools } from './tools';
const SYSTEM_PROMPT = `You are a reasoning agent.
You have access to tools. Use them one at a time.
Think step by step. When you have the answer, respond with "Final:" followed by your conclusion.`;
async function runAgent(userInput: string): Promise<string> {
const messages: Array<{ role: string; content: string }> = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userInput }
];
for (let round = 0; round < 25; round++) {
const response: LLMResponse = await llm.chat(messages, {
tools: tools.map(t => ({
name: t.name,
description: t.description,
parameters: t.parameters
}))
});
if (response.type === 'final_answer') {
return response.content;
}
// Execute the tool
const tool = tools.find(t => t.name === response.toolCall!.name);
if (!tool) throw new Error(`Unknown tool: ${response.toolCall!.name}`);
const result = await tool.execute(response.toolCall!.args);
// Feed result back
messages.push(
{ role: 'assistant', content: response.content },
{ role: 'tool', content: result }
);
}
throw new Error('Agent reached maximum iterations');
}
// tools.ts
export const tools: Tool[] = [
{
name: 'search_web',
description: 'Search the internet for current information',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' }
},
required: ['query']
},
execute: async ({ query }) => {
// In production: call a search API
return `Mock results for: ${query}`;
}
},
{
name: 'calculate',
description: 'Perform mathematical calculations',
parameters: {
type: 'object',
properties: {
expression: { type: 'string', description: 'Math expression' }
},
required: ['expression']
},
execute: async ({ expression }) => {
return String(eval(expression as string));
}
}
];
// Usage
const answer = await runAgent(
'What was the GDP of Japan in 2023, and what is 15% of that?'
);
console.log(answer);That is the entire runtime. The intelligence — the tool selection, the step-by-step reasoning, the ability to chain operations — is not in the code. It is in the prompt and the model's training.
The code is a glorified for loop with a router. The magic is emergent.
5. Why This Fascinates
There is a direct analogy to Conway's Game of Life. Three rules on a grid produce gliders, oscillators, and infinite complexity. Here, three primitives — a loop, a language model, and a tool list — produce what looks like deliberate reasoning.
The loop forces the model to externalize its thinking. Each iteration is a commit to the conversation history — a visible step in a chain of reasoning. The model cannot hallucinate its way past a missing fact because the tool call returns ground truth. The model cannot jump to a conclusion because the loop keeps asking "what next?"
This is the inversion of traditional programming:
| Traditional | Agentic |
|---|---|
| You write every branch | The LLM chooses the branch |
| You handle every error | Tools return results, the LLM adapts |
| The logic is in your code | The logic is in the prompt |
| The program is deterministic | The program is emergent |
The engineer's job shifts from writing logic to curating tools and designing prompts. The loop is constant. The variety is infinite.
And that is the fascination. A while loop — the first control flow every programmer learns — becomes the universal reasoning primitive. The simplicity is not an implementation detail. It is the point.
Kyberneees, 2026
¹ Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K., & Cao, Y. (2023). ReAct: Synergizing Reasoning and Acting in Language Models. ICLR. arXiv:2210.03629.