Build an AI Agent Loop from Scratch in TypeScript
Write a production-ready agent loop in TypeScript: Zod-typed tools, turn, token, and time limits, parallel tool calls, error handling, events for your UI, and unit tests.
On this page· The loop
Why this matters
Every agent, from a coding assistant to a research bot, is the same loop: call the model, run the tools it asks for, send back the results, repeat. Writing that loop yourself, with the limits and error handling production needs, is the best way to understand agents, and often all you need. Frameworks and SDK runners are convenient, but when an agent misbehaves at 2 a.m., you need to know exactly what the loop does.
The loop#
Step 1 of 7
The user asks a question
The answer depends on live data the model has never seen, so a plain model call would guess. Your app receives the message like any other request.
User message
"Where is my order #4821?"Show text descriptionHide text description
The diagram shows five parts: the user, your app (a Node.js server), the LLM behind the Messages API, your tool code, and your database. The model never executes tools itself; your app runs the loop.
- The user asks a question. The answer depends on live data the model has never seen, so a plain model call would guess. Your app receives the message like any other request.
- Your app calls the model with tool definitions. Alongside the conversation, you describe each tool: a name, a description the model reads to decide when to use it, and a JSON Schema for its input.
- The model asks you to run a tool. The model cannot execute code. It stops with stop_reason "tool_use" and returns a tool_use block naming the tool and the arguments it chose.
- Your code executes the tool. Validate the input, check that this user may see this order, then run the function. Your data never leaves your control unless you return it.
- Send the tool result back. The API is stateless: append the assistant turn exactly as received, then a user turn containing a tool_result that references the tool_use id, and call the model again.
- The model writes the final answer. With the result in context, the model answers and stops with stop_reason "end_turn". If it needs more data, it returns tool_use again and the loop repeats from step 3.
- Stream the answer to the user. Show the final text in the UI. In production, cap the loop (for example at 10 iterations), log every tool call, and treat tool results as untrusted input.
The agent loop is the tool-use loop from the tool use article, plus everything that makes it safe to leave running on its own:
- Limits on turns, tokens, and wall-clock time, so a confused model cannot run up a bill.
- Error handling that turns tool failures into results the model can react to.
- Context hygiene: oversized tool results are truncated before they flood the context window.
- Loop detection: the same call repeated with the same input is a sign the agent is stuck.
- Events for logging and for showing progress in a UI.
Here is one run of a support agent, turn by turn. Step through it to see what the loop does between model calls:
Step 1 of 10
goal + tools + history
Turn 1 starts with the system prompt, tool definitions, and the user's goal.
Show text descriptionHide text description
A support agent answering “Why was I charged twice?”, turn by turn. Participants, left to right: Agent loop, Model, Tools.
- Agent loop → Model: goal + tools + history · Turn 1 starts with the system prompt, tool definitions, and the user's goal.
- Model → Agent loop: tool_use find_customer · The model decides it needs the customer record first.
- Agent loop → Tools: find_customer(email) · Your code runs the tool with the authenticated user's permissions.
- Tools → Agent loop: customer cus_1
- Agent loop → Model: tool_result + check limits · The result is appended to history. Before calling the model again, the loop checks turn, token, and time budgets.
- Model → Agent loop: tool_use list_payments · With the customer known, the model asks for recent payments.
- Agent loop → Tools: list_payments(cus_1)
- Tools → Agent loop: 2 payments, 1 pending
- Agent loop → Model: tool_result
- Model → Agent loop: end_turn: final answer · The model has enough information and answers. stop_reason end_turn ends the loop.
Tools with Zod#
A small helper defines a tool once: the Zod schema validates input at run time, types the run function, and generates the JSON schema the API needs.
import type Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
export interface ToolContext {
userId: string; // who the agent acts for: tools must scope data access to this user
signal: AbortSignal; // aborted when the run times out
}
export interface AgentTool {
definition: Anthropic.Tool;
run(input: unknown, ctx: ToolContext): Promise<string>;
}
export function defineTool<S extends z.ZodObject>(spec: {
name: string;
description: string;
input: S;
run: (input: z.infer<S>, ctx: ToolContext) => Promise<string>;
}): AgentTool {
const { $schema: _ignored, ...schema } = z.toJSONSchema(spec.input);
return {
definition: { name: spec.name, description: spec.description, input_schema: schema as Anthropic.Tool.InputSchema },
async run(input, ctx) {
const parsed = spec.input.safeParse(input);
if (!parsed.success) {
// A clear message lets the model fix its own call.
throw new Error(`Invalid input: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
}
return spec.run(parsed.data, ctx);
},
};
}The agent loop#
import Anthropic from "@anthropic-ai/sdk";
import type { AgentTool, ToolContext } from "./tool.ts";
export type AgentEvent =
| { type: "turn"; turn: number; inputTokens: number; outputTokens: number }
| { type: "text"; text: string }
| { type: "tool_call"; id: string; name: string; input: unknown }
| { type: "tool_result"; id: string; name: string; ok: boolean; content: string };
export type StopReason = "done" | "max_turns" | "token_budget" | "timeout" | "refused" | "max_tokens";
export interface AgentResult {
status: StopReason;
answer: string;
turns: number;
usage: { inputTokens: number; outputTokens: number };
messages: Anthropic.MessageParam[];
}
export interface AgentOptions {
system: string;
tools: AgentTool[];
userId: string;
model?: Anthropic.Model;
maxTurns?: number;
tokenBudget?: number; // input + output tokens across the whole run
timeoutMs?: number;
maxResultChars?: number;
onEvent?: (event: AgentEvent) => void;
client?: Anthropic; // injectable for tests
}
export async function runAgent(goal: string, o: AgentOptions): Promise<AgentResult> {
const client = o.client ?? new Anthropic();
const { maxTurns = 12, tokenBudget = 300_000, timeoutMs = 120_000, maxResultChars = 8_000 } = o;
const emit = o.onEvent ?? (() => {});
const signal = AbortSignal.timeout(timeoutMs);
const ctx: ToolContext = { userId: o.userId, signal };
const byName = new Map(o.tools.map((t) => [t.definition.name, t]));
const seenCalls = new Map<string, number>();
const messages: Anthropic.MessageParam[] = [{ role: "user", content: goal }];
const usage = { inputTokens: 0, outputTokens: 0 };
let answer = "";
const finish = (status: StopReason, turns: number): AgentResult => ({ status, answer, turns, usage, messages });
for (let turn = 1; turn <= maxTurns; turn++) {
let message: Anthropic.Message;
try {
message = await client.messages.create(
{
model: o.model ?? "claude-opus-5-5",
max_tokens: 16_000,
system: o.system,
tools: o.tools.map((t) => t.definition),
cache_control: { type: "ephemeral" }, // the growing history is reused every turn
messages,
},
{ signal },
);
} catch (error) {
if (signal.aborted) return finish("timeout", turn);
throw error; // the SDK already retried transient errors; this one is real
}
usage.inputTokens += message.usage.input_tokens + (message.usage.cache_read_input_tokens ?? 0) + (message.usage.cache_creation_input_tokens ?? 0);
usage.outputTokens += message.usage.output_tokens;
emit({ type: "turn", turn, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens });
messages.push({ role: "assistant", content: message.content });
answer = message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
if (answer) emit({ type: "text", text: answer });
if (message.stop_reason === "refusal") return finish("refused", turn);
if (message.stop_reason === "max_tokens") return finish("max_tokens", turn);
if (message.stop_reason !== "tool_use") return finish("done", turn);
if (usage.inputTokens + usage.outputTokens > tokenBudget) return finish("token_budget", turn);
const calls = message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
const results = await Promise.all(
calls.map(async (call): Promise<Anthropic.ToolResultBlockParam> => {
emit({ type: "tool_call", id: call.id, name: call.name, input: call.input });
// Loop detection: the same call with the same input, again and again.
const key = `${call.name}:${JSON.stringify(call.input)}`;
const repeats = (seenCalls.get(key) ?? 0) + 1;
seenCalls.set(key, repeats);
let content: string;
let ok = true;
try {
const tool = byName.get(call.name);
if (!tool) throw new Error(`Unknown tool "${call.name}". Available: ${[...byName.keys()].join(", ")}`);
content = await tool.run(call.input, ctx);
if (content.length > maxResultChars) {
content = `${content.slice(0, maxResultChars)}\n[truncated ${content.length - maxResultChars} characters; narrow your request]`;
}
if (repeats >= 3) content += "\n[note: you have made this exact call several times; try a different approach]";
} catch (error) {
ok = false;
content = error instanceof Error ? error.message : String(error);
}
emit({ type: "tool_result", id: call.id, name: call.name, ok, content });
return { type: "tool_result", tool_use_id: call.id, content, ...(ok ? {} : { is_error: true }) };
}),
);
messages.push({ role: "user", content: results });
}
return finish("max_turns", maxTurns);
}Walk through the decisions:
- Stop reasons drive the loop.
tool_usecontinues;end_turnfinishes;refusalandmax_tokensend the run with a status your caller can handle differently. - Every limit returns a status, not an exception. The caller decides what "ran out of turns" means: show a partial answer, escalate to a human, or retry with a bigger budget.
- The timeout covers everything. One
AbortSignalcancels the in-flight model request and is passed to tools, so slow tools can stop too. - The budget check happens before running tools, so an expensive run stops before doing more work.
- The full message history is returned, so you can log it, show it, or resume from it (see durable execution).
Running it#
import { z } from "zod";
import { runAgent } from "./lib/agent.ts";
import { defineTool } from "./lib/tool.ts";
const NOTES = [
{ id: "n1", title: "Q3 churn analysis", body: "Churn rose from 3.1% to 4.4% in Q3, mostly in the Starter plan after the price change." },
{ id: "n2", title: "Pricing change", body: "Starter went from $19 to $29 on July 1. Pro stayed at $79." },
{ id: "n3", title: "Support themes Q3", body: "Top themes: invoice confusion (31%), export bugs (22%), price complaints (18%)." },
];
const searchNotes = defineTool({
name: "search_notes",
description: "Search internal notes by keyword. Returns matching note IDs and titles. Use short queries such as 'churn' or 'pricing'.",
input: z.object({ query: z.string().min(2) }),
run: async ({ query }) => {
const q = query.toLowerCase();
const hits = NOTES.filter((n) => `${n.title} ${n.body}`.toLowerCase().includes(q)).map(({ id, title }) => ({ id, title }));
return JSON.stringify(hits);
},
});
const readNote = defineTool({
name: "read_note",
description: "Read the full text of a note by ID.",
input: z.object({ id: z.string() }),
run: async ({ id }) => {
const note = NOTES.find((n) => n.id === id);
if (!note) throw new Error(`No note with id ${id}`);
return `${note.title}\n\n${note.body}`;
},
});
const result = await runAgent("Why did churn increase in Q3? Cite the notes you used.", {
system: "You are a research assistant for a SaaS company. Use the tools to find evidence before answering, and cite note IDs.",
tools: [searchNotes, readNote],
userId: "user_42",
maxTurns: 8,
onEvent: (e) => {
if (e.type === "tool_call") console.log(`→ ${e.name}(${JSON.stringify(e.input)})`);
if (e.type === "tool_result") console.log(` ${e.ok ? "✓" : "✗"} ${e.content.slice(0, 80)}`);
if (e.type === "turn") console.log(`[turn ${e.turn}] ${e.inputTokens + e.outputTokens} tokens so far`);
},
});
console.log(`\n${result.status} after ${result.turns} turns\n\n${result.answer}`);A typical run makes two or three turns: search, read the relevant notes (often in parallel), then answer with citations to n1 and n2.
Testing the loop without the API#
Because the client is injectable, the loop's control flow can be tested with a scripted fake that returns predefined responses. These tests run in milliseconds and cost nothing:
import { test } from "node:test";
import assert from "node:assert/strict";
import type Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { runAgent } from "./lib/agent.ts";
import { defineTool } from "./lib/tool.ts";
// A fake client that replays scripted responses and records requests.
function scripted(responses: Partial<Anthropic.Message>[]) {
const requests: Anthropic.MessageCreateParams[] = [];
const client = {
messages: {
create: async (params: Anthropic.MessageCreateParams) => {
requests.push(structuredClone(params));
const next = responses.shift();
if (!next) throw new Error("no more scripted responses");
return { usage: { input_tokens: 100, output_tokens: 20 }, ...next };
},
},
} as unknown as Anthropic;
return { client, requests };
}
const toolUse = (id: string, name: string, input: unknown): Partial<Anthropic.Message> => ({
stop_reason: "tool_use",
content: [{ type: "tool_use", id, name, input, caller: { type: "direct" } } as Anthropic.ToolUseBlock],
});
const final = (text: string): Partial<Anthropic.Message> => ({
stop_reason: "end_turn",
content: [{ type: "text", text, citations: null } as Anthropic.TextBlock],
});
const echo = defineTool({ name: "echo", description: "Echo", input: z.object({ text: z.string() }), run: async ({ text }) => text });
const base = { system: "test", tools: [echo], userId: "u1" };
test("runs a tool and finishes", async () => {
const { client, requests } = scripted([toolUse("t1", "echo", { text: "hi" }), final("done: hi")]);
const result = await runAgent("go", { ...base, client });
assert.equal(result.status, "done");
assert.equal(result.answer, "done: hi");
const lastUser = requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[];
assert.deepEqual(lastUser[0], { type: "tool_result", tool_use_id: "t1", content: "hi" });
});
test("returns tool errors to the model as is_error results", async () => {
const { client, requests } = scripted([toolUse("t1", "echo", { text: 42 }), final("sorry")]);
await runAgent("go", { ...base, client });
const result = (requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[])[0]!;
assert.equal(result.is_error, true);
assert.match(String(result.content), /Invalid input: text/);
});
test("stops at the turn limit", async () => {
const { client } = scripted(Array.from({ length: 5 }, (_, i) => toolUse(`t${i}`, "echo", { text: "again" })));
const result = await runAgent("go", { ...base, client, maxTurns: 3 });
assert.equal(result.status, "max_turns");
});
test("stops when the token budget is exceeded", async () => {
const { client } = scripted([toolUse("t1", "echo", { text: "x" }), toolUse("t2", "echo", { text: "y" })]);
const result = await runAgent("go", { ...base, client, tokenBudget: 150 });
assert.equal(result.status, "token_budget");
assert.equal(result.turns, 2);
});
test("flags repeated identical calls", async () => {
const same = () => toolUse(crypto.randomUUID(), "echo", { text: "same" });
const { client, requests } = scripted([same(), same(), same(), final("ok")]);
await runAgent("go", { ...base, client });
const third = (requests[3]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[])[0]!;
assert.match(String(third.content), /several times/);
});Run them with node --test agent.test.ts. Tests like these pin down the loop's behavior; evals measure whether the model makes good choices inside it.
Hand-written loop or SDK runner?#
| Hand-written loop | SDK tool runner | |
|---|---|---|
| Code to maintain | ~120 lines | A few lines |
| Limits, budgets, loop detection | Exactly as you design them | max_iterations; the rest you add |
| Approval before tools, custom persistence | Natural | Possible, with more effort |
| Best for | Production agents with specific policies | Prototypes and simple agents |
Both are good choices. Many teams prototype with the runner and move to their own loop when policies such as approvals, budgets, and durable state appear.
Common mistakes#
- Throwing on tool errors. One failed lookup should not kill the run. Return
is_errorresults. - Limits only on turns. A single turn can be expensive. Budget tokens and time too.
- Feeding raw tool output back. A 200 KB API response costs money on every following turn and buries the signal. Truncate or summarize.
- Tools that trust the model for identity. Pass the user from your session through the context, never as a tool argument.
- No visibility. Without events or traces, a bad run is a mystery. Log every call and result.
Try it
Extend the loop
- Add a
costUSDfield to theturnevent, using the prices from the billing article, and stop the run when it passes $0.50. - Add a test for it with the scripted client.
- Add a third tool,
list_recent_notes, and runresearch.tswith a vaguer goal ("What should we worry about this quarter?"). Count the turns and note whether loop detection fires.
Check your understanding
Key takeaways
- An agent is a tool-use loop plus limits, error handling, context hygiene, loop detection, and events.
- Define tools once with Zod for validation, types, and the JSON schema.
- Cap turns, tokens, and time; return a status for each limit; pass one abort signal to the model call and the tools.
- Make the client injectable and test the loop with scripted responses.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Tool Use (Function Calling) with Claude in TypeScriptLet the model call your functions: define tools, run the tool-use loop by hand, handle errors and parallel calls, then use the SDK tool runner with Zod-typed tools.
- Agent Memory, State, and Durable Execution with PostgresGive agents memory that lasts and runs that survive crashes: memory tiers, checkpointed steps in Postgres, leases, and idempotent side effects.
- Human-in-the-Loop Agents: Approvals, Pauses, and Resumable RunsPause an agent before risky actions, persist the run, ask a person to approve, edit, or reject, and resume with the right tool results, in TypeScript and Next.js.