The agent loop calls the model again after each tool result. One call cannot finish a task that needs a tool first.
chat() runs this loop for you. The model calls a tool, chat() runs it and gives the result back, and the model continues until it can answer.
// tools.ts
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
export const getWeather = toolDefinition({
name: "get_weather",
description: "Get the current weather for a city",
inputSchema: z.object({
city: z.string(),
}),
}).server(async ({ city }) => {
// Replace with a call to your weather API.
return { city, temperature: 62, conditions: "cloudy" };
});
export const getClothingAdvice = toolDefinition({
name: "get_clothing_advice",
description: "Get clothing advice for the weather",
inputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
}),
}).server(async ({ temperature }) => {
if (temperature < 65) {
return { recommendation: "Wear a light jacket" };
}
return { recommendation: "Light clothing is fine" };
});// api/chat.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getClothingAdvice, getWeather } from "./tools";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
tools: [getWeather, getClothingAdvice],
});
return toServerSentEventsResponse(stream);
}The tool calls arrive as tool-call parts in the same message as the text:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
export function Chat() {
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
});
return (
<>
<button
type="button"
onClick={() => sendMessage("What should I wear in San Francisco today?")}
>
Ask
</button>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) => {
if (part.type === "tool-call") {
return <p key={index}>Called {part.name}</p>;
}
return part.type === "text" ? <p key={index}>{part.content}</p> : null;
})}
</div>
))}
</>
);
}Click "Ask". You see two tool calls, then an answer that uses both results.
For the question above, the loop runs three model turns:
sequenceDiagram
participant Client
participant Server
participant LLM
participant Tools
Client->>Server: Send message
Server->>LLM: Message + tool definitions
LLM->>Server: tool_call get_weather
Server->>Tools: Run get_weather
Tools-->>Server: Result
Server->>LLM: tool_result
LLM->>Server: tool_call get_clothing_advice
Server->>Tools: Run get_clothing_advice
Tools-->>Server: Result
Server->>LLM: tool_result
LLM-->>Server: Final text
Server-->>Client: Stream the answerThe loop continues only while both of these are true:
The loop ends when the model stops with the finish reason stop. The full loop is one run, so the client sees one stream.
Related pages:
Tip
Code Mode can make the loop shorter. The model writes one program that calls many tools in one run. See Code Mode.
By default, the loop stops after five model turns (maxIterations(5)), even if the model calls more tools. It also stops after an answer that hits the output limit. The full default is combineStrategies([maxIterations(5), untilFinishReason(["length"])]).
Set agentLoopStrategy to change it:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
combineStrategies,
maxIterations,
toServerSentEventsResponse,
} from "@tanstack/ai";
import type { AgentLoopState } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getClothingAdvice, getWeather } from "./tools";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
tools: [getWeather, getClothingAdvice],
agentLoopStrategy: combineStrategies([
maxIterations(10),
({ messages }: AgentLoopState) => messages.length < 100,
]),
});
return toServerSentEventsResponse(stream);
}The built-in strategies:
A strategy is a function. It gets the loop state and returns true to allow one more turn, or false to stop. The state has these fields:
A model can run out of output tokens in the middle of a tool call, for example while it writes a long file. The provider stops with the finish reason length, and the input of the call is not complete.
chat() never runs such a call:
By default, the run then ends with the finish reason length. To let the model send the cut call again, use a strategy that does not stop on length:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
maxIterations,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getClothingAdvice, getWeather } from "./tools";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
tools: [getWeather, getClothingAdvice],
agentLoopStrategy: maxIterations(5),
});
return toServerSentEventsResponse(stream);
}Then this happens after a cut answer:
The client needs no change. When the loop continues, the RUN_FINISHED event of the cut answer has the finish reason tool_calls. So useChat keeps the stream open for the next model turn.
The default error text tells the model to send the calls again and to split a large input. To use your own text, return a new result from the onAfterToolCall middleware hook. The error of a cut call has the code max_tokens:
import { defineChatMiddleware } from "@tanstack/ai";
export const cutToolCallText = defineChatMiddleware({
onAfterToolCall(_ctx, info) {
const cut =
info.error instanceof Error &&
"code" in info.error &&
info.error.code === "max_tokens";
if (!cut) return;
return {
type: "replaceResult",
result: `Your call to ${info.toolName} was cut off. Write the file in parts of 200 lines or less.`,
};
},
});Add it with middleware: [cutToolCallText] next to agentLoopStrategy.
On the server, each assistant message that chat() stores keeps metadata.tanstack.finishReason. A cut answer has length. If a transcript has a cut call with no result, chat() does not run that call.
maxIterations limits model turns, not tool calls. One model turn can emit many tool calls in parallel. A strategy runs between turns, so it cannot stop one turn that calls too many tools.
TanStack AI has no built-in maxToolCalls strategy. Use middleware to limit tool calls:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
maxIterations,
toServerSentEventsResponse,
type ChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getClothingAdvice, getWeather } from "./tools";
// Your own policy. This is not a library export.
function toolCallBudget(options: {
max?: number;
maxPerTurn?: number;
}): ChatMiddleware {
const { max, maxPerTurn } = options;
let perTurn = 0;
return {
name: "tool-call-budget",
onIteration() {
perTurn = 0;
},
// A pending or resume batch has no onIteration, so reset here too.
onToolPhaseComplete() {
perTurn = 0;
},
onBeforeToolCall() {
if (maxPerTurn == null) return undefined;
perTurn += 1;
if (perTurn > maxPerTurn) {
return {
type: "skip",
result: {
error: `Skipped: exceeded maxToolCallsPerTurn (${maxPerTurn})`,
},
};
}
return undefined;
},
onShouldContinue(_ctx, state) {
if (max != null && state.toolCallCount >= max) return false;
return undefined;
},
};
}
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
tools: [getWeather, getClothingAdvice],
agentLoopStrategy: maxIterations(20), // model turns
middleware: [
toolCallBudget({
maxPerTurn: 10, // limit parallel calls inside one turn
max: 20, // stop more turns after 20 emitted tool calls in total
}),
],
});
return toServerSentEventsResponse(stream);
}Put this middleware before toolCacheMiddleware. Then a skip for a call over the budget wins over a cache hit. See onShouldContinue for the hook contract.