Skip to content
Interrupts

Apply Answers

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.

Quick setup

1. Write the middleware

This middleware asks for the review, reads the answer, and adds the note to the system prompts:

ts
// 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}`,
      ],
    }
  },
}

2. Register it on the server

The continuation needs the same definitions and the same middleware as the paused run. Forward parentRunId and resume from the request:

ts
// 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)
}

3. Try it

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.

How it works

The pause spans two chat() calls, for one turn that the user sees:

  1. Pause. onInterruptBoundary returns { interrupts }. The run ends with RUN_FINISHED and outcome: interrupt. onInterruptResolution does not run.
  2. Resume. After resolveInterrupt() or cancel(), the client sends a new request with parentRunId and resume. onInterruptResolution runs here.

On the continuation, the hooks run in this order:

  1. onConfig with phase: 'init'. The answers are not applied yet.
  2. onInterruptResolution. Read the answers and store them on the capability.
  3. onConfig with phase: 'beforeModel'. Return the new prompts, tools, or messages.
mermaid
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 stop

What the hook can return

Return toolResume to decide what happens to the pending tools of the paused turn:

ValueEffect
continueRun the pending tools
cancelMark the pending tools as cancelled. Do not run them
stopEnd 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 atNext step
beforeModelThe model call
afterModelTools, if the model asked for them
beforeToolsTool execution
afterToolsThe next model turn (the tools already ran)

Read the typed answers

  • resumed.for(reviewPlan) keeps the response type for that definition.
  • resumed.all() returns every registered answer.
  • resumed.all(reviewPlan, otherDefinition) narrows to those definitions.

Each item is resolved (with response) or cancelled.

Advanced

Exact place in the second call

plaintext
setup
onConfig                 (phase is init)
onInterruptResolution    (phase is still init)
onStart
then stop, or continue the agent loop
  • The hook runs once, at the start of the continuation. It does not run at beforeModel, afterModel, beforeTools, or afterTools.
  • ctx.phase is 'init' and ctx.iteration is 0. No model call has started.
  • The hook runs once per batch. Two cards in one pause give one hook call with both answers.
  • The hook does not run on the first user message.
  • The hook does not run for a tool-approval or client-tool batch that has no generic interrupt.
  • If the continuation pauses again, the next continuation is a third chat() call. The hook runs again at the start of that call.

What the hook cannot change

onInterruptResolution cannot change config, the model, or the adapter. These fields change only from onConfig (or onStructuredOutputConfig):

  • messages
  • systemPrompts
  • tools
  • modelOptions
  • metadata

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.

Why a capability

A middleware capability keeps the value on ctx for this chat() call:

  • Another middleware can declare requires and read the same note.
  • Two chat() calls that run at the same time do not share the value.
  • A capability in provides must be provided in setup. The answer is not there yet, so setup provides an empty object. onInterruptResolution writes the answer into that object later. A second provide logs a duplicate-capability warning.

A later middleware can read the same note:

ts
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:

  • onBeforeToolCall can rewrite arguments, skip a tool, or abort.
  • onChunk can rewrite or drop stream events.
  • onShouldContinue can return false to stop the loop with a normal finish.

See Middleware for those hooks.

Client tools in the same batch

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.

The resume request

useChat sends these fields for you. If you POST by hand, include all three:

  • a new runId
  • parentRunId, set to the paused run
  • resume, with the answers. Each generic item also has metadata with the original request (tanstack:interruptContinuation).

If resume is present and parentRunId is missing, the server throws.

A generic resume item that you build by hand looks like this:

ts
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',
  }),
}

Persistence

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).