This page covers each middleware hook in detail. Set up your first middleware with Middleware.
Each chat() call goes through the same steps. The hooks fire at these steps:
graph TD
A["chat() called"] --> B["onConfig (phase: init)"]
B --> C[onStart]
C --> D["onConfig (phase: beforeModel)"]
D --> E["Adapter streams response"]
E --> F["onChunk (for each chunk)"]
F --> G{Tool calls?}
G -->|No| H[onUsage]
G -->|Yes| I[onBeforeToolCall]
I --> J[Tool executes]
J --> K[onAfterToolCall]
K --> L{Continue loop?}
L -->|Yes| D
L -->|No| H
H --> SO{"Structured output path?"}
SO -->|None| M{Outcome}
SO -->|"Native combined"| SOH["Post-loop structured-output harvest (onChunk)"]
SOH --> M
SO -->|"Separate finalization"| SOC[onStructuredOutputConfig]
SOC --> SOM["onConfig (phase: structuredOutput)"]
SOM --> SOS["Structured-output finalization (onChunk, onUsage)"]
SOS --> M
M -->|Success| N[onFinish]
M -->|Abort| O[onAbort]
M -->|Error| P[onError]
style I fill:#e1f5ff
style J fill:#ffe1e1
style SOC fill:#e1f5ff
style SOM fill:#e1f5ff
style SOS fill:#e1f5ff
style SOH fill:#e1f5ff
style N fill:#e1ffe1
style O fill:#fff4e1
style P fill:#ffe1e1The phase field of the context tells you where the run is:
| Phase | When | Hooks called |
|---|---|---|
| init | Once at startup | onConfig |
| beforeModel | Before each model call (one time per iteration) | onConfig |
| modelStream | While the adapter streams chunks | onChunk, onUsage |
| beforeTools | Before the tools run | onBeforeToolCall |
| afterTools | After the tools run | onAfterToolCall |
| structuredOutput | During the separate structured-output call (see below) | onStructuredOutputConfig, onConfig, onChunk, onUsage |
When you call chat() with outputSchema, the adapter takes one of two paths.
onConfig runs once in the init phase, and once per iteration in the beforeModel phase. Use it to change the config that the model gets.
On the separate-finalization path, onConfig runs one more time at the structured-output step, with ctx.phase === 'structuredOutput'. It gets the config after onStructuredOutputConfig. So a separate-finalization run with one iteration calls onConfig three times (init, beforeModel, structuredOutput). Native-combined output does not add this third call.
Return a partial config with only the fields you want to change. The engine merges it into the current config (a shallow merge). You do not have to spread the existing config.
import { type ChatMiddleware } from "@tanstack/ai";
const dynamicTemperature: ChatMiddleware = {
name: "dynamic-temperature",
onConfig: (ctx, config) => {
if (ctx.phase === "init") {
// Add a system prompt at startup. Only systemPrompts changes.
return {
systemPrompts: [
...config.systemPrompts,
"You are a helpful assistant.",
],
};
}
if (ctx.phase === "beforeModel" && ctx.iteration > 0) {
// Increase the temperature on later calls. Sampling params live in
// the provider-native modelOptions object. `temperature` has the same
// key on all providers. Spread modelOptions to keep the other options.
const current =
typeof config.modelOptions?.temperature === "number"
? config.modelOptions.temperature
: 0.7;
return {
modelOptions: {
...config.modelOptions,
temperature: Math.min(current + 0.1, 1.0),
},
};
}
},
};Note
Sampling parameters (temperature, top_p / topP, the max*Tokens keys) live in modelOptions, under the native name of each provider. temperature has the same name on all providers. A token limit does not. For example, OpenAI uses max_output_tokens, and Ollama uses num_predict inside modelOptions.options.
Config fields you can change:
| Field | Type | Description |
|---|---|---|
| messages | ModelMessage[] | The canonical conversation history. Persistence and ctx.messages use this field. |
| providerMessages | ModelMessage[] | Temporary context sent to the provider. Defaults to messages. |
| systemPrompts | SystemPrompt[] | System prompts |
| tools | Tool[] | The tools the model can call |
| metadata | Record<string, unknown> | Request metadata |
| modelOptions | Record<string, unknown> | Provider-native options, including the sampling params |
| reasoning | ReasoningRequest | undefined | How hard the model thinks at this call |
| toolChoice | ToolChoice | undefined | How the model uses the tools at the next model call. See Change the tool choice of a call. |
| wrapFetch | FetchWrapper | undefined | Wraps the HTTP fetch of the next model call. See Change the HTTP requests of a call. |
| promptCache | ResolvedPromptCache | undefined | The prompt cache of the next model call: its retention and key. See Change the prompt cache of a call. |
When more than one middleware has onConfig, the config is piped through them in order. Each one gets the merged config from the one before it.
If a change must affect only the model call, return providerMessages. Returning messages also changes the provider input, unless the same result sets providerMessages.
This hook runs once, at the start of the separate structured-output call. It runs only when chat() has outputSchema and supportsCombinedToolsAndSchema() does not return true for the current model and options.
It pipes through the middleware in order, like onConfig. It also gets the JSON Schema that goes to the provider. Use it to:
Note
Native-combined adapters (modern OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x, see issue #605) make no separate call, so this hook never runs. The engine sends the converted schema to chatStream after onConfig. Middleware cannot change the schema on that path.
Return a partial StructuredOutputMiddlewareConfig with the fields you want to change. The engine merges it (a shallow merge). Return nothing to pass the config through.
import { type ChatMiddleware } from "@tanstack/ai";
import { sharedDefs } from "./defs";
const injectDefs: ChatMiddleware = {
name: "inject-defs",
onStructuredOutputConfig: (_ctx, config) => {
// `config.outputSchema` is the JSON Schema that goes to the provider
return {
outputSchema: {
...config.outputSchema,
$defs: { ...sharedDefs },
},
};
},
};Config fields you can change:
| Field | Type | Description |
|---|---|---|
| messages | ModelMessage[] | The canonical conversation history |
| providerMessages | ModelMessage[] | Temporary context sent to the final call |
| systemPrompts | SystemPrompt[] | System prompts on the final call |
| metadata | Record<string, unknown> | Request metadata |
| modelOptions | Record<string, unknown> | Provider-native options, including the sampling params |
| outputSchema | JSONSchema | The JSON Schema that goes to the provider |
The order at the structured-output step:
Use onConfig for changes that apply to every adapter call. If you need the schema, use onStructuredOutputConfig.
onStart runs once, after the first onConfig. Use it to start timers or write a log line.
import { type ChatMiddleware } from "@tanstack/ai";
const timer: ChatMiddleware = {
name: "timer",
onStart: (ctx) => {
console.log(`Request ${ctx.requestId} started at iteration ${ctx.iteration}`);
},
};onIteration runs at the start of each agent-loop iteration, after the engine creates the new assistant message ID. It gets { iteration, messageId }. Use it to see the iteration boundaries. It does not run for the separate structured-output call.
onChunk runs for each chunk from the adapter. It can read, change, split, or drop the chunk.
import { type ChatMiddleware } from "@tanstack/ai";
const redactor: ChatMiddleware = {
name: "redactor",
onChunk: (ctx, chunk) => {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
// Redact numbers that look like an SSN
return {
...chunk,
delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
};
}
// Return nothing to pass the chunk through
},
};Return values:
| Return | Effect |
|---|---|
| void / undefined | The chunk passes through unchanged |
| StreamChunk | Replaces the chunk |
| StreamChunk[] | Splits it into more chunks |
| null | Drops the chunk |
When more than one middleware has onChunk, chunks flow through them in order. If one middleware drops a chunk, the middleware after it never see it.
onChunk gets every AG-UI event of the run, not only text. Narrow on chunk.type (a discriminated union) before you read the fields of a type. The common types:
| chunk.type | Meaning | Key fields |
|---|---|---|
| RUN_STARTED / RUN_FINISHED / RUN_ERROR | Start and end of the run | runId, finishReason, usage (on finish), message (on error) |
| TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END | Assistant text | messageId, delta (content) |
| TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END | Tool calls | toolCallId, toolCallName, delta (args), result on end |
| REASONING_START / REASONING_MESSAGE_CONTENT / REASONING_END | Thinking or reasoning | messageId, delta. The signature comes on REASONING_ENCRYPTED_VALUE |
| STATE_SNAPSHOT / STATE_DELTA | Agent state sync | snapshot, delta |
| CUSTOM | Extension events, including structured output (see below) | name, value |
For the full list of events and their fields, see the AG-UI protocol docs.
Structured-output chunks go through the same onChunk hook as all other chunks. There is no separate chunk hook. When chat() has outputSchema, these chunks flow through onChunk:
How you find them depends on the path of the adapter:
import { type ChatMiddleware } from "@tanstack/ai";
const redactStructuredOutput: ChatMiddleware = {
name: "redact-structured-output",
onChunk: (ctx, chunk) => {
// Separate-finalization path: the JSON streams as TEXT_MESSAGE_CONTENT
// in the 'structuredOutput' phase. Change the delta like any other text
// chunk. Here, redact numbers that look like an SSN.
if (
ctx.phase === "structuredOutput" &&
chunk.type === "TEXT_MESSAGE_CONTENT"
) {
return {
...chunk,
delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
};
}
// Both paths: the complete typed payload comes as a CUSTOM
// `structured-output.complete` event. On the native-combined path this
// is the only signal (ctx.phase never becomes 'structuredOutput'), so
// check the event name, not the phase. `chunk.value` has { object, raw }.
if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
console.log("final structured output:", chunk.value);
}
// Return nothing to pass all other chunks through.
},
};The config hooks are different because the config at the structured-output step has an outputSchema field that ChatMiddlewareConfig does not have (see onStructuredOutputConfig). Chunks are always StreamChunk, in every phase. So one onChunk plus ctx.phase (or the CUSTOM event name) covers all cases.
onShouldContinue runs when the engine decides if it starts another agent-loop iteration (after a tool phase, or between model turns). It gets the same AgentLoopState as the loop strategies (iterationCount, toolCallCount, lastTurnToolCallCount, and more).
This hook does not abort the run. The stream finishes normally with the current messages. Use ctx.abort() only for a hard stop.
import { type ChatMiddleware } from "@tanstack/ai";
const budget: ChatMiddleware = {
name: "tool-budget",
onShouldContinue: (_ctx, state) => {
// Stop more turns after the model makes 20 tool calls
if (state.toolCallCount >= 20) return false;
},
};For a full budget per turn and per run, see Tool-call budgets.
Some model calls fail for a reason that your middleware can fix. For example, the prompt is too long for the model. onModelError gets the RUN_ERROR before anyone else sees it. Fix the request and call ctx.retry(). While the call has retries left, the run goes on as if the call never failed.
Say your app sends retrieved documents as a second system prompt. When the call is too long, this middleware drops them and tries again:
import { CONTEXT_OVERFLOW_CODE } from "@tanstack/ai";
import type { ChatMiddleware, ChatMiddlewareContext } from "@tanstack/ai";
// The runs that go on without the documents.
const withoutDocs = new WeakSet<ChatMiddlewareContext>();
const dropDocsWhenTooLong: ChatMiddleware = {
name: "drop-docs-when-too-long",
onModelError: (ctx, error) => {
if (error.code !== CONTEXT_OVERFLOW_CODE) return;
withoutDocs.add(ctx);
ctx.retry();
},
onConfig: (ctx, config) => {
if (ctx.phase !== "beforeModel" || !withoutDocs.has(ctx)) return;
// Keep the main system prompt. Drop the documents after it.
return { systemPrompts: config.systemPrompts.slice(0, 1) };
},
};What ctx.retry() does while the call has retries left:
When the hook runs:
withCompaction uses this hook to compact and retry after a context overflow.
Use these hooks when middleware needs data from the client. Define the request with defineInterrupt(). Register it with chat({ interrupts }) and useChat({ interrupts }). Do not send raw AG-UI events from middleware.
onInterruptBoundary runs at four points in an agent iteration:
Each middleware can return requests from one boundary. The engine puts all requests from that boundary into one AG-UI interrupt batch. The batch ends the run with one interrupt outcome.
This hook cannot change the config. It can return only { interrupts } or nothing. The continuation is a new chat() call, so the hook runs again. If ctx.parentRunId is set and the pause belongs to the first request only, return nothing.
For what ctx has at each phase, and when to use each phase, see Lifecycle Boundaries.
Create one shared definition. The server and the client both import this value, so the definition ID and the response shape are the same on both sides.
import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
import { z } from 'zod'
export const reviewPlan = defineInterrupt({
id: 'review-plan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.object({ approved: z.boolean() }),
})
export const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'review-plan',
onInterruptBoundary(ctx) {
if (ctx.phase !== 'beforeTools') return
if (ctx.parentRunId) return
return {
interrupts: [
reviewPlan.interrupt({
key: 'release-plan',
reason: 'review-required',
message: 'Approve this plan?',
payload: { title: 'Release plan' },
}),
],
}
},
onInterruptResolution(_ctx, resumedInterrupts) {
for (const result of resumedInterrupts.for(reviewPlan)) {
if (result.status === 'resolved' && !result.response.approved) {
return { toolResume: 'stop' }
}
}
},
}Register the definition on the server. Forward parentRunId and resume, so that a client answer starts the continuation with its full context.
import {
chat,
chatParamsFromRequestBody,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { reviewMiddleware, reviewPlan } from './review-plan'
export async function POST(request: Request) {
const params = await chatParamsFromRequestBody(await request.json())
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
interrupts: [reviewPlan],
middleware: [reviewMiddleware],
})
return toServerSentEventsResponse(stream)
}Register the same definition on the client. Check kind and definitionId. TypeScript then treats the item as GenericInterrupt<typeof reviewPlan>. resolveInterrupt uses the response shape from reviewPlan.responseSchema.
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import type { GenericInterrupt } from '@tanstack/ai-react'
import { reviewPlan } from './review-plan'
function ReviewCard({
interrupt,
}: {
interrupt: GenericInterrupt<typeof reviewPlan>
}) {
return (
<button
onClick={() => interrupt.resolveInterrupt({ approved: true })}
>
Approve plan
</button>
)
}
export function ReviewPlanPanel() {
const { interrupts, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
interrupts: [reviewPlan],
})
return (
<>
<button onClick={() => sendMessage('Review the release plan')}>
Start review
</button>
{interrupts.map((interrupt) => {
if (interrupt.kind !== 'generic') return null
if (!('definitionId' in interrupt)) return null
if (interrupt.definitionId !== reviewPlan.id) return null
return <ReviewCard key={interrupt.id} interrupt={interrupt} />
})}
</>
)
}onInterruptResolution does not run in the chat() call that paused. It runs once at the start of the next chat() call, after the client answers:
setup
onConfig (phase is init)
onInterruptResolution (phase is still init)
onStart
then stop, or continue the agent loopuseChat sends parentRunId and resume on that second request. Each generic resume item has the original request in metadata. If resume is present and parentRunId is missing, the server throws.
To read the answers:
The hook can return toolResume: 'continue', 'cancel', or 'stop'. The results from all middleware combine by the most restrictive rule: stop wins over cancel, and cancel wins over continue.
This hook cannot change prompts, tools, or messages. Store the answer on a capability. Then return those fields from onConfig when ctx.phase === 'beforeModel'.
| Hook | Can change |
|---|---|
| onInterruptBoundary | Nothing. It can only pause. |
| onInterruptResolution | Pending-tool policy (toolResume) |
| onConfig | messages, systemPrompts, tools, modelOptions, metadata, toolChoice |
The full resume order, and an example that writes a user note into the system prompt, is in Apply Answers.
onBeforeToolCall runs before a server tool runs, and before a client tool goes to the client. The first middleware that returns a decision skips the other middleware for that call.
Each tool call goes through these steps:
The schema checks the input after your hook:
When the hook runs:
import { type ChatMiddleware } from "@tanstack/ai";
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
}
const guard: ChatMiddleware = {
name: "guard",
onBeforeToolCall: (ctx, hookCtx) => {
// Block dangerous tools
if (hookCtx.toolName === "deleteDatabase") {
return { type: "abort", reason: "Dangerous operation blocked" };
}
// Add a default argument
if (hookCtx.toolName === "search" && isRecord(hookCtx.args) && !hookCtx.args.limit) {
return {
type: "transformArgs",
args: { ...hookCtx.args, limit: 10 },
};
}
},
};Decisions:
| Decision | Effect |
|---|---|
| void / undefined | Go on as usual. The next middleware can decide. |
| { type: 'transformArgs', args } | Replace the tool arguments before the tool runs |
| { type: 'skip', result } | Do not run the tool. Use result instead. |
| { type: 'abort', reason? } | Abort the whole chat run |
The hookCtx fields:
| Field | Type | Description |
|---|---|---|
| toolCall | ToolCall | The raw tool call object |
| tool | Tool | undefined | The resolved tool definition |
| args | unknown | The parsed arguments |
| toolName | string | The tool name |
| toolCallId | string | The tool call ID |
A permission check in onBeforeToolCall can miss calls. An earlier middleware can return a decision first, and then your hook does not run. Put checks that must always run in onGuardToolCall:
import { type ChatMiddleware } from "@tanstack/ai";
const noDeletes: ChatMiddleware = {
name: "no-deletes",
onGuardToolCall: (ctx, hookCtx) => {
if (hookCtx.toolName === "deleteFile") {
return { type: "skip", result: { error: "Deleting files is not allowed." } };
}
},
};When the guard runs:
| Decision | Effect |
|---|---|
| void / undefined | Allow the call. The next guard runs. |
| { type: 'skip', result } | Deny the call. The tool does not run, and the model gets result, the same as an onBeforeToolCall skip. |
| { type: 'abort', reason? } | Abort the entire chat run |
A guard cannot change the arguments. To change them, use onBeforeToolCall.
onAfterToolCall runs after each tool runs (or is skipped). Every middleware runs, in array order.
import { type ChatMiddleware } from "@tanstack/ai";
const toolLogger: ChatMiddleware = {
name: "tool-logger",
onAfterToolCall: (ctx, info) => {
if (info.ok) {
console.log(`${info.toolName} completed in ${info.duration}ms`);
} else {
console.error(`${info.toolName} failed:`, info.error);
}
},
};The info fields:
| Field | Type | Description |
|---|---|---|
| toolCall | ToolCall | The raw tool call object |
| tool | Tool | undefined | The resolved tool definition |
| toolName | string | The tool name |
| toolCallId | string | The tool call ID |
| ok | boolean | true when the tool succeeded |
| duration | number | Run time in milliseconds |
| result | unknown | The result (when ok is true). After a replaceResult from an earlier middleware, the new result. |
| error | unknown | The error (when ok is false) |
Some tools return much more than the model needs: 500 rows, a huge log, a secret you want to hide. The model reads every token of it. Return { type: 'replaceResult', result }, and the model gets your version:
import { type ChatMiddleware } from "@tanstack/ai";
const firstTwentyRows: ChatMiddleware = {
name: "first-twenty-rows",
onAfterToolCall: (ctx, info) => {
if (!info.ok || !Array.isArray(info.result)) return;
if (info.result.length <= 20) return;
return {
type: "replaceResult",
result: {
rows: info.result.slice(0, 20),
omitted: info.result.length - 20,
},
};
},
};| Return | What happens |
|---|---|
| nothing | The result stays the same. |
| { type: 'replaceResult', result } | The model gets result. The stream sends it in TOOL_CALL_RESULT, so the client shows it too. |
Good to know:
When the model calls more than one server tool in one turn, the tools run at the same time:
With toolExecution: 'sequential', each call finishes its onBeforeToolCall, its run, and its onAfterToolCall before the next call starts. See Run tools one at a time.
onToolPhaseComplete runs once after all tool calls of an iteration are processed. Its info has:
onUsage runs once per model call when the RUN_FINISHED chunk has usage data. It gets the usage object.
import { type ChatMiddleware } from "@tanstack/ai";
const usageTracker: ChatMiddleware = {
name: "usage-tracker",
onUsage: (ctx, usage) => {
console.log(
`Iteration ${ctx.iteration}: ${usage.totalTokens} tokens`
);
},
};The main usage fields:
| Field | Type | Description |
|---|---|---|
| promptTokens | number | Input tokens |
| completionTokens | number | Output tokens |
| totalTokens | number | Total tokens |
| cost | number | undefined | The cost that the provider reports, when it reports one |
The object can also have promptTokensDetails, completionTokensDetails, costDetails, and providerUsageDetails.
Exactly one terminal hook runs per chat() call:
| Hook | When it runs |
|---|---|
| onFinish | The run completed normally |
| onAbort | The run was aborted: by ctx.abort(), by an external AbortSignal, or by an abort decision from onBeforeToolCall |
| onError | An error was not handled |
onFinish also runs when the call pauses for a client tool or an approval. The run is not complete yet. To tell a pause from a completion, look in onChunk for a RUN_FINISHED chunk with outcome.type === 'interrupt'.
onAbort gets info.cancelRequested. It is true only when an explicit cancel aborted the run, never when the client only disconnected.
import { type ChatMiddleware } from "@tanstack/ai";
const terminal: ChatMiddleware = {
name: "terminal",
onFinish: (ctx, info) => {
console.log(`Finished: ${info.finishReason}, ${info.duration}ms`);
console.log(`Content: ${info.content}`);
if (info.usage) {
console.log(`Tokens: ${info.usage.totalTokens}`);
}
},
onAbort: (ctx, info) => {
console.log(`Aborted: ${info.reason}, ${info.duration}ms`);
},
onError: (ctx, info) => {
console.error(`Error after ${info.duration}ms:`, info.error);
},
};The info fields for onFinish (FinishInfo):
| Field | Type | Description |
|---|---|---|
| finishReason | string | null | The last finishReason of the agent loop. null when no agent-loop iteration sent RUN_FINISHED (for example, a chat({ outputSchema }) run with no tools). |
| duration | number | The total run time in milliseconds, including any structured-output call. |
| content | string | The text of the agent loop. Includes native-combined structured JSON. Excludes separate-finalization JSON. |
| usage | TokenUsage | undefined | Optional. The last RUN_FINISHED.usage of the agent loop. Does not include the tokens of the separate structured-output call. Always guard with if (info.usage) or info.usage?.. |
To count the usage of the whole run, add up the values from onUsage. Do not use info.usage for that.
On the separate-finalization path, the adapter makes one more provider call after the agent loop:
On the native-combined path, the JSON comes in the normal agent-loop stream:
On both paths, a successful onFinish gets the complete canonical transcript in ctx.messages:
This transcript is separate from the fields on info.
Each hook gets a ChatMiddlewareContext as its first argument. The fields you use most:
| Field | Type | Description |
|---|---|---|
| requestId | string | A unique ID for this chat request |
| streamId | string | A unique ID for this stream |
| runId | string | The AG-UI run ID |
| threadId | string | The AG-UI thread ID: the threadId that you pass to chat(). Use it to correlate events. |
| parentRunId | string | undefined | The paused run that this continuation belongs to |
| provider, model | string | The provider name and the model ID |
| phase | ChatMiddlewarePhase | The current lifecycle phase |
| iteration | number | The agent-loop iteration (starts at 0) |
| chunkIndex | number | How many chunks the run sent so far |
| callMetadata | Readonly<TanStackMessageMetadata> | The metadata.tanstack of the current model call (source, stopReason, responseId, and more). It changes as chunks arrive. Each assistant message of the call gets a copy. |
| messages | ReadonlyArray<ModelMessage> | The current messages (read-only) |
| signal | AbortSignal | undefined | The external abort signal |
| abort(reason?) | function | Abort the run from middleware |
| retry() | function | Call the model again. Works only in onModelError. |
| emitCustomEvent(name, value, options?) | function | Push a CUSTOM chunk onto the stream now. See Live custom events. |
| context | TContext | The runtime context that you pass to chat() |
| defer(promise) | function | Register a side effect that does not block the stream |
ChatMiddleware takes a context generic. Middleware that you declare outside chat() then reads the same typed runtime context as your tools.
import { chat, type ChatMiddleware } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { session, audit } from "./server";
type AppContext = {
userId: string;
audit: {
write(event: { userId: string; requestId: string }): Promise<void>;
};
};
export const auditMiddleware: ChatMiddleware<AppContext> = {
name: "audit",
onStart(ctx) {
ctx.defer(
ctx.context.audit.write({
userId: ctx.context.userId,
requestId: ctx.requestId,
})
);
},
};
chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello" }],
threadId: "thread-1",
middleware: [auditMiddleware],
context: {
userId: session.user.id,
audit,
},
});See Runtime Context for the server, the client, and how to send values from the client to the server.
Call ctx.abort() to stop the run. The onAbort terminal hook then runs:
import { type ChatMiddleware } from "@tanstack/ai";
const timeout: ChatMiddleware = {
name: "timeout",
onChunk: (ctx) => {
if (ctx.chunkIndex > 1000) {
ctx.abort("Too many chunks");
}
},
};Use ctx.defer() to register promises that do not block the stream. The engine awaits them after the terminal hook:
import { type ChatMiddleware } from "@tanstack/ai";
const analytics: ChatMiddleware = {
name: "analytics",
onFinish: (ctx, info) => {
ctx.defer(
fetch("/api/analytics", {
method: "POST",
body: JSON.stringify({
requestId: ctx.requestId,
duration: info.duration,
tokens: info.usage?.totalTokens,
}),
})
);
},
};Middleware runs in array order. The order matters for hooks that pipe or stop early:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { authMiddleware, loggingMiddleware, cachingMiddleware } from "./middleware";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello" }],
threadId: "thread-1",
middleware: [authMiddleware, loggingMiddleware, cachingMiddleware],
});| Hook | Composition | Effect of the order |
|---|---|---|
| onConfig | Piped. Each gets the output of the one before it. | The earlier middleware changes the config first. |
| onStructuredOutputConfig | Piped. Each gets the output of the one before it. | The earlier middleware changes the config first. |
| onStart | Sequential | All run in order. |
| onChunk | Piped. Chunks flow through each middleware. | If the first one drops a chunk, the later ones never see it. |
| onBeforeToolCall | First wins. The first decision wins. | The earlier middleware has priority. |
| onGuardToolCall | Sequential. Every guard runs until one returns a decision. | An earlier onBeforeToolCall decision never skips a guard. |
| onShouldContinue | AND. One false stops the loop. | The order changes only which middleware runs first. |
| onModelError | Sequential. If any middleware calls ctx.retry() and the call has retries left, the model is called again. | All run in order, so each one sees the error. |
| onAfterToolCall | Piped. Each gets the result of the previous replaceResult. | The last replacement wins. |
| onUsage | Sequential | All run in order. |
| onFinish / onAbort / onError | Sequential | All run in order. |
Middleware often needs to share state. A provider middleware creates something (a database handle, a counter for the request, a sandbox). A consumer middleware reads it later in the same run.
Capabilities make this hand-off type-safe and check the order:
Create a capability with createCapability<TValue>()('name'). It is a curried call:
import { createCapability } from "@tanstack/ai";
const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;You give the value type explicitly (<{ value: number }>). TypeScript infers the name literal from the argument ("counter"). One call cannot do both. If you give T explicitly, TypeScript stops inferring the name and makes it string. Then the compile-time coverage check, which uses the literal name, does not work.
The returned counterCapability has two roles:
The accessors:
| Accessor | Behavior |
|---|---|
| getCounter(ctx) | Returns the value. Throws if no middleware provided the capability. |
| getCounter(ctx, { optional: true }) | Returns TValue | undefined. Does not throw. |
| provideCounter(ctx, value) | Sets the value for this run. Call it from setup. |
The context has the same accessors: ctx.get(capability), ctx.getOptional(capability), and ctx.provide(capability, value). Pass the capability handle. The handle gives the type, so getCounter(ctx) and ctx.get(counterCapability) do the same thing.
Warning
Use a unique capability name across your app. The compile-time check uses the name literal. The runtime check uses the handle reference. Two capabilities with the same name get mixed up in the type check.
You provide capabilities in a setup(ctx) hook. setup runs first, before any onConfig (init), across all middleware in array order. So every capability is in place when the rest of the run starts.
Three array fields declare the capability contract of a middleware. Each one is a ReadonlyArray<CapabilityHandle>. List the capability handles:
| Field | Meaning |
|---|---|
| provides | Capabilities that this middleware sets up. Each one must be provided in setup, or chat() throws after the setup phase. |
| requires | Capabilities that this middleware reads. chat() checks (at compile time and at runtime) that an earlier middleware provides each one. |
| optionalRequires | Capabilities that this middleware uses if present. They never cause a validation error. Read them with getX(ctx, { optional: true }). |
Write middleware with defineChatMiddleware. It gives exact tuple types to requires and provides, so the coverage check and the builder can read them. Here a provider sets up a counter in setup, and a consumer reads it in a hook:
import {
chat,
createCapability,
defineChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;
// Provider: declares `provides` and sets the value in `setup`.
const withCounter = defineChatMiddleware({
name: "with-counter",
provides: [counterCapability],
setup(ctx) {
provideCounter(ctx, { value: 0 });
},
});
// Consumer: declares `requires` and reads the value with `get` in a hook.
const countsChunks = defineChatMiddleware({
name: "counts-chunks",
requires: [counterCapability],
onChunk(ctx) {
getCounter(ctx).value++;
},
onFinish(ctx) {
console.log(`Saw ${getCounter(ctx).value} chunks`);
},
});
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello" }],
threadId: "thread-1",
// The provider must come before the consumer.
middleware: [withCounter, countsChunks],
});If you remove withCounter from the array, TypeScript shows an error at the middleware option. The error names the missing "counter" capability. At runtime, chat() throws before it calls the adapter.
createChatMiddleware() builds the array with chained .use() calls. It checks the provider-before-consumer order at compile time. Each .use() requires that earlier .use() calls provide the requires of the new middleware.
import {
chat,
createCapability,
createChatMiddleware,
defineChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;
const withCounter = defineChatMiddleware({
name: "with-counter",
provides: [counterCapability],
setup(ctx) {
provideCounter(ctx, { value: 0 });
},
});
const countsChunks = defineChatMiddleware({
name: "counts-chunks",
requires: [counterCapability],
onChunk(ctx) {
getCounter(ctx).value++;
},
});
const middleware = createChatMiddleware()
.use(withCounter) // provides "counter"
.use(countsChunks) // requires "counter", which the line above provides
.build();
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello" }],
threadId: "thread-1",
middleware,
});If you swap the two .use() calls (.use(countsChunks).use(withCounter)), the builder rejects the .use(countsChunks) line. The consumer comes before its provider, so "counter" is not in the provided set yet.
The capability system fails early, with a clear error:
A host is a middleware that keeps a session log and provides LogRecordsCapability. chat() does not provide it, so without a host there is no log. With a host, your middleware can write its own records to that log. For example, a note that a run started. Read the writer with getLogRecords:
import {
LogRecordsCapability,
defineChatMiddleware,
getLogRecords,
} from "@tanstack/ai";
const noteStart = defineChatMiddleware({
name: "note-start",
optionalRequires: [LogRecordsCapability],
async onStart(ctx) {
const log = getLogRecords(ctx, { optional: true });
await log?.append([{ type: "app.note", text: "run started" }]);
},
});If you write a host, provide the log in setup and list it in provides:
import {
LogRecordsCapability,
defineChatMiddleware,
provideLogRecords,
} from "@tanstack/ai";
const withSessionLog = defineChatMiddleware({
name: "with-session-log",
provides: [LogRecordsCapability],
setup(ctx) {
// One log per run. A real host writes to its durable store here.
const records: Array<{ type: string }> = [];
provideLogRecords(ctx, {
append: async (batch) => {
records.push(...batch);
},
});
},
});A long onConfig hook can send progress to the client while it waits. Call ctx.emitCustomEvent when the work starts, then again when it finishes:
import { type ChatMiddleware } from "@tanstack/ai";
async function prepare() {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1);
});
}
const progress: ChatMiddleware = {
name: "progress",
async onConfig(ctx) {
if (ctx.phase !== "beforeModel") return;
ctx.emitCustomEvent("job:started", { step: "prepare" });
await prepare();
ctx.emitCustomEvent("job:ended", { step: "prepare" });
},
};Read these events on the client the same way as emitCustomEvent calls from tools. See Custom Events.
Limit the number of tool calls per request:
import { type ChatMiddleware } from "@tanstack/ai";
function rateLimitMiddleware(maxCalls: number): ChatMiddleware {
let toolCallCount = 0;
return {
name: "rate-limit",
onBeforeToolCall: (ctx, hookCtx) => {
toolCallCount++;
if (toolCallCount > maxCalls) {
return {
type: "abort",
reason: `Rate limit: exceeded ${maxCalls} tool calls`,
};
}
},
};
}The counter lives in the middleware object. Call rateLimitMiddleware() in the request handler, so each request gets its own counter.
Log each action for compliance:
import { type ChatMiddleware } from "@tanstack/ai";
import { db } from "./db";
const auditTrail: ChatMiddleware = {
name: "audit-trail",
onStart: (ctx) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "chat_started",
timestamp: Date.now(),
})
);
},
onAfterToolCall: (ctx, info) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "tool_executed",
toolName: info.toolName,
success: info.ok,
duration: info.duration,
timestamp: Date.now(),
})
);
},
onFinish: (ctx, info) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "chat_finished",
duration: info.duration,
tokens: info.usage?.totalTokens,
timestamp: Date.now(),
})
);
},
};Give the model different tools at different steps of the agent loop:
import { type ChatMiddleware } from "@tanstack/ai";
const toolSwapper: ChatMiddleware = {
name: "tool-swapper",
onConfig: (ctx, config) => {
if (ctx.phase !== "beforeModel") return;
if (ctx.iteration === 0) {
// First iteration: only allow search
return {
tools: config.tools.filter((t) => t.name === "search"),
};
}
// Later iterations: allow all tools
},
};Drop or change chunks before they reach the consumer:
import { type ChatMiddleware } from "@tanstack/ai";
import { containsProfanity } from "./filters";
const contentFilter: ChatMiddleware = {
name: "content-filter",
onChunk: (ctx, chunk) => {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
if (containsProfanity(chunk.delta)) {
// Drop the chunk
return null;
}
}
},
};For a ready-made version, see contentGuardMiddleware.
import { type ChatMiddleware } from "@tanstack/ai";
import { alertService } from "./services";
const errorAlerts: ChatMiddleware = {
name: "error-alerts",
onError: (ctx, info) => {
ctx.defer(
alertService.send({
level: "error",
message: `Chat ${ctx.requestId} failed after ${info.duration}ms`,
error: String(info.error),
})
);
},
};The core middleware types come from @tanstack/ai:
import type {
ChatMiddleware,
ChatMiddlewareContext,
ChatMiddlewarePhase,
ChatMiddlewareConfig,
StructuredOutputMiddlewareConfig,
ToolCallHookContext,
BeforeToolCallDecision,
AfterToolCallInfo,
AfterToolCallDecision,
IterationInfo,
ToolPhaseCompleteInfo,
UsageInfo,
FinishInfo,
AbortInfo,
ErrorInfo,
} from "@tanstack/ai";The option types of the built-in middleware come from the @tanstack/ai/middlewares subpath:
import type {
ToolCacheMiddlewareOptions,
ToolCacheStorage,
ToolCacheEntry,
ContentGuardMiddlewareOptions,
ContentGuardRule,
ContentFilteredInfo,
} from "@tanstack/ai/middlewares";