The Vercel AI SDK’s maxSteps parameter is the control knob that turns a single tool call into an autonomous agent loop. Without it, the model gets one shot at tools and must return a final answer. With it, the model can chain multiple tool invocations — search, then calculate, then write — before responding. This guide walks through configuring maxSteps for vercel ai sdk maxsteps multi-step tool calls, handling the streaming lifecycle, and verifying the behavior in a real Next.js route.
Step 1: Install the required packages
Start with a fresh Next.js 14+ project using the App Router. You need the AI SDK core, the OpenAI provider (or your provider of choice), and Zod for schema validation.
npm i ai @ai-sdk/openai zod
If you’re using a different provider — Anthropic, Google, or a gateway like n4n.ai — swap @ai-sdk/openai for the appropriate package. The maxSteps logic is provider-agnostic.
Step 2: Define your tools with Zod schemas
Each tool needs a strict schema. The model uses these to decide what to call and with what arguments. Keep schemas minimal; extra fields confuse the model and waste tokens.
// lib/tools.ts
import { tool } from 'ai';
import { z } from 'zod';
export const searchWeb = tool({
parameters: z.object({
query: z.string().min(3).max(200),
maxResults: z.number().int().min(1).max(10).default(5),
}),
execute: async ({ query, maxResults }) => {
// Replace with real search API (Serper, Tavily, Exa, etc.)
const results = await mockSearch(query, maxResults);
return { results };
},
});
export const calculate = tool({
parameters: z.object({
expression: z.string().describe('Math expression to evaluate, e.g. "(1200 * 0.15) + 45"'),
}),
execute: async ({ expression }) => {
// Use a safe evaluator in production (mathjs, not eval)
const result = evaluateSafely(expression);
return { result };
},
});
export const writeFile = tool({
parameters: z.object({
path: z.string().min(1),
content: z.string(),
}),
execute: async ({ path, content }) => {
await fs.promises.writeFile(path, content, 'utf-8');
return { success: true, path };
},
});
async function mockSearch(query: string, maxResults: number) {
// Stub — replace with real implementation
return Array.from({ length: maxResults }, (_, i) => ({
title: `Result ${i + 1} for "${query}"`,
url: `https://example.com/result-${i + 1}`,
snippet: `This is a mock snippet for result ${i + 1}.`,
}));
}
function evaluateSafely(expr: string): number {
// Extremely naive — use mathjs or similar in production
return Function(`"use strict"; return (${expr})`)();
}
Step 3: Create the route handler with maxSteps
The route handler is where maxSteps lives. Set it to the maximum number of tool-call rounds you want to allow. Each round = one model turn that may produce multiple tool calls in parallel, followed by tool results fed back to the model.
// app/api/chat/route.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { searchWeb, calculate, writeFile } from '@/lib/tools';
export const maxDuration = 60; // seconds, adjust for your platform
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
tools: {
searchWeb,
calculate,
writeFile,
},
maxSteps: 5, // <-- the key setting for multi-step tool calls
temperature: 0.2,
system: `You are a research assistant.
When users ask complex questions, break them down:
1. Search for current information
2. Calculate or analyze as needed
3. Write findings to a file if requested
Always cite sources from search results.`,
});
return result.toDataStreamResponse();
}
What maxSteps: 5 actually does: The model receives the user message, calls tools (possibly several in parallel), receives results, then decides whether to call more tools or answer. That’s one step. It repeats up to 5 times. If it hits the limit mid-task, the final response will note it couldn’t complete — so set the limit higher than your longest expected chain.
Step 4: Handle the streaming response on the client
The client needs to render tool calls, tool results, and the final answer as they arrive. The AI SDK’s useChat hook handles this, but you must opt into multi-step rendering.
// app/chat/page.tsx
'use client';
import { useChat } from 'ai/react';
import { Message } from 'ai';
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/chat',
// Critical: this tells useChat to expect multiple assistant messages
// per user turn (one per step)
onFinish: (message) => {
console.log('Final message:', message);
},
});
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{messages.map((message: Message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8, marginTop: 24 }}>
<input
value={input}
onChange={handleInputChange}
placeholder="Ask a multi-step question..."
style={{ flex: 1, padding: 12, fontSize: 16 }}
disabled={isLoading}
/>
<button type="submit" disabled={isLoading || !input.trim()}>
{isLoading ? 'Working...' : 'Send'}
</button>
</form>
</div>
);
}
function MessageBubble({ message }: { message: Message }) {
const isAssistant = message.role === 'assistant';
const hasToolCalls = message.toolInvocations && message.toolInvocations.length > 0;
const hasToolResults = message.toolResults && message.toolResults.length > 0;
return (
<div
style={{
display: 'flex',
justifyContent: isAssistant ? 'flex-start' : 'flex-end',
marginBottom: 12,
}}
>
<div
style={{
maxWidth: '85%',
padding: '12 16',
borderRadius: 12,
backgroundColor: isAssistant ? '#f3f4f6' : '#3b82f6',
color: isAssistant ? '#111827' : 'white',
}}
>
{message.content && (
<div style={{ whiteSpace: 'pre-wrap', marginBottom: 8 }}>{message.content}</div>
)}
{hasToolCalls && (
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: '#6b7280' }}>
Tool calls ({message.toolInvocations!.length})
</summary>
<pre style={{ marginTop: 8, fontSize: 12, overflow: 'auto' }}>
{JSON.stringify(message.toolInvocations, null, 2)}
</pre>
</details>
)}
{hasToolResults && (
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: '#059669' }}>
Tool results ({message.toolResults!.length})
</summary>
<pre style={{ marginTop: 8, fontSize: 12, overflow: 'auto' }}>
{JSON.stringify(message.toolResults, null, 2)}
</pre>
</details>
)}
</div>
</div>
);
}
The useChat hook automatically splits the streamed response into separate Message objects per step. Each assistant message in the array represents one step — containing either tool calls, tool results, or the final text.
Step 5: Test a multi-step scenario
Start the dev server and try a prompt that requires chaining:
"Find the current price of AAPL and TSLA, calculate what a 50/50 portfolio of $10,000 would look like, and write the breakdown to portfolio.txt"
Watch the stream. You should see:
- Step 1 — Assistant message with two
searchWebtool calls (parallel) - Step 2 — Assistant message with tool results, then a
calculatetool call - Step 3 — Assistant message with calculation result, then a
writeFiletool call - Step 4 — Assistant message with file write confirmation and final summary
Verify success
Open the browser dev tools Network tab and inspect the /api/chat response. You’ll see a data: stream with multiple tool_call and tool_result events before the final text delta. Count the assistant messages in the messages array returned by useChat — there should be 4 in this example (one per step).
Also verify the file was created:
cat portfolio.txt
Expected output:
Portfolio Allocation (50/50, $10,000 total)
------------------------------------------
AAPL: $5,000 @ $189.42 = 26.40 shares
TSLA: $5,000 @ $248.15 = 20.15 shares
Prices as of 2024-01-15 (source: Yahoo Finance via searchWeb)
Step 6: Tune maxSteps for your use case
maxSteps is a safety limit, not a target. Set it based on the longest legitimate chain your tasks require:
| Task complexity | Recommended maxSteps |
|---|---|
| Single lookup + answer | 2 |
| Search → calculate → answer | 3 |
| Search → search → calculate → write → answer | 5 |
| Open-ended research agent | 8–10 |
Warning: Each step consumes an additional model call (input tokens + output tokens). A 5-step chain with 4k context each ≈ 5× the cost of a single turn. Monitor your usage.
Add a guardrail in the system prompt to prevent runaway loops:
system: `You are a research assistant.
Max steps: 5. If you reach the limit, summarize what you have and stop.
Prefer parallel tool calls. Never call the same tool with the same arguments twice.`,
Step 7: Handle errors and partial failures
Tool execution can fail. The SDK surfaces errors as tool results with error: true. Your model needs to see these and decide whether to retry, try a different tool, or apologize to the user.
// In your tool definition
execute: async ({ query, maxResults }) => {
try {
const results = await realSearchAPI(query, maxResults);
return { results };
} catch (err) {
// Return structured error — the model sees this
return {
error: true,
message: err instanceof Error ? err.message : 'Search failed',
query,
};
}
},
The model will receive this in the next step’s context. A well-prompted model will retry with a modified query or fall back to a different source.
Step 8: Add observability (optional but recommended)
In production, log each step’s duration, token usage, and tool outcomes. The streamText result exposes a usage promise and onStepFinish callback.
const result = streamText({
// ... config
onStepFinish: async (step) => {
console.log(JSON.stringify({
step: step.stepIndex,
toolCalls: step.toolCalls?.length ?? 0,
toolResults: step.toolResults?.length ?? 0,
usage: step.usage,
durationMs: step.duration,
}));
},
});
// After streaming completes
const usage = await result.usage;
console.log('Total usage:', usage);
This lets you correlate cost with task complexity and spot steps that stall or fail repeatedly.
Step 9: Deploy with appropriate timeouts
Multi-step calls take longer. Vercel’s default 10s function timeout will kill a 5-step chain. Set maxDuration in the route (as shown in Step 3) and configure your platform:
- Vercel:
maxDuration: 60in the route export (max 60s on Hobby, 300s on Pro) - AWS Lambda: Increase timeout in
serverless.ymlor console - Docker/K8s: Set container timeout and keep-alive
If you hit the platform timeout before maxSteps completes, the user gets a truncated response. Monitor onStepFinish duration to size your timeout with headroom.
Step 10: Advanced — dynamic maxSteps per request
Not every request needs the same limit. Expose maxSteps as a client-controlled parameter (with a server-side cap).
// app/api/chat/route.ts
export async function POST(req: Request) {
const { messages, maxSteps = 5 } = await req.json();
const clampedSteps = Math.min(Math.max(maxSteps, 1), 10); // clamp 1–10
const result = streamText({
// ...
maxSteps: clampedSteps,
});
return result.toDataStreamResponse();
}
Client usage:
const { handleSubmit } = useChat({
api: '/api/chat',
body: { maxSteps: 8 }, // for complex research tasks
});
This lets simple chat stay cheap while power users opt into deeper chains.
Quick reference: maxSteps behavior matrix
| maxSteps | Model calls | Tool rounds | Typical use case |
|---|---|---|---|
| 1 | 1 | 0 | Simple Q&A, no tools |
| 2 | 2 | 1 | One tool call + answer |
| 3 | 3 | 2 | Search → answer |
| 5 | 5 | 4 | Search → calc → write → answer |
| 10 | 10 | 9 | Deep research, multi-source synthesis |
Common pitfalls
- Forgetting
onStepFinish— Without it, you can’t debug why the agent stopped at step 3 of 5. - Setting
maxStepstoo low — The model cuts off mid-task and hallucinates a completion. - Not handling tool errors — A failed search returns an error object; if the model doesn’t know to retry, it makes up answers.
- Parallel vs sequential confusion — The model can call multiple tools in one step. Design tools to be independent so parallelism works.
- Token bloat — Each step adds the full conversation + tool results to context. For long chains, summarize earlier steps in the system prompt or use a separate summarization pass.
You now have a working multi-step tool-calling pipeline with maxSteps. The pattern scales: add more tools, tighten schemas, instrument the callbacks, and you have a production-grade agent loop. The only thing maxSteps doesn’t solve is judgment — that’s still on your prompt engineering and tool design.