Multi-Step AI Agents with the Vercel AI SDK: Agentic Loops in Next.js
The maxSteps option in streamText unlocks true agentic behaviour — the model reasons, calls tools, sees results, and loops until it has a final answer. Here's how to wire it up in Next.js 16 and keep it from spinning forever.
6 min read
A single LLM round-trip is enough for many tasks: summarise this text, extract these fields, answer this question. But real-world agents often need to act on their own output — look up a fact, decide the result is incomplete, look up another fact, then synthesise. That loop requires the model to call tools more than once per user message, and by default streamText stops after the first round.
The maxSteps option changes that. Set it to a number greater than one and the Vercel AI SDK will continue the loop — feeding each tool result back into the model as new context — until the model emits a final text response or the step cap is reached.
What a step is
Each step is one model inference call plus any tool executions the model requests. A two-step agent looks like this:
- Step 1: model receives the user message, decides to call a tool, SDK executes the tool, result is appended to the message history.
- Step 2: model receives the updated history (including the tool result), decides it has enough context, emits a final text answer.
A three-step agent adds another tool call in the middle. The SDK manages the history assembly between steps automatically — you only define the tools and set the cap.
Wiring it up in a Route Handler
Here is a minimal route handler for a research agent. It has two tools — one to look up a weather forecast and one to look up local events — and lets the model decide which to call and in what order:
// app/api/agent/route.ts
import { streamText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
maxSteps: 5,
tools: {
getWeather: tool({
description: "Get the current weather for a city.",
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => fetchWeather(city),
}),
getLocalEvents: tool({
description: "List upcoming events in a city.",
parameters: z.object({ city: z.string(), date: z.string() }),
execute: async ({ city, date }) => fetchEvents(city, date),
}),
},
});
return result.toDataStreamResponse();
}With maxSteps: 5, the model can call up to five tools before the SDK forces a final response. In practice, a well-prompted agent rarely needs more than two or three for this kind of task, so five gives comfortable headroom without risking runaway costs.
Streaming intermediate steps to the client
toDataStreamResponse() already streams tool call events alongside text chunks. On the client, useChat from ai/reactpicks these up automatically. You can surface in-progress tool calls as a “thinking” indicator:
"use client";
import { useChat } from "ai/react";
export function AgentChat() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: "/api/agent",
});
return (
<div>
{messages.map((m) => (
<div key={m.id}>
{m.role === "assistant" && m.toolInvocations?.map((t) => (
t.state === "call" ? (
<p key={t.toolCallId}>Calling {t.toolName}…</p>
) : (
<details key={t.toolCallId}>
<summary>{t.toolName} result</summary>
<pre>{JSON.stringify(t.result, null, 2)}</pre>
</details>
)
))}
{m.content && <p>{m.content}</p>}
</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
<button type="submit">Send</button>
</form>
</div>
);
}The toolInvocations array on each assistant message holds every tool call in that step, with a state field that transitions from "call" to "result" as the tool executes. Rendering both states gives users a transparent view of what the agent is doing while it works.
Observing steps on the server with onStepFinish
For logging and cost tracking, the onStepFinishcallback fires after each step completes. It receives the step's token usage and tool results, making it easy to write an audit trail without blocking the stream:
const result = streamText({
model: openai("gpt-4o"),
messages,
maxSteps: 5,
tools: { /* … */ },
onStepFinish({ stepType, usage, toolCalls, toolResults }) {
console.log("step:", stepType, "tokens:", usage.totalTokens);
},
});Pair this with the after() API covered in a previous post to write usage records after the response is sent without adding latency.
Preventing runaway loops
Three things bound a misbehaving agent. First, maxSteps is a hard cap — the SDK throws if the model keeps requesting tools past it, and streamText will still return whatever text the model has produced. Second, set an abortSignal tied to the request so the loop is cancelled if the client disconnects. Third, give each tool an internal timeout so a slow external API cannot hold the loop open indefinitely:
execute: async ({ city }, { abortSignal }) => {
const res = await fetch(`https://api.weather.example/${city}`, {
signal: AbortSignal.any([abortSignal, AbortSignal.timeout(3000)]),
});
return res.json();
},With these three guards in place, the worst case is a capped, time-bounded loop that fails fast rather than accumulating unbounded tokens.
When to reach for maxSteps
Single-step tool use covers most chatbot features — answering a question with a database lookup, generating structured output, calling an external API once. Reach for maxSteps when the task requires reasoning about the tool result before deciding the next action: research tasks that involve multiple sources, code agents that run-and-observe-and-fix, and any workflow where the number of tool calls is not known in advance.
For deterministic pipelines where you know exactly which tools to call in which order, a regular sequential server function is simpler and cheaper. maxStepsearns its keep when the model needs autonomy — when the routing logic is itself part of what you're asking the LLM to do.