Middleware runs around every chat() call. Use it to log a run, count tokens, or block a tool, without copying that code into each route.
Write it once as middleware. A middleware is an object with hooks. chat() calls each hook at a fixed step of the run.
// logger.ts
import { type ChatMiddleware } from "@tanstack/ai";
export const logger: ChatMiddleware = {
name: "logger",
onStart: (ctx) => {
console.log(`[${ctx.requestId}] started with ${ctx.model}`);
},
onAfterToolCall: (ctx, info) => {
console.log(`[${ctx.requestId}] ${info.toolName} took ${info.duration} ms`);
},
onFinish: (ctx, info) => {
console.log(
`[${ctx.requestId}] done in ${info.duration} ms, ${info.usage?.totalTokens ?? 0} tokens`,
);
},
};// api/chat.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { logger } from "./logger";
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,
middleware: [logger],
});
return toServerSentEventsResponse(stream);
}Send a message from your chat UI. The server console shows one line when the run starts, one line for each tool call, and one line when the run finishes:
[chat-1760000000000-k3j9x2a] started with gpt-6.1-sol
[chat-1760000000000-k3j9x2a] done in 1840 ms, 312 tokensThe client code does not change. Middleware runs on the server only.
Tip
Do you only want to see the chunks during development? Set debug: { middleware: true } on chat(). You do not need your own middleware for that. See Debug Logging.
Every hook is optional. Add only the hooks you need. The ones you use most:
| Hook | When it runs | What it can do |
|---|---|---|
| onConfig | At the start, and before each model call | Change messages, system prompts, tools, model options |
| onStart | Once, after the first onConfig | Start timers, write a log line |
| onChunk | For each chunk of the stream | Change, split, or drop the chunk |
| onBeforeToolCall | Before a tool runs | Change the input, skip the tool, or stop the run |
| onAfterToolCall | After a tool runs | Read the result, or replace it |
| onUsage | When a model call reports token usage | Count tokens and cost |
| onFinish, onAbort, onError | Once, at the end | Exactly one of the three runs |
More hooks exist for the agent loop, model errors, and interrupts. See Middleware: Advanced.
The middleware run in array order:
Each hook gets a context object (ctx) as its first argument. It has the IDs of the run, the current phase and iteration, and helpers like ctx.abort() and ctx.defer().
Your agent hits its last model call, and the model calls a tool again. The run ends after the tool result, and the user gets no answer. Return toolChoice: 'none' on that last call, and the model answers in text:
import {
chat,
maxIterations,
toolDefinition,
type ChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const MAX_CALLS = 10;
const search = toolDefinition({
name: "search",
description: "Search the docs",
inputSchema: z.object({ query: z.string() }),
}).server(async ({ query }) => ({ hits: [`A page about ${query}`] }));
const answerOnLastCall: ChatMiddleware = {
name: "answer-on-last-call",
onConfig: (ctx) => {
if (ctx.phase === "beforeModel" && ctx.iteration === MAX_CALLS - 1) {
return { toolChoice: "none" };
}
},
};
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "How do I add a tool?" }],
threadId: "thread-1",
tools: [search],
agentLoopStrategy: maxIterations(MAX_CALLS),
middleware: [answerOnLastCall],
});Your proxy wants a header on each request. Or you want to log each model call. You do not need your own client for that. Return wrapFetch from onConfig:
import { type ChatMiddleware } from "@tanstack/ai";
const traceHeader: ChatMiddleware = {
name: "trace-header",
onConfig: (ctx) => {
if (ctx.phase !== "beforeModel") return;
return {
wrapFetch: (next) => (input, init) => {
const headers = new Headers(init?.headers);
headers.set("x-trace-id", `${ctx.requestId}-${ctx.iteration}`);
return next(input, { ...init, headers });
},
};
},
};Do you want the same wrapper on every call of the run? Pass it to chat():
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hi" }],
threadId: "thread-1",
wrapFetch: (next) => async (input, init) => {
const started = Date.now();
const response = await next(input, init);
console.log(`model call: ${response.status} in ${Date.now() - started} ms`);
return response;
},
});A slow tool, like a build or a test run, can pause your agent for more than 5 minutes. The short Claude cache is gone by then. Return promptCache from onConfig to keep the cache longer from the next call on:
import { type ChatMiddleware } from "@tanstack/ai";
const longCacheAfterTools: ChatMiddleware = {
name: "long-cache-after-tools",
onConfig: (ctx, config) => {
if (ctx.phase !== "beforeModel" || ctx.iteration === 0) return;
return { promptCache: { ...config.promptCache, retention: "long" } };
},
};Some tools must never run from a chat. Return an abort decision from onBeforeToolCall, and the run stops before the tool runs:
import { type ChatMiddleware } from "@tanstack/ai";
const guard: ChatMiddleware = {
name: "guard",
onBeforeToolCall: (ctx, hookCtx) => {
if (hookCtx.toolName === "deleteDatabase") {
return { type: "abort", reason: "Dangerous operation blocked" };
}
},
};To change the input or skip the tool with your own result, see onBeforeToolCall.
You do not have to write the common cases yourself:
See Built-in Middleware. To keep long chats under the context limit, see Compaction.
Every hook in detail, the context object, capabilities, recipes, and the types are in Middleware: Advanced.