defineInterrupt pauses a run for a user answer that is not a tool call. The run continues after the answer.
A generic interrupt pauses the run with a typed question. defineInterrupt describes the data that the client sees and the data that it sends back. The server and the client register the same definition, so both sides share the types.
Put the definition in a module that the route and the client can both import. The schemas must be able to 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() }),
})Return the request from onInterruptBoundary. Guard on ctx.phase to pick when it runs. If ctx.parentRunId is set, return nothing. Without that guard, the continuation pauses again:
// 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'] },
}),
],
}
},
}To pick a different phase, see Lifecycle Boundaries.
Pass the definitions to chat({ interrupts }) and the middleware to chat({ middleware }). Forward parentRunId and resume from the request:
// 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-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: [requestReview],
})
return toServerSentEventsResponse(stream)
}Register the same definitions with useChat. Check kind and definitionId, then pass the item to a card typed as 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} />
})}
</>
)
}Send any message. The run stops before the model call and shows the "Release plan" card. Click Approve. The run continues, and the model answers.
onInterruptResolution runs once at the start of the continuation, after the client answers. It does not run in the chat() call that paused:
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' }
}
}
},
}This hook cannot change prompts, tools, or messages. To use the answer in the next model call, see Apply Answers.
The React chat example has a playground for beforeModel, afterModel, beforeTools, and afterTools. Each pause shows two typed cards: reviewPlan and chooseAudience.
onInterruptBoundary can run at each value in INTERRUPT_BOUNDARY_PHASES:
When to use each phase, and what ctx holds there, is in Lifecycle Boundaries.
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.
An external system can emit a standard AG-UI generic interrupt. Without a valid TanStack binding, the client shows it as kind: 'unbound'. It stays visible, but it has no resolve or cancel method. Then your client never sends a continuation for a pause that a different system owns. See Interrupts from other systems.