The Agent Loop Explained: How 5 SDKs Run Tools Until Done

TL;DR
An agent loop calls the model, runs tools, feeds results back and repeats until a stop rule fires. Defaults vary: 20 steps, 25 super-steps, 5, 10 or no cap.
Last updated: October 17, 2026
An agent loop is a while loop: call the model with your tool schemas, run any tool calls it returns, append the results to the conversation, and repeat until the model answers without calling a tool or a stop rule fires. The Vercel AI SDK, LangGraph (via LangChain's createAgent), Mastra, the OpenAI Agents SDK and the Claude Agent SDK all implement that same loop. What differs is what each one counts, the default cap, and what happens when you hit it - so set the cap explicitly in every SDK you use.
This post covers the mechanics: the five moving parts, a hand-rolled loop in TypeScript, and a side-by-side of how each SDK counts and stops. For the design side - convergence criteria, verification, retry ladders - read the companion piece, Loop Engineering: How to Design Agent Loops That Actually Converge.
Every code example below was type-checked (strict mode, tsc --noEmit) against ai@7.0.143, @ai-sdk/anthropic@4.0.82, @ai-sdk/workflow@2.0.75, langchain@1.5.18, @langchain/langgraph@1.5.1, @mastra/core@1.78.0, @openai/agents@0.20.0, @anthropic-ai/claude-agent-sdk@0.3.302 and @anthropic-ai/sdk@0.135.0. No API keys were used and no live calls were made.
The five moving parts of every agent loop#
Strip away the framework and every agent loop has the same five pieces:
- Model call. You send the message history plus a list of tool schemas (name, description, JSON Schema for the input).
- Tool calls. The model replies with zero or more requests to run a tool. In the Claude Messages API these are
tool_usecontent blocks, each with anid. In the OpenAI Responses API they arefunction_calloutput items, each with acall_id. - Tool results. Your code runs the tools and sends the outputs back, tagged with the matching id:
tool_resultblocks withtool_use_idfor Claude,function_call_outputitems withcall_idfor OpenAI. A failed execution is still a result - Claude's API has anis_errorflag for exactly this. - Natural stop. The model answers without requesting tools. In the Claude API a clean finish is
stop_reason: "end_turn"(orstop_sequence, if you set custom stop sequences). The other values in the SDK'sStopReasontype are not a finished answer:max_tokensandmodel_context_window_exceededmean the response was cut off,refusalmeans the model declined, andpause_turnis a special case covered below. - Imposed stop. Everything you add on top: step or turn caps, cost budgets, timeouts, "stop when this tool is called", and pauses for human approval.
Anthropic's docs put the client loop this way: "while stop_reason == "tool_use", execute the tools and continue the conversation" - continuing means sending the results back in a new user message. OpenAI's function calling guide describes the same flow in five steps: request with tools, receive a tool call, execute it in your app, send a second request with the output, and receive either a final answer or more tool calls.
%% title: One agent loop, step by step
sequenceDiagram
participant App
participant Model
participant Tools
App->>Model: messages + tool schemas
Model-->>App: stop_reason tool_use, 2 calls
par run calls
App->>Tools: call A (id_1)
App->>Tools: call B (id_2)
end
Tools-->>App: outputs
App->>Model: tool_result id_1, id_2
Model-->>App: stop_reason end_turn + text
App-->>App: return answerThat is the whole mechanism. Every SDK in this post is that diagram plus opinions about steps 4 and 5.
A minimal hand-rolled loop in TypeScript#
Here is the loop written directly against the Anthropic TypeScript SDK. It is short on purpose, but it handles the cases that bite in production: parallel tool calls, unknown tools, tools that throw, truncated responses, and a hard step cap.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const tools: Anthropic.Tool[] = [
{
name: "get_order_status",
description: "Look up the shipping status of an order by id.",
input_schema: {
type: "object",
properties: { orderId: { type: "string" } },
required: ["orderId"],
},
},
];
type ToolImpl = (input: Record<string, unknown>) => Promise<string>;
const impls: Record<string, ToolImpl> = {
get_order_status: async (input) =>
JSON.stringify({ orderId: input.orderId, status: "shipped" }),
};
export async function runAgent(prompt: string, maxSteps = 10): Promise<string> {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: prompt }];
for (let step = 0; step < maxSteps; step++) {
// 1. Model call: full history plus tool schemas.
const response = await client.messages.create({
model: "claude-sonnet-5-5",
max_tokens: 2048,
tools,
messages,
});
messages.push({ role: "assistant", content: response.content });
const stop = response.stop_reason;
// Server tools paused mid-turn: resend as-is so the model continues.
if (stop === "pause_turn") continue;
// 2. Natural stop: the model finished without requesting tools.
if (stop === "end_turn" || stop === "stop_sequence") {
return response.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
}
// Truncated or refused (max_tokens, refusal, model_context_window_exceeded):
// not a finished answer, so fail loudly.
if (stop !== "tool_use") {
throw new Error(`Agent stopped without finishing: stop_reason=${stop}`);
}
// 3. Run every tool_use block (in parallel) and collect results.
const toolUses = response.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
);
const results: Anthropic.ToolResultBlockParam[] = await Promise.all(
toolUses.map(async (call) => {
const impl = impls[call.name];
if (!impl) {
return { type: "tool_result", tool_use_id: call.id, content: `Unknown tool: ${call.name}`, is_error: true };
}
try {
const output = await impl(call.input as Record<string, unknown>);
return { type: "tool_result", tool_use_id: call.id, content: output };
} catch (err) {
return { type: "tool_result", tool_use_id: call.id, content: String(err), is_error: true };
}
}),
);
// 4. Feed all results back in ONE user message, then loop.
messages.push({ role: "user", content: results });
}
// 5. Imposed stop: fail loudly instead of returning a half answer.
throw new Error(`Agent hit maxSteps (${maxSteps}) without finishing`);
}
A few choices in there are deliberate:
- All tool results go back in one user message. When the model asks for two tools in one response, both
tool_resultblocks belong in the same next message, each matched bytool_use_id.Promise.allruns them concurrently, which is fine for independent reads and wrong for tools that mutate shared state - serialize those. - Failures become results, not exceptions. An unknown tool name or a thrown error turns into a
tool_resultwithis_error: true. The model reads the message and can retry with corrected input or try another approach. Crashing the loop throws that recovery away. Our Claude tool use production patterns post goes deeper on schema design and retries. - Only
end_turnandstop_sequencecount as done. A response that stopped onmax_tokens,refusalormodel_context_window_exceededhas no tool calls either, so a loop that returns on "anything buttool_use" would hand a truncated or refused response to the caller as a finished answer. The loop throws instead; in production you might raisemax_tokensand retry, or compact the history, before giving up. - Hitting the cap throws. Returning whatever text the model produced on step 10 would look like success to the caller. It is not; the task was not finished. We made the case for failing loudly in The Response Looked Right. The Work Was Not Done..
The OpenAI equivalent is structurally identical. With the Responses API you read function_call items from response.output, run them, and send back function_call_output items carrying the same call_id. Setting parallel_tool_calls: false guarantees zero or one tool call per response, which removes the need for the Promise.all branch.
You may not need to write this at all. Anthropic's SDKs ship a tool runner (client.beta.messages.toolRunner in TypeScript) that runs this exact loop until Claude returns a message without tool use, or until max_iterations if you set it. Each iteration is one API request. Write the loop yourself when you want control over every message; use the runner when you just want the loop.
The second loop you do not run: server tools and pause_turn#
Some Claude tools are executed by Anthropic rather than by your code - code execution is the common example. For those, the API runs its own loop inside a single request: the model calls the tool, the server runs it, the model sees the result, and so on. That server-side loop has an iteration cap. When it hits the cap, the response comes back with stop_reason: "pause_turn".
pause_turn does not mean "done". It means "send the conversation back as-is and I will keep going". A loop that treats every non-tool_use stop reason as final (which is a common shortcut) will return a half-finished turn as the answer. That is why the hand-rolled loop above checks for pause_turn before the natural-stop branch.
%% title: Two loops in one request
sequenceDiagram
participant App
participant API
participant Sandbox as Server tool
App->>API: messages + server tool
loop server-side iterations
API->>Sandbox: run code
Sandbox-->>API: result
end
alt iteration cap hit
API-->>App: stop_reason pause_turn
App->>API: resend conversation
else finished
API-->>App: stop_reason end_turn
endSame loop, five vocabularies#
This is where the SDKs actually differ. They all count something and stop at a cap, but "step", "super-step", "turn" and "iteration" are not the same unit, the defaults range from 5 to unlimited, and hitting the cap ranges from a quiet return to a thrown error.
| SDK (version) | Loop primitive | One unit counts | Default cap | When the cap is hit | Stop early on purpose |
|---|---|---|---|---|---|
Vercel AI SDK (ai@7.0.143) | ToolLoopAgent, generateText, streamText | A step: one model call plus its tool executions | isStepCount(20) on ToolLoopAgent and WorkflowAgent.generate() (from @ai-sdk/workflow@2.0.75); isStepCount(1) on generateText/streamText; none on WorkflowAgent.stream() | Returns normally; logs a warning if the default 20 cut off a loop that could have continued | hasToolCall(...), a tool with no execute, tool approval, custom StopCondition |
LangChain createAgent on LangGraph (langchain@1.5.18, @langchain/langgraph@1.5.1) | Graph with model_request and tools nodes | A super-step: one layer of graph nodes | recursionLimit 25 | Throws GraphRecursionError | modelCallLimitMiddleware / toolCallLimitMiddleware, returnDirect tools, human-in-the-loop interrupts |
Mastra (@mastra/core@1.78.0) | agent.generate / agent.stream | A step: one LLM call plus its tool executions | maxSteps 5 | Returns normally with a finishReason | stopWhen, onIterationComplete returning { continue: false }, isTaskComplete scorers, requireToolApproval, modelSettings.timeout.totalMs |
OpenAI Agents SDK (@openai/agents@0.20.0) | run() / Runner | A turn: one model invocation | maxTurns 10 (null disables it) | Throws MaxTurnsExceededError unless errorHandlers.maxTurns supplies an output | toolUseBehavior (stop_on_first_tool, stopAtToolNames, or a function), guardrail tripwires |
Claude Agent SDK (@anthropic-ai/claude-agent-sdk@0.3.302) | query() | A turn: one tool-use round trip (the final text-only response is not counted) | No limit on maxTurns or maxBudgetUsd | Result message with subtype error_max_turns or error_max_budget_usd; a single-shot query() then throws | PreToolUse hooks that deny, permission modes, canUseTool |
Two consequences worth spelling out:
- The same number is a different budget in each SDK.
maxTurns: 10in the OpenAI Agents SDK,stopWhen: isStepCount(10)in the AI SDK andrecursionLimit: 10in LangGraph do not allow the same amount of work. In LangGraph's case, thecreateAgentgraph alternates between themodel_requestnode and thetoolsnode, so each tool round trip consumes two super-steps. In our offline test with LangChain'sFakeToolCallingModel(langchain@1.5.18,@langchain/langgraph@1.5.1),recursionLimit: 10allowed five tool round trips andrecursionLimit: 20allowed ten. Each middleware hook node adds another super-step per round trip. The docs do not state this number, but it meansrecursionLimit: 10buys roughly five model calls, not ten. - The failure mode differs. The AI SDK and Mastra return a result and expect you to inspect it. LangGraph and the OpenAI Agents SDK throw. The Claude Agent SDK emits a typed error result first and then throws on single-shot calls. Code that moves between SDKs needs to handle both shapes.
Vercel AI SDK: stopWhen and prepareStep#
The AI SDK's loop exits when the model returns a finish reason other than tool-calls, when a tool without an execute function is called, when a tool call needs approval, or when a stop condition is met. ToolLoopAgent defaults to stopWhen: isStepCount(20). The lower-level generateText and streamText default to isStepCount(1) - so without stopWhen they make exactly one model call and never loop, which surprises people coming from older tutorials.
import { ToolLoopAgent, tool, isStepCount, hasToolCall } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const agent = new ToolLoopAgent({
model: anthropic("claude-sonnet-5-5"),
instructions: "Look up orders, then call done with a one-line summary.",
tools: {
getOrderStatus: tool({
description: "Look up the shipping status of an order by id.",
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => ({ orderId, status: "shipped" }),
}),
// No execute function: calling it ends the loop and hands control back.
done: tool({
description: "Call when the task is complete.",
inputSchema: z.object({ summary: z.string() }),
}),
},
// Stop when ANY condition is true: 10 steps, or the model called done.
stopWhen: [isStepCount(10), hasToolCall("done")],
// Runs before every step. Force a tool call on the first step only.
prepareStep: async ({ stepNumber }) =>
stepNumber === 0 ? { toolChoice: "required" } : {},
});
const result = await agent.generate({ prompt: "Where is order A-1001?" });
console.log(result.steps.length, result.finishReason, result.text);
What the AI SDK does well here is composability. A stop condition is just a function over { steps }, and an array stops when any element is true, so a token or cost budget is a few lines of custom StopCondition. The built-ins are isStepCount(n), hasToolCall(...names) and isLoopFinished(), which never triggers and so removes the cap entirely. prepareStep runs before every step and can swap the model, activeTools, toolChoice, the messages (the override persists as the base for later steps) and sampling settings. Pair it with the built-in pruneMessages helper to compact history mid-loop. Tool calls within a step run concurrently by default; set toolCallConcurrency: 1 for sequential execution in the order the model generated them.
If you are reading older code, the API moved three times. AI SDK 5.0.0 (July 31, 2025) replaced maxSteps with stopWhen. 6.0.0 (December 22, 2025) set the agent default to 20 steps. 7.0.0 (June 25, 2026) renamed stepCountIs to isStepCount (the old name is still exported as an alias) and added isLoopFinished. Our AI SDK 7 production agents post covers the rest of that release, including WorkflowAgent, which ships in the separate @ai-sdk/workflow package. Its generate() gained the same 20-step default in @ai-sdk/workflow 2.0.59 (ai 7.0.128), while WorkflowAgent.stream() has no default cap.
LangGraph and LangChain createAgent: the loop is a graph#
LangChain defines an agent as "a model calling tools in a loop until a given task is complete", and createAgent (from the langchain package) builds that loop as an explicit LangGraph graph. A router after the model_request node sends execution to tools when the last AIMessage has tool_calls, and to the exit otherwise.
%% title: createAgent as a LangGraph graph
flowchart LR
S([start]) --> M[model_request]
M -- tool_calls --> T[tools]
T --> M
M -- no tool_calls --> E([end])
T -- returnDirect --> EThe tools node is a ToolNode, which runs every tool call in a message concurrently with Promise.all. Tools marked returnDirect end the run right after they execute, without another model call.
import { createAgent, tool, modelCallLimitMiddleware } from "langchain";
import { GraphRecursionError } from "@langchain/langgraph";
import { z } from "zod";
const getOrderStatus = tool(
async ({ orderId }) => JSON.stringify({ orderId, status: "shipped" }),
{
name: "get_order_status",
description: "Look up the shipping status of an order by id.",
schema: z.object({ orderId: z.string() }),
},
);
const agent = createAgent({
model: "anthropic:claude-sonnet-5-5",
tools: [getOrderStatus],
// Budget in model calls, not graph super-steps. "end" stops gracefully.
middleware: [modelCallLimitMiddleware({ runLimit: 8, exitBehavior: "end" })],
});
try {
const result = await agent.invoke(
{ messages: [{ role: "user", content: "Where is order A-1001?" }] },
// Top-level config key, counts super-steps. Call-limit middleware makes a
// round trip cost ~4 super-steps, so keep this above 4 x runLimit.
{ recursionLimit: 40 },
);
console.log(result.messages.at(-1)?.content);
} catch (err) {
if (err instanceof GraphRecursionError) {
console.error("Graph hit its recursion limit");
} else {
throw err;
}
}
Here the explicit graph earns its keep: the loop is inspectable and checkpointable, and middleware gives you deterministic hooks around it. LangChain v1 ships limit middleware (modelCallLimitMiddleware with threadLimit, runLimit and exitBehavior: "end" | "error", and toolCallLimitMiddleware), plus humanInTheLoopMiddleware, summarizationMiddleware, toolRetryMiddleware and modelRetryMiddleware.
The tradeoff to plan for is the unit. recursionLimit counts super-steps - one iteration over the graph's nodes, where nodes that run in parallel share a super-step - not model calls. The default is 25 and hitting it raises GraphRecursionError. Pass it as a top-level config key to invoke or stream, not inside configurable. If what you actually want is "at most eight model calls", the call-limit middleware expresses that directly, and recursionLimit becomes the backstop - but only if it sits above the middleware budget. Each middleware hook (beforeModel, afterModel) is its own graph node and costs one more super-step per round trip, and modelCallLimitMiddleware registers both, which raises the cost of a round trip from 2 super-steps to 4. In our offline test, runLimit: 8 with recursionLimit: 20 threw GraphRecursionError after five tool runs, before the graceful "end" exit ever fired; with recursionLimit: 40 the same agent returned cleanly after eight.
Mastra: maxSteps plus iteration hooks#
Mastra's docs put it plainly: agents "continue iterating until the model emits a final answer or an optional stop condition is met". maxSteps caps the number of sequential LLM calls, and the Agent.generate() reference documents the default as 5. When you pass maxSteps, it is combined with any stopWhen conditions.
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
const getOrderStatus = createTool({
id: "get-order-status",
description: "Look up the shipping status of an order by id.",
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => ({ orderId, status: "shipped" }),
});
const agent = new Agent({
id: "order-assistant",
name: "Order assistant",
instructions: "Answer questions about orders.",
model: "anthropic/claude-sonnet-5-5",
tools: { getOrderStatus },
});
const result = await agent.generate("Where is order A-1001?", {
maxSteps: 8, // the default is 5
// Wall-clock budget for the whole run; fails with MastraTimeoutError.
modelSettings: { timeout: { totalMs: 60_000 } },
onIterationComplete: ({ iteration }) => {
// Nudge: feedback is added to the conversation before the next iteration.
if (iteration === 5) {
return { continue: true, feedback: "Wrap up: answer with what you have." };
}
// Hard stop: no further iterations, so no feedback is needed here.
if (iteration >= 7) return { continue: false };
return { continue: true };
},
});
console.log(result.finishReason, result.text);
Mastra has the most built-in "is it actually done?" machinery of the five. onIterationComplete runs after each iteration and returns { continue, feedback }: continue: false stops the loop, and feedback is added to the conversation before the next iteration - so it only reaches the model when the loop keeps going, which is why the example above nudges with continue: true and hard-stops separately. isTaskComplete runs scorers with an all or any strategy. modelSettings.timeout takes totalMs, stepMs and firstChunkMs; exceeding one fails with MastraTimeoutError, and fallback models are not tried when totalMs runs out, because it is a hard deadline for the whole run. requireToolApproval pauses the run with finishReason: "suspended". Tool concurrency is approval-aware: toolCallConcurrency defaults to 10, drops to 1 when approval may be required, and the object form { limit, strategy: "called" } decides based on the tools actually called in each step.
The one thing to watch: 5 steps is the lowest default here. A task that reads three files, edits one and runs a test is already past it, so long tool chains need an explicit maxSteps.
OpenAI Agents SDK: turns, handoffs and toolUseBehavior#
The OpenAI Agents SDK runner calls the current agent's model and inspects the response. A final output returns. A handoff switches to another agent and keeps the conversation history. Tool calls execute, their results are appended, and the loop repeats. maxTurns defaults to 10; pass null to disable it. Reaching it throws MaxTurnsExceededError.
import { Agent, run, tool, MaxTurnsExceededError } from "@openai/agents";
import { z } from "zod";
const getOrderStatus = tool({
name: "get_order_status",
description: "Look up the shipping status of an order by id.",
parameters: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => JSON.stringify({ orderId, status: "shipped" }),
});
const agent = new Agent({
name: "Order assistant",
instructions: "Answer questions about orders.",
tools: [getOrderStatus],
// The tool's output becomes the final output; no extra model call.
toolUseBehavior: { stopAtToolNames: ["get_order_status"] },
});
try {
const result = await run(agent, "Where is order A-1001?", { maxTurns: 5 });
console.log(result.finalOutput);
} catch (err) {
if (err instanceof MaxTurnsExceededError) {
console.error("Gave up after 5 turns");
} else {
throw err;
}
}
Handoffs are the distinctive part: switching agents is a first-class transition inside the loop rather than something you build on top. errorHandlers can catch the maxTurns, modelRefusal and invalidFinalOutput kinds and return a synthesized final output instead of throwing, which is useful when you would rather degrade than fail. toolUseBehavior controls what happens after a tool runs: run_llm_again (the default), stop_on_first_tool, { stopAtToolNames: [...] }, or a function that decides. It applies to function tools only, since hosted tools are always processed by the model. resetToolChoice defaults to true, which resets a forced tool choice after a call so the agent does not loop on the same tool forever. Our OpenAI Agents SDK vs Claude Agent SDK comparison covers the platform-level differences.
Claude Agent SDK: the Claude Code loop, embedded#
The Claude Agent SDK runs the same execution loop that powers Claude Code. A turn is one tool-use round trip, and turns continue until Claude produces output with no tool calls. Unlike the others, it ships the whole harness: built-in tools such as Read, Edit, Write, Glob, Grep and Bash, permissions, hooks, sessions and automatic compaction (which emits a compact_boundary system message).
import { query } from "@anthropic-ai/claude-agent-sdk";
try {
for await (const message of query({
prompt: "Find the failing test in src/ and explain the cause.",
options: {
allowedTools: ["Read", "Glob", "Grep"],
maxTurns: 25, // tool-use round trips; the default is no limit
maxBudgetUsd: 2, // checked after each response, so leave headroom
},
})) {
if (message.type === "result") {
if (message.subtype === "success") {
console.log(message.result);
} else {
// error_max_turns, error_max_budget_usd, error_during_execution, ...
console.error(message.subtype, message.num_turns, message.total_cost_usd);
}
}
}
} catch (err) {
// A single-shot query() throws after an error result.
console.error("Agent run failed:", err);
}
maxTurns counts tool-use turns only and defaults to no limit; maxBudgetUsd also defaults to no limit. Hitting either produces a result message with subtype error_max_turns or error_max_budget_usd. The full set of result subtypes is success, error_max_turns, error_max_budget_usd, error_during_execution and error_max_structured_output_retries. Only success carries the result field, but every subtype carries total_cost_usd, usage, num_turns and session_id, so you can log what a failed run cost.
The concurrency model is sensible by default: read-only tools (Read, Glob, Grep, and MCP tools marked read-only) can run concurrently, while state-modifying tools (Edit, Write, Bash) run sequentially. Custom tools are sequential unless you set readOnlyHint. Hooks run in your process, not in the context window, and a PreToolUse hook that rejects a call prevents execution; Claude receives the rejection message and continues.
Two caveats from the docs. First, there is no default cap, so an unattended agent with no maxTurns or maxBudgetUsd runs until it decides it is done. Second, the budget check happens after each model response arrives, so spend can exceed maxBudgetUsd by up to one response plus any running subagents. Subagent spend counts toward the cap, and the documented enforcement behavior requires Claude Code v2.1.217 or later. Leave headroom.
Choosing and setting stop conditions#
The mechanics above reduce to a short checklist:
- Always set the cap explicitly, even when the default suits you. Defaults range from 5 to unlimited, and someone porting your agent to another SDK will not carry the implicit default with them. In the AI SDK, setting
stopWhenexplicitly also removes the warning it logs when the default 20-step limit cuts a loop short. - Pair a count cap with a cost or time cap. Step counts do not bound spend when context grows each turn. Use Claude Agent SDK
maxBudgetUsd, MastramodelSettings.timeout.totalMs, or an AI SDK customStopConditionthat sums usage acrosssteps. - Give the model an explicit way to finish. A
donetool with noexecutein the AI SDK,stopAtToolNamesin the OpenAI Agents SDK, or areturnDirecttool in LangChain makes completion a signal you can check rather than a guess from the absence of tool calls. - Decide whether the cap throws or returns, and surface it either way. If your SDK returns quietly (AI SDK, Mastra), check the step count or finish reason before treating the output as an answer. If it throws (LangGraph, OpenAI), catch the specific error type.
- Return tool failures as results.
is_error: truein Claude's API, or an error string in the tool output elsewhere, lets the model recover instead of ending the run. - Treat
pause_turnas "continue" if you use Claude server tools in a hand-rolled loop.
%% title: Where each loop can stop
flowchart TD
A[Call model] --> B{Tool calls?}
B -- no --> Z[Done: final answer]
B -- yes --> C{Stop rule hit?}
C -- yes --> Y[Stop: return or throw]
C -- no --> D{Needs approval?}
D -- yes --> X[Pause for a human]
D -- no --> E[Run tools]
E --> F[Append results]
F --> G{Step or budget cap?}
G -- yes --> Y
G -- no --> AFor how to decide when an agent is actually done - verification, convergence, and progress detection - the loop engineering post picks up where this one stops.
FAQ#
What is an agent loop?#
An agent loop calls a model with tool definitions, runs the tool calls it returns, sends the results back, and repeats until the model answers without calling a tool or an imposed stop rule fires. LangChain describes an agent as "a model calling tools in a loop until a given task is complete", and the Claude Agent SDK docs describe turns continuing until Claude produces output with no tool calls.
What is the default max steps in the Vercel AI SDK, LangGraph, Mastra, OpenAI Agents SDK and Claude Agent SDK?#
As of the October 2026 releases: Vercel AI SDK ToolLoopAgent and WorkflowAgent.generate() default to 20 steps (generateText and streamText default to 1, and WorkflowAgent.stream() has no cap), LangGraph defaults to a recursionLimit of 25 super-steps, Mastra defaults to 5 steps, the OpenAI Agents SDK defaults to 10 turns, and the Claude Agent SDK has no default limit on turns or budget.
Is a step the same as a turn?#
No. An AI SDK or Mastra step is one model call plus its tool executions. An OpenAI Agents SDK turn is one model invocation. A Claude Agent SDK turn is one tool-use round trip, and the final text-only response is not counted. LangGraph counts super-steps, which are graph node layers, so one tool round trip in createAgent uses at least two.
What happens when an agent hits its step limit?#
The Vercel AI SDK and Mastra return normally, so you have to inspect the result. LangGraph throws GraphRecursionError. The OpenAI Agents SDK throws MaxTurnsExceededError unless an errorHandlers.maxTurns handler supplies an output. The Claude Agent SDK emits a result with subtype error_max_turns, and a single-shot query() then throws.
Should I write my own loop or use an SDK?#
Hand-roll the loop, or use Anthropic's tool runner, when you work with one provider and want full control over every message. Use an SDK when you need multiple providers, human approvals, durable or checkpointed runs, or a complete harness with built-in tools and permissions.
Sources#
- Vercel AI SDK, Loop Control: https://ai-sdk.dev/docs/agents/loop-control
- Vercel AI SDK changelog and type definitions: https://www.npmjs.com/package/ai
- AI SDK Workflow package (
WorkflowAgent) and changelog: https://www.npmjs.com/package/@ai-sdk/workflow - LangChain, Agents (JavaScript): https://docs.langchain.com/oss/javascript/langchain/agents
- LangGraph, Graph API (JavaScript): https://docs.langchain.com/oss/javascript/langgraph/graph-api
- LangChain package: https://www.npmjs.com/package/langchain
- LangGraph package: https://www.npmjs.com/package/@langchain/langgraph
- Mastra, Agents overview: https://mastra.ai/docs/agents/overview
- Mastra, Agent.generate reference: https://mastra.ai/reference/agents/generate
- Mastra core package: https://www.npmjs.com/package/@mastra/core
- OpenAI Agents SDK (JS), Running agents: https://openai.github.io/openai-agents-js/guides/running-agents/
- OpenAI Agents SDK package: https://www.npmjs.com/package/@openai/agents
- OpenAI, Function calling guide: https://developers.openai.com/api/docs/guides/function-calling
- Claude Agent SDK, The agent loop: https://code.claude.com/docs/en/agent-sdk/agent-loop
- Claude Agent SDK package: https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk
- Anthropic, How tool use works: https://platform.claude.com/docs/en/agents-and-tools/tool-use/how-tool-use-works
- Anthropic, Handle tool calls: https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls
- Anthropic, Tool runner: https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-runner
- Anthropic TypeScript SDK package (
StopReasontype): https://www.npmjs.com/package/@anthropic-ai/sdk - Anthropic, Handling stop reasons: https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons
Continue Reading#
- Loop Engineering: How to Design Agent Loops That Actually Converge - convergence criteria, retries and budgets on top of these mechanics
- Tool Use in the Claude API: Production Patterns for Reliable Agents - schema design, the execution layer and failure modes
- OpenAI Agents SDK vs Claude Agent SDK: Building Agents on the Two Big Platforms - choosing between the two vendor SDKs
Get the next deep dive like this in your inbox
One email a week on AI Agents and the rest of the AI dev stack. Free.
Read next on AI coding tools
Loop Engineering: How to Design Agent Loops That Actually Converge
Loop engineering patterns: plan/act/verify cycles, convergence criteria, retries, budget-bounded loops, and loop-until-dry, so agents stop when they should.
11 min readTool Use in the Claude API: Production Patterns for Reliable Agents
Master tool use in the Claude API. Schema design, retry logic, multi-step loops, and the failure modes that only show up at 10k calls a day.
12 min readWhat Is an Agent Harness? A Developer's Map for 2026
An agent harness is everything around the model: loop, tools, context, sandbox. How Pi, DeepSeek, Codex and Claude Code differ, and how to choose.
5 min readNew here? Start with
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.








