Read the user's answer in onInterruptResolution, then apply it in onConfig. A no stops the run.
Keep the answer on a middleware capability between the two hooks.
This page assumes that you have the reviewPlan definition from Generic Interrupts.
This middleware asks for the review, reads the answer, and adds the note to the system prompts:
// app/review-plan.ts
import { createCapability, type ChatMiddleware } from '@tanstack/ai'
import { reviewPlan } from './interrupts'
export const reviewNote = createCapability<{ note?: string }>()('review-note')
export const [getReviewNote, provideReviewNote] = reviewNote
export const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'review-plan',
provides: [reviewNote],
setup(ctx) {
provideReviewNote(ctx, {})
},
onInterruptBoundary(ctx) {
if (ctx.phase !== 'beforeModel') return
if (ctx.parentRunId) return
return {
interrupts: [
reviewPlan.interrupt({
key: 'initial-plan',
reason: 'review-required',
message: 'Review the proposed plan.',
payload: {
title: 'Release plan',
changes: ['Add search'],
},
}),
],
}
},
onInterruptResolution(ctx, resumed) {
const [result] = resumed.for(reviewPlan)
if (result?.status !== 'resolved') return
// setup() already provided the value. Change it, do not provide it again.
getReviewNote(ctx).note = result.response.note
if (!result.response.approved) {
return { toolResume: 'stop' }
}
},
onConfig(ctx, config) {
if (ctx.phase !== 'beforeModel') return
const note = getReviewNote(ctx).note
if (!note) return
return {
systemPrompts: [
...config.systemPrompts,
`User review note: ${note}`,
],
}
},
}The continuation needs the same definitions and the same middleware as the paused run. 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 { reviewMiddleware } from '../../review-plan'
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: [reviewMiddleware],
})
return toServerSentEventsResponse(stream)
}Use the PlanReview component from Generic Interrupts. Make it resolve with { approved: true, note: 'Keep it short' }. The model reply now follows the note. Resolve with { approved: false }, and the run stops with no model call.
The pause spans two chat() calls, for one turn that the user sees:
On the continuation, the hooks run in this order:
sequenceDiagram
participant User
participant Client
participant Server
Client->>Server: first chat() request
Server-->>Client: RUN_FINISHED outcome interrupt
Client->>User: interrupts array
User->>Client: resolveInterrupt or cancel
Client->>Server: second chat() with parentRunId and resume
Note over Server: onInterruptResolution runs here
Server-->>Client: continue, cancel tools, or stopReturn toolResume to decide what happens to the pending tools of the paused turn:
| Value | Effect |
|---|---|
| continue | Run the pending tools |
| cancel | Mark the pending tools as cancelled. Do not run them |
| stop | End the run after onStart. No tools. No model call |
When more than one middleware returns a value, the engine keeps the stricter one. stop wins over cancel. cancel wins over continue.
After continue, the engine continues from the phase that paused:
| First run paused at | Next step |
|---|---|
| beforeModel | The model call |
| afterModel | Tools, if the model asked for them |
| beforeTools | Tool execution |
| afterTools | The next model turn (the tools already ran) |
Each item is resolved (with response) or cancelled.
setup
onConfig (phase is init)
onInterruptResolution (phase is still init)
onStart
then stop, or continue the agent looponInterruptResolution cannot change config, the model, or the adapter. These fields change only from onConfig (or onStructuredOutputConfig):
The engine does not add the user answer to messages. If the model must see the note, add it yourself, as the quick setup does.
A middleware capability keeps the value on ctx for this chat() call:
A later middleware can read the same note:
import { type ChatMiddleware } from '@tanstack/ai'
import { getReviewNote, reviewNote } from './review-plan'
export const applyVoice: ChatMiddleware = {
name: 'apply-voice',
requires: [reviewNote],
onConfig(ctx, config) {
const note = getReviewNote(ctx).note
if (!note) return
return {
systemPrompts: [...config.systemPrompts, `Voice note: ${note}`],
}
},
}Other hooks can change behavior, but they do not see this resume payload:
See Middleware for those hooks.
A generic interrupt can share a batch with client tools. Then the client runs those client tools only when toolResume is continue. cancel and stop skip them. After continue, the engine emits the client-tool wait again.
useChat sends these fields for you. If you POST by hand, include all three:
If resume is present and parentRunId is missing, the server throws.
A generic resume item that you build by hand looks like this:
import { wrapGenericInterruptContinuation } from '@tanstack/ai'
const resumeItem = {
interruptId: 'generic-1',
status: 'resolved' as const,
payload: { approved: true },
metadata: wrapGenericInterruptContinuation({
v: 1,
definitionId: 'review-plan',
key: 'turn-1',
batchIndex: 0,
reason: 'review',
message: 'Review the plan',
}),
}With chat persistence, the hook runs at the same moment: after init onConfig, before onStart.
Persistence rebuilds the pending requests from the store. It clears config.resume, so the engine does not rebuild them from the client history. You still read the answers from resumed.for(definition).