Skip to content
AI Full-Stack Wiki

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.

Difficulty: Advanced22 min readUpdated
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#

The tool-use loopThe user asks your app a question; your app sends the conversation and tool definitions to the model; the model replies with a tool_use request; your code runs the tool against your data and sends a tool_result back; the model then writes the final answer, which your app streams to the user.questionmessages + toolscontent blocksrun toolresultanswerUserYour appNode.js serverLLMMessages APITool codegetOrderStatus()Orders DBYour data

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?"
The tool-use (function calling) loop: the model requests a tool with a tool_use block, your Node.js code runs it and returns a tool_result, and the model uses the result to answer. Press Play or step through it.
Show 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

An agent run, turn by turnThe agent loop sends the goal to the model, which requests a customer lookup; the loop runs it and sends the result; the model requests a payment search; the loop runs it; the model writes the final answer and the loop checks budgets after each turn.turn 1turn 2turn 3Agent loopModelToolsgoal + tools +historytool_usefind_customerfind_customer(email)customer cus_1tool_result +check limitstool_uselist_paymentslist_payments(cus_1)2 payments, 1 pendingtool_resultend_turn: finalanswer

Step 1 of 10

goal + tools + history

Turn 1 starts with the system prompt, tool definitions, and the user's goal.

One agent run: the loop calls the model, executes the tools it requests, appends results, and repeats until the model finishes or a limit stops it.
Show text description

A support agent answering “Why was I charged twice?”, turn by turn. Participants, left to right: Agent loop, Model, Tools.

  1. Agent loop → Model: goal + tools + history · Turn 1 starts with the system prompt, tool definitions, and the user's goal.
  2. Model → Agent loop: tool_use find_customer · The model decides it needs the customer record first.
  3. Agent loop → Tools: find_customer(email) · Your code runs the tool with the authenticated user's permissions.
  4. Tools → Agent loop: customer cus_1
  5. 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.
  6. Model → Agent loop: tool_use list_payments · With the customer known, the model asks for recent payments.
  7. Agent loop → Tools: list_payments(cus_1)
  8. Tools → Agent loop: 2 payments, 1 pending
  9. Agent loop → Model: tool_result
  10. 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.

lib/tool.ts
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#

lib/agent.ts
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_use continues; end_turn finishes; refusal and max_tokens end 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 AbortSignal cancels 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#

research.ts
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:

agent.test.ts
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 loopSDK tool runner
Code to maintain~120 linesA few lines
Limits, budgets, loop detectionExactly as you design themmax_iterations; the rest you add
Approval before tools, custom persistenceNaturalPossible, with more effort
Best forProduction agents with specific policiesPrototypes 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_error results.
  • 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

  1. Add a costUSD field to the turn event, using the prices from the billing article, and stop the run when it passes $0.50.
  2. Add a test for it with the scripted client.
  3. Add a third tool, list_recent_notes, and run research.ts with a vaguer goal ("What should we worry about this quarter?"). Count the turns and note whether loop detection fires.

Check your understanding

Why does runAgent return a status such as max_turns instead of throwing?
What should happen when a tool receives invalid input from the model?
How can you test the loop's control flow without calling the API?

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.