You know you need a generic interrupt. You do not know which phase to pause in. If you pause too early, the model has no draft to review. If you pause too late, a tool has already run.
By the end of this page you can pick one phase from INTERRUPT_BOUNDARY_PHASES and write the ctx.phase guard for it.
For the define, register, and resolve steps, see Generic Interrupts.
onInterruptBoundary runs at each of these points in one agent iteration. Return { interrupts } to pause. Return nothing to let the run continue.
| Phase | When it runs | Typical question |
|---|---|---|
| beforeModel | After onConfig for this iteration, before the adapter call | Do we have enough from the user to spend tokens? |
| afterModel | After the model stream ends, before tools run | Is this draft or these tool calls acceptable? |
| beforeTools | After the assistant tool-call message is in messages, before execution | May these tools run? |
| afterTools | After tools finish and their result messages are in messages | May these results go back to the model? |
The engine combines every request from every middleware at the same phase into one interrupt batch. That batch ends the current run with one interrupt outcome.
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'],
},
}),
],
}
},
}onInterruptBoundary cannot change config. It can only pause. To change prompts, tools, or messages from the user answer, see Apply Answers.
The continuation is a new chat() call. Every boundary hook runs again.
If you return the same request, the run pauses again. If ctx.parentRunId is set, skip the emit. Use that skip when the pause belongs to the original request only.
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'],
},
}),
],
}
}Every hook receives the same ChatMiddlewareContext. The contents change.
Useful fields on ctx:
Mutating ctx.messages does not change the engine config.
onConfig for this iteration has already run. Prompts, tools, messages, and modelOptions are the values after that merge.
Use this phase when you need data before you pay for a model call.
The model stream is complete.
If you need tool names or args, wait for beforeTools, or watch onChunk during modelStream.
The engine has added the assistant message with toolCalls to messages. Tools have not run.
Use this phase to inspect the proposed calls before any side effect.
Tools have run. Result messages with role: 'tool' are already in messages. onToolPhaseComplete has already run.
Use this phase to inspect results before the next model turn.
Ask for a plan, an audience, or a locale before the model writes.
Examples:
The model has written text. You want a human to accept it before tools run.
Examples:
The model asked for tools. Nothing has executed.
Examples:
This is close to tool approval. Use a generic interrupt when the question is not a yes or no on one tool. Also use it when several tools must be judged as one batch.
The tools have already run. You want a human to see the output before the model uses it.
Examples:
The React chat example has a playground for all four phases.
When you are ready to apply the answer to prompts or to stop the run, go to Apply Answers.