Use a generic interrupt when the server needs data from the client but no tool call caused the request. For example, ask the user to select a plan before the model runs.
defineInterrupt defines the data that the client sees and the data it must return. Register the same definition with chat() and useChat(). This gives both sides the same types.
Put the definition in a module that both the route and the client can import. The schemas must export JSON Schema.
// app/interrupts.ts
import { defineInterrupt } from '@tanstack/ai'
import { z } from 'zod'
export const reviewPlan = defineInterrupt({
id: 'review-plan',
payloadSchema: z.object({ title: z.string(), changes: z.array(z.string()) }),
responseSchema: z.object({ approved: z.boolean(), note: z.string().optional() }),
})payloadSchema describes display data that travels from the server to the client. responseSchema describes data that travels from the client back to the server. The display payload is optional. The response schema is required.
Return requests from onInterruptBoundary. The hook can run at each value in INTERRUPT_BOUNDARY_PHASES:
Requests from every middleware in the same boundary form one interrupt batch.
Pick the phase with a ctx.phase guard. If ctx.parentRunId is set, skip the emit. If you do not, the same pause happens again.
When to use each phase, and what ctx contains there, is in Lifecycle Boundaries.
// app/chat-middleware.ts
import type { ChatMiddleware } from '@tanstack/ai'
import { reviewPlan } from './interrupts'
export const requestReview: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'request-review',
onInterruptBoundary(ctx) {
if (ctx.phase !== 'beforeModel') return
if (ctx.parentRunId) return
if (ctx.iteration !== 0) return
return {
interrupts: [
reviewPlan.interrupt({
key: 'initial-plan',
reason: 'review-required',
message: 'Review the proposed plan.',
payload: { title: 'Release plan', changes: ['Add search', 'Add tests'] },
}),
],
}
},
}interrupt() accepts only key, reason, message, expiresAt, and, when declared, payload. It returns an immutable request. Do not put secrets in the payload because the client receives it and persistence can store it.
Register all definitions on chat({ interrupts }). A duplicate definition id fails before the adapter starts.
// app/api/chat/route.ts
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { requestReview } from '../../chat-middleware'
import { reviewPlan } from '../../interrupts'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
interrupts: [reviewPlan],
middleware: [requestReview],
})
return toServerSentEventsResponse(stream)
}An interrupt ends the current AG-UI run with one RUN_FINISHED event whose outcome is interrupt. Resolving it starts a new run with parentRunId set to the interrupted run. The continuation carries the response to the registered middleware.
Register the same definitions with useChat. A bound generic interrupt has its definition id, typed display payload, and typed resolveInterrupt method.
Check kind and definitionId. Then pass the item to a card that takes GenericInterrupt<typeof reviewPlan>.
// app/plan-review.tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import type { GenericInterrupt } from '@tanstack/ai-react'
import { reviewPlan } from './interrupts'
function ReviewCard({
interrupt,
}: {
interrupt: GenericInterrupt<typeof reviewPlan>
}) {
return (
<article>
<h2>{interrupt.payload?.title}</h2>
<button onClick={() => interrupt.resolveInterrupt({ approved: true })}>
Approve
</button>
<button onClick={() => interrupt.cancel()}>Cancel</button>
</article>
)
}
export function PlanReview() {
const { interrupts } = useChat({
threadId: 'release-42',
connection: fetchServerSentEvents('/api/chat'),
interrupts: [reviewPlan],
})
return (
<>
{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} />
})}
</>
)
}resolveInterrupt stages the answer. The client sends one continuation only after every bound interrupt in the batch is resolved or cancelled. Use cancel() when the user declines to provide data.
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.
That second call is a new run. useChat sends parentRunId and resume. Each generic resume item carries the original request in metadata. The hook runs after init onConfig and before onStart. ctx.phase is still 'init'.
for(definition) keeps the response type for that definition. all() reads all registered definitions. all(definitionA, definitionB) narrows the result to those definitions.
import type { ChatMiddleware } from '@tanstack/ai'
import { reviewPlan } from './interrupts'
export const applyReview: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'apply-review',
onInterruptResolution(_ctx, resumedInterrupts) {
for (const result of resumedInterrupts.for(reviewPlan)) {
if (result.status === 'resolved' && !result.response.approved) {
return { toolResume: 'stop' }
}
}
},
}Middleware can return toolResume: 'continue', 'cancel', or 'stop'. When more than one middleware returns a value, 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'. The full order, plus a working example, is in Apply Answers.
The React chat example has a playground for beforeModel, afterModel, beforeTools, and afterTools. Each pause shows two typed cards: reviewPlan and chooseAudience.
An external system can emit a standard AG-UI generic interrupt. TanStack AI shows it as kind: 'unbound' unless it has a valid TanStack binding. It stays visible, but it has no resolve or cancel method. This keeps another system from receiving a continuation that TanStack AI owns.
See Multiple Interrupts for mixed tool approvals and generic interrupts. See Chat persistence when an interrupt must survive a restart.
| You want to | Page |
|---|---|
| Pick beforeModel, afterModel, beforeTools, or afterTools | Lifecycle Boundaries |
| Apply the user answer to prompts, tools, or toolResume | Apply Answers |
| Mix generic interrupts with tool approvals | Multiple Interrupts |