Interrupts

Generic Interrupts

Generic Interrupts

Sometimes the agent needs an answer that isn't a tool call at all. Mid-run it has to ask the user to pick a shipping speed, confirm an address, or choose which of two drafts to keep. There's no tool to approve here, just a question your app asks and the user answers.

A generic interrupt is that question. You end the run with a pause that carries a responseSchema describing the answer you expect, render a form for it, and continue the run once the user submits a valid value.

Because the pause is defined by your app, you own both ends: the server emits it, and the client resolves it.

Resolve it on the client

The schema arrives over the wire, so its value is unknown at compile time. Validate the user's answer against it before resolving. Build the value from your form fields and pass it straight to the schema:

tsx
// app/refund-reason.tsx
import { useState } from 'react'
import type { GenericAGUIInterrupt } from '@tanstack/ai-client'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { z } from 'zod'

// You emitted this pause, so you know the shape of the answer. Here it is a
// single reason string chosen from a dropdown.
function RefundReasonForm({ interrupt }: { interrupt: GenericAGUIInterrupt }) {
  const [reason, setReason] = useState('damaged')
  const [errors, setErrors] = useState<ReadonlyArray<string>>([])

  const submit = () => {
    if (!interrupt.responseSchema) {
      setErrors(['This interrupt has no response schema.'])
      return
    }
    const result = z
      .fromJSONSchema(interrupt.responseSchema)
      .safeParse({ reason })
    if (!result.success) {
      setErrors(result.error.issues.map((issue) => issue.message))
      return
    }
    interrupt.resolveInterrupt(result.data)
    setErrors([])
  }

  return (
    <div>
      <p>{interrupt.message ?? interrupt.reason}</p>
      <select value={reason} onChange={(event) => setReason(event.target.value)}>
        <option value="damaged">Damaged</option>
        <option value="wrong-item">Wrong item</option>
        <option value="no-longer-needed">No longer needed</option>
      </select>
      <button disabled={!interrupt.canResolve} onClick={submit}>
        Submit
      </button>
      {errors.map((message) => (
        <p key={message}>{message}</p>
      ))}
    </div>
  )
}

export function RefundReasons() {
  const { interrupts } = useChat({
    threadId: 'order-7',
    connection: fetchServerSentEvents('/api/chat'),
  })

  return (
    <>
      {interrupts.map((interrupt) =>
        interrupt.kind === 'generic' ? (
          <RefundReasonForm key={interrupt.id} interrupt={interrupt} />
        ) : null,
      )}
    </>
  )
}

z.fromJSONSchema gives you a runtime validator, not a trustworthy static type. The library does not validate the wire schema for you. Whatever you pass to resolveInterrupt is sent as-is, so validate the value here on the client, and again on the server if you need to trust it, the same way you would treat any other user input.

Emit it on the server

Tool approvals are rebuilt by chat() from message history for free. Generic pauses are not, because only your app knows when to ask and what to ask. You emit the descriptor and validate the answer yourself:

  1. End a run with RUN_FINISHED and outcome.type === 'interrupt', carrying a generic descriptor with your responseSchema. A small middleware is the usual place to do this.
  2. On the continuation request, correlate the incoming resume against that same pending descriptor with validateInterruptResumeBatch. It checks the batch is complete and matches the pending item; it does not validate your generic value, that is yours to do. Then append the answer and continue.

The interrupt lab in examples/ts-react-chat has a complete middleware that emits a generic pause and correlates its answer. Without the server half, a generic answer fails resume validation with unknown-interrupt or incomplete-batch.

Gating a tool instead of asking a free-form question? A tool approval gives you typed branches on top of validation.