Agents

What is an agent loop? Build one in TypeScript

Every coding agent, research agent and "autonomous" thing you have seen this year is the same idea in a nicer coat: a while loop. This summer the idea got a name, loop engineering, and a trade-press article describing it as a new phrase circulating among AI developers. Before you engineer loops, it helps to write one. It is shorter than you think, and the interesting part is not the loop. It is the exits.

If tool calls are new to you, read tool use explained first. If you are not sure you need an agent at all, agents vs workflows will probably save you money.

What is an agent loop?#

Call the modelconversation + toollistModel asks fortoolsstop_reason:tool_useRun them, appendresultsyour code, yourrulesuntil it answersor a limit fires
The model decides what to do next. Your code decides whether it is allowed to.

Three things to notice:

  1. The model never runs anything. It returns a request ("call search_docs with this input"). Your code runs it.
  2. The conversation is the memory. Each turn appends the model's reply and the tool results, so the next call sees everything so far.
  3. The model decides when it is done. That is the scary part, and the reason the rest of this post exists.

The happy path is 20 lines#

Here is the shape, with the Anthropic TypeScript SDK. The model call is passed in as a function, so the loop does not care which model or client you use, and you can test it without spending tokens.

ts
import Anthropic from "@anthropic-ai/sdk";

type Msg = Anthropic.MessageParam;
type Tool = (input: unknown) => Promise<string>;
type Model = (messages: Msg[]) => Promise<Anthropic.Message>;

const messages: Msg[] = [{ role: "user", content: task }];
while (true) {
  const res = await model(messages);
  messages.push({ role: "assistant", content: res.content });
  if (res.stop_reason !== "tool_use") break;

  const calls = res.content.filter(isToolUse);
  const results = await Promise.all(
    calls.map((c) => runTool(tools, c)),
  );
  messages.push({ role: "user", content: results });
}

It works. It is also a loop whose only exit is controlled by a model, and a model can get stuck, confused or tricked. Ask anyone who left an agent running over lunch and came back to a surprising bill.

Where do agent loops go wrong?#

Four failure modes cover most of what you will see:

FailureWhat it looks likeThe exit
Never finishesKeeps "checking one more thing"A hard turn limit
Costs too muchEach turn resends a growing contextA token budget for the whole run
Gets stuckSame tool, same input, again and againA repeat detector
Stops weirdlyOutput cut off, or the model declinedHandle every stop reason

The first three are limits the model cannot argue with. The fourth is about reading the API properly: stop_reason is not just tool_use or end_turn. A response can also stop at max_tokens (a tool input may be cut off mid-way) or refusal (the model declined). Running tools from a truncated or refused turn is how you get very creative bugs.

The full loop, with the exits#

ts
export interface Limits {
  maxTurns: number; // hard cap on model calls
  maxTokens: number; // input + output, summed over the run
  maxRepeats: number; // same tool + same input = stuck
}

export async function runAgent(
  model: Model,
  tools: Record<string, Tool>,
  task: string,
  limits: Limits,
): Promise<string> {
  const messages: Msg[] = [{ role: "user", content: task }];
  const seen = new Map<string, number>();
  let tokens = 0;

  for (let turn = 1; turn <= limits.maxTurns; turn++) {
    const res = await model(messages);
    tokens += res.usage.input_tokens + res.usage.output_tokens;
    messages.push({ role: "assistant", content: res.content });

    if (res.stop_reason === "end_turn") return textOf(res);
    if (res.stop_reason !== "tool_use") {
      throw new Error(`stopped early: ${res.stop_reason}`);
    }
    if (tokens > limits.maxTokens) {
      throw new Error(`token budget spent at turn ${turn}`);
    }

The turn limit is the for loop itself. The budget counts input tokens too, because in a loop the input grows every turn: turn ten resends everything from turns one to nine.

Then the repeat detector and the tool calls:

ts
    const calls = res.content.filter(isToolUse);
    for (const c of calls) {
      const key = c.name + JSON.stringify(c.input);
      const n = (seen.get(key) ?? 0) + 1;
      seen.set(key, n);
      if (n > limits.maxRepeats) {
        throw new Error(`stuck: ${c.name} repeated ${n} times`);
      }
    }
    const results = await Promise.all(
      calls.map((c) => runTool(tools, c)),
    );
    messages.push({ role: "user", content: results });
  }
  throw new Error(`no answer after ${limits.maxTurns} turns`);
}

Two details matter here:

  • All tool results go back in one user message. The model can ask for several tools in one turn. Run them, then return every result together.
  • Tool failures are results, not crashes. If a tool throws, tell the model so it can try something else:
ts
async function runTool(
  tools: Record<string, Tool>,
  call: Anthropic.ToolUseBlock,
): Promise<Anthropic.ToolResultBlockParam> {
  const base = { type: "tool_result", tool_use_id: call.id } as const;
  try {
    const tool = tools[call.name];
    if (!tool) throw new Error(`unknown tool: ${call.name}`);
    return { ...base, content: await tool(call.input) };
  } catch (err) {
    return { ...base, content: String(err), is_error: true };
  }
}

isToolUse and textOf are two one-line filters over res.content (for tool_use and text blocks). They are in the complete file below.

Run it#

Wire in a real model and a tool. searchHandbook is a plain async function that returns matching lines from a tiny in-memory handbook:

ts
const client = new Anthropic(); // reads ANTHROPIC_API_KEY

const answer = await runAgent(
  (messages) =>
    client.messages.create({
      model: "claude-opus-5-5",
      max_tokens: 16000,
      tools: TOOLS, // search_docs: name, description, schema
      messages,
    }),
  { search_docs: searchHandbook },
  "When does the on-call rotation change?",
  { maxTurns: 8, maxTokens: 50_000, maxRepeats: 2 },
);

How I tested this: the loop was type-checked against @anthropic-ai/sdk 0.135.0 and run under Node's test runner against a scripted model that replays fixed responses. The tests cover a normal tool round trip, an unknown tool, a tool that throws, a model that repeats itself, the turn limit, the token budget, and refusal and max_tokens stops (tools must not run for either). I did not run it against the live API for this post. A scripted model is also how I would test your agent: you want to know what your loop does when the model misbehaves, and the real model rarely misbehaves on cue.

Loop engineering, Ralph loops and graphs#

Once you have one loop, the 2026 conversation makes more sense:

  • Loop engineering is designing the cycle around a coding agent: what it is asked to do, what checks run inside the loop (tests, linters, evals) and what limits sit around it. The loop above is the smallest version of that.
  • A Ralph loop is the bluntest version: Geoffrey Huntley's technique is literally a shell while loop that feeds the same prompt file to a terminal coding agent again and again. Each run picks up from the files the last one left behind. It works better than it has any right to, as long as the repo has good tests to tell the agent when it is wrong.
  • Loops vs graphs is this year's follow-up debate: instead of one agent looping, several specialized steps connected by data flow. In my opinion a graph is mostly a workflow with loops inside it, and that is a good thing: the more of the path your code controls, the easier it is to test.

The common thread is a quote worth putting above your desk, from Addy Osmani in that same article: "a loop running unattended is also a loop making mistakes unattended." The exits are not optional extras. They are the product.

Make it safer before you make it longer#

A longer-running loop is only as safe as its tools. If any tool reads untrusted content (web pages, issues, emails) and another can send data out, read how to protect AI agents from prompt injection next. The guards there plug straight into this runAgent.

The complete file#

ts
import Anthropic from "@anthropic-ai/sdk";

type Msg = Anthropic.MessageParam;
type Tool = (input: unknown) => Promise<string>;
type Model = (messages: Msg[]) => Promise<Anthropic.Message>;
export interface Limits {
  maxTurns: number; // hard cap on model calls
  maxTokens: number; // input + output, summed over the run
  maxRepeats: number; // same tool + same input = stuck
}

export async function runAgent(
  model: Model,
  tools: Record<string, Tool>,
  task: string,
  limits: Limits,
): Promise<string> {
  const messages: Msg[] = [{ role: "user", content: task }];
  const seen = new Map<string, number>();
  let tokens = 0;

  for (let turn = 1; turn <= limits.maxTurns; turn++) {
    const res = await model(messages);
    tokens += res.usage.input_tokens + res.usage.output_tokens;
    messages.push({ role: "assistant", content: res.content });

    if (res.stop_reason === "end_turn") return textOf(res);
    if (res.stop_reason !== "tool_use") {
      throw new Error(`stopped early: ${res.stop_reason}`);
    }
    if (tokens > limits.maxTokens) {
      throw new Error(`token budget spent at turn ${turn}`);
    }

    const calls = res.content.filter(isToolUse);
    for (const c of calls) {
      const key = c.name + JSON.stringify(c.input);
      const n = (seen.get(key) ?? 0) + 1;
      seen.set(key, n);
      if (n > limits.maxRepeats) {
        throw new Error(`stuck: ${c.name} repeated ${n} times`);
      }
    }
    const results = await Promise.all(
      calls.map((c) => runTool(tools, c)),
    );
    messages.push({ role: "user", content: results });
  }
  throw new Error(`no answer after ${limits.maxTurns} turns`);
}

async function runTool(
  tools: Record<string, Tool>,
  call: Anthropic.ToolUseBlock,
): Promise<Anthropic.ToolResultBlockParam> {
  const base = { type: "tool_result", tool_use_id: call.id } as const;
  try {
    const tool = tools[call.name];
    if (!tool) throw new Error(`unknown tool: ${call.name}`);
    return { ...base, content: await tool(call.input) };
  } catch (err) {
    return { ...base, content: String(err), is_error: true };
  }
}

const isToolUse = (
  b: Anthropic.ContentBlock,
): b is Anthropic.ToolUseBlock => b.type === "tool_use";

const textOf = (res: Anthropic.Message): string =>
  res.content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text)
    .join("");

Sources: Messages API tool use, loop engineering (ADTmag, July 2026), Ralph (Geoffrey Huntley).

Frequently asked questions

What is an agent loop?

An agent loop is code that repeatedly sends the conversation to a language model, runs the tools the model asks for, appends the results and calls the model again, until the model gives a final answer or a stop condition ends the run.

How do I stop an AI agent from looping forever?

Give the loop hard exits that do not depend on the model: a maximum number of turns, a token budget summed across the run, and a detector that stops when the same tool is called with the same input again and again. Also treat every non-tool stop reason, such as max_tokens or refusal, as the end of the turn.

What is loop engineering?

Loop engineering is a 2026 term for designing repeatable cycles in which coding agents build, test and revise with less direct prompting. It covers the loop, the checks inside it and the limits around it.

What is a Ralph loop?

A Ralph loop is a technique popularized by Geoffrey Huntley: a shell loop that feeds the same prompt file to a terminal coding agent over and over, so each run picks up where the last one stopped.

Should I write my own agent loop or use an SDK?

Write one once to understand it. In production, an SDK helper such as a tool runner, or an agent framework, saves you from re-implementing retries, streaming and context management. You still choose the limits.