How `useChat` Appends Tool Results to the Next Turn — A Deep Dive into Vercel AI SDK 4.x Multi-Step Loops
Many developers know how to implement the agent loop — where an LLM calls tools instead of returning text, accumulates results in context, and produces a final answer — but far fewer truly understand what's happening internally. At first, I used it with only a vague sense of "call addToolResult on the client and something happens," until longer conversations started hitting bizarre payload issues and maxSteps exhaustion, which finally pushed me to dig deeper.
This post structurally analyzes the mechanism by which useChat in Vercel AI SDK 4.x collects client-side tool execution results and stitches them into the context for the next LLM turn. Rather than covering basic usage, it addresses why this structure exists, where costs arise, and what mistakes to watch out for. Version references are based on August 2026.
Why This Structure Is Necessary — Difference from Simple Chat
Text Responses Are Simple; Tool Calls Are Not
Regular chat is straightforward. User message → server calls LLM → text streams back → done. useChat handles all of this automatically.
But the moment the LLM decides "I need to call a weather API to answer this question," the flow changes. Instead of text, it returns a tool call directive, and once that tool executes and returns a result, that result must be fed back into the context before the LLM can produce a final text response.
The complication is that tools may run on the server, or may only be accessible on the client. The browser's navigator.geolocation, local storage, DOM state — the server has no way to access these. This is why the AI SDK designed a cooperative architecture that supports both server-side and client-side tools.
The Full Flow of the Multi-Step Loop
It sounds complex in words, but the pattern becomes clear with a diagram.
The key is that it splits into two branches. Server-side tools with execute have streamText's maxSteps handle the loop within a single HTTP request, delivering everything including the final text in one stream. Client-side tools without execute, on the other hand, end the stream at the tool call directive, and the client must fill in the result and resend the full message array before the next step begins. This automatic resend is what "multi-step" means for client-side tools.
maxSteps controls the limit on this resend loop. The default is 1, so no resend occurs; setting it to 2 or higher enables client-side resending.
Why the Message Structure Changed — Understanding the parts Array
Before 4.2: Limitations of the toolInvocations Array
Before 4.2, an assistant message had a single content string and a separate toolInvocations array. In multi-step responses mixing text and tool calls, ordering information was lost. It was difficult to accurately reproduce in the UI whether "the model spoke text first then called a tool, or generated text after seeing the tool result."
4.2+: Unified parts Array
The AI SDK 4.2 release introduced message.parts — a single array containing text, tool calls, and step delimiters in the exact order they occurred.
// Message structure returned by useChat (AI SDK 4.2+)
{
role: "assistant",
parts: [
{ type: "step-start" },
{ type: "text", text: "Let me check the weather." },
{
type: "tool-invocation",
toolInvocation: {
toolCallId: "call_abc123",
toolName: "getWeather",
args: { city: "Seoul" },
// state transitions: "partial-call" (arguments streaming)
// -> "call" (arguments complete, awaiting execution)
// -> "result" (execution complete)
state: "result",
result: { temp: 22, condition: "Sunny" }
}
},
{ type: "step-start" },
{ type: "text", text: "The current temperature in Seoul is 22 degrees." }
]
}state starts at "partial-call" while arguments are being streamed, transitions to "call" once arguments are complete, and then to "result" when the actual result is filled in. Misunderstanding this order will cause loading UIs to behave completely backwards, so be careful. The step-start part marks the boundary between each step in the multi-step loop.
Three Implementation Patterns
Pattern 1: Auto-Executing Client Tool
The simplest form — exposing browser-only data as a tool. Return a value from the onToolCall callback and useChat will automatically call addToolResult and proceed to the next step.
// app/chat/page.tsx
const { messages, input, handleSubmit, handleInputChange } = useChat({
maxSteps: 5, // limit on client resend round-trips
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === "getUserLocation") {
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(pos) => resolve({
lat: pos.coords.latitude,
lng: pos.coords.longitude
}),
() => resolve({ error: "Location access denied" })
);
});
}
},
});// app/api/chat/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,
// In this scenario, only tools without execute exist, so
// maxSteps on streamText is effectively meaningless.
// The resend limit is controlled by maxSteps on the useChat side.
tools: {
getUserLocation: tool({
description: "Gets the user's current location",
parameters: z.object({}),
// omitting execute = client-side tool
}),
},
});
return result.toDataStreamResponse();
}Omitting the execute function from a server-side tool definition marks it as a client-side tool. The server sends the tool call directive in the stream and closes the response, so adding maxSteps to the server's streamText in this scenario does nothing — no server-internal loop runs. The loop limit is managed on the useChat side.
Pattern 2: Human-in-the-Loop — Tools Requiring User Approval
For sensitive operations like file deletion, sending emails, or payments, a human must confirm even if the LLM issues a call directive. Detect the state: "call" state to show an approval UI, then call addToolResult based on the user's response.
There is a common mistake here. If a child component like MessageList calls useChat on its own, it creates a completely separate instance from the parent, and addToolResult will touch state unrelated to the messages visible on screen. Always call the hook once at the top level and pass messages and addToolResult down via props or Context.
// app/chat/page.tsx — hook called only once at the top level
"use client";
import { useChat } from "@ai-sdk/react";
import { MessageList } from "@/components/MessageList";
export default function ChatPage() {
const chat = useChat({ maxSteps: 3 });
return (
<>
<MessageList
messages={chat.messages}
addToolResult={chat.addToolResult}
/>
<form onSubmit={chat.handleSubmit}>
<input value={chat.input} onChange={chat.handleInputChange} />
</form>
</>
);
}// components/MessageList.tsx — uses only state passed as props
import type { UseChatHelpers } from "@ai-sdk/react";
type Props = Pick<UseChatHelpers, "messages" | "addToolResult">;
export function MessageList({ messages, addToolResult }: Props) {
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.role === "assistant" &&
message.parts?.map((part, idx) => {
// A single message can have multiple text parts,
// so construct a unique key that includes the index.
const baseKey = `${message.id}:${idx}`;
if (part.type === "text") {
return <p key={baseKey}>{part.text}</p>;
}
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "call" &&
part.toolInvocation.toolName === "deleteFile"
) {
const { toolCallId, args } = part.toolInvocation;
return (
<ConfirmCard
key={toolCallId}
message={`Delete file ${args.path}?`}
onApprove={() =>
addToolResult({
toolCallId,
result: { approved: true, deletedPath: args.path },
})
}
onReject={() =>
addToolResult({
toolCallId,
result: { approved: false, reason: "User rejected" },
})
}
/>
);
}
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
return (
<ToolResultBadge
key={part.toolInvocation.toolCallId}
name={part.toolInvocation.toolName}
result={part.toolInvocation.result}
/>
);
}
})}
</div>
))}
</div>
);
}The flow from awaiting approval → approve/reject → result badge ultimately branches on a single state field. This pattern was later documented separately in the Tool Approvals docs, but it can be implemented directly on 4.x using the same principle.
Pattern 3: Multi-Step Agent — Server-Side Tool Chaining
The LLM calls multiple tools sequentially to compose a final answer. If only server-side tools are used, steps repeat within a single HTTP request without any client round-trips.
// app/api/chat/route.ts — server-side multi-step
import { streamText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic("claude-3-5-sonnet-20241022"),
messages,
maxSteps: 5, // server-internal loop limit
tools: {
getWeather: tool({
description: "Gets the current weather for a city",
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => {
return { temp: 22, condition: "Sunny", city };
},
}),
searchRestaurants: tool({
description: "Searches for restaurants based on weather and location",
parameters: z.object({
city: z.string(),
weather: z.string(),
}),
execute: async ({ city, weather }) => {
return { restaurants: ["Restaurant A", "Restaurant B"] };
},
}),
},
});
return result.toDataStreamResponse();
}Tools with execute run on the server immediately, and results are included in the stream. No client resend is needed, so response latency is much lower with no round-trip overhead. Client-side tools, on the other hand, require UI interaction, adding a network round-trip per step. Which tools go server-side vs. client-side is effectively an architectural decision.
Trade-offs — Costs Hidden Behind Convenience
useChat's automatic context management is convenient. There's no need to manually manipulate the message array, and injecting tool results is as simple as calling addToolResult. But there are a few traps in production use.
| Item | Details | Severity |
|---|---|---|
| Client tool resend cost | Every client tool result return resends the entire conversation history to the server | Medium |
| Context token accumulation | As conversations grow longer, token count per request increases | Medium–High |
maxSteps exhaustion |
With models that call tools aggressively, hitting the limit ends the loop without a final text response | High |
Dual maxSteps management |
Server streamText's maxSteps and useChat's maxSteps control different axes |
Medium |
| Frequency of API changes | Hook APIs and loop termination condition expressions have been updated multiple times between 4.x and 5.x | Medium |
Global maxSteps only |
No API support for applying different limits to specific tools (Discussion #3815) | Low–Medium |
stopWhen mixing conflicts |
Mixing v5's stopWhen with useChat's resend logic can cause unexpected additional round-trips (Issue #7502) |
Medium |
Two Common Mistakes
Mistake 1: Setting maxSteps arbitrarily without knowing the model's tool-calling patterns
maxSteps: 2 isn't inherently a bad value — it's fine for simple cases where only one tool call is needed. The problem is that some models chain multiple tool calls for a single answer. Setting a low limit with an aggressively tool-calling model means the last step ends on a tool call, and no final text is ever produced. It's safer to log actual call counts per model and decide based on data.
Mistake 2: The client can't detect when the server's maxSteps is exhausted
When a server-side multi-step loop hits the server's maxSteps limit and terminates, from the client's perspective it just looks like a "short response" or "tool call with no text following." Without parsing the finishReason or step logs and surfacing them in the UI, the user simply sees the assistant trail off mid-sentence.
Migration Notes: v4.x → v5+
The pace of version changes is fast. As of August 2026, the feel of 4.x and 5.x is quite different. Always consult the official Migration Guide and the AI SDK 5 release blog for exact symbol names and signatures, and adjust to match the version you're using.
| Concern | v4.x | v5+ |
|---|---|---|
| Client tool result injection | addToolResult({ toolCallId, result }) |
Result injection API exposed by the hook (verify name and signature) |
| Loop limit expression | maxSteps: number |
stopWhen-style options that describe step count or termination conditions |
| Auto-resend condition | Handled implicitly inside the hook | Option to explicitly declare resend conditions |
| Message parts structure | parts array introduced in 4.2 |
Continues parts-based structure with expanded part types |
In v4.x, the implicit behavior of "resend once all tool results are filled" was hidden inside the hook. In v5, this becomes an explicitly declared option. This is also the biggest friction point when migrating 4.x code to 5.x.
Closing Thoughts
The useChat multi-step loop is ultimately a single pattern: the LLM issues a tool call directive → the client or server executes it → the result-enriched context is passed to the next LLM call. useChat simply automates this repetition on your behalf. But you need to precisely understand the boundary of that automation — the loop that runs inside the server vs. the loop that requires a client round-trip — to catch unexpected round-trips or premature termination issues.
Things worth checking next time you translate this into code:
- Log how many times on average a given model calls tools per response, and set
maxStepsbased on data, not intuition. - For conversations beyond a certain length, add a context compression strategy that summarizes or truncates previous tool results. It's easy to forget that auto-resend sends the entire history every time.
- Move any tools that can be server-side to the server. This reduces round-trips and eliminates the risk of exposing auth credentials or secret keys.
- For tools requiring approval, establish the pattern of calling the hook once at the top level and passing state down before your component tree grows. Fixing it after components multiply is much more painful.
References
- AI SDK UI: Chatbot Tool Usage — Official Docs
- AI SDK UI: useChat API Reference (v4)
- AI SDK Core: Tools and Tool Calling
- Tool Approvals - Agents
- Migration Guide: AI SDK 4.1 → 4.2 (introduction of message parts)
- AI SDK 4.2 Release Blog
- AI SDK 5 Release Blog
- GitHub: Per-tool maxSteps support request Discussion #3815
- GitHub: stopWhen and useChat resend conflict Issue #7502
- GitHub: client-side tool calling bug Issue #4283
- Next.js: Call Tools — Official Cookbook