Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Persistence (Advanced)
Embeddings
Reranking
Evaluate
Sandboxes (Advanced)
Class References
Function References
Interface References
Type Alias References
Variable References
Chat UI Recipes

Ask the user your own question

Use defineInterrupt when the question is not about approving a tool. The server pauses the run and asks. Your component answers.

Two schemas define the exchange:

  1. payloadSchema is what the server sends you.
  2. responseSchema is what you send back.

Define it

tsx
import { defineInterrupt } from '@tanstack/ai'
import { z } from 'zod'

export const choosePlan = defineInterrupt({
  id: 'choosePlan',
  payloadSchema: z.object({
    options: z.array(z.string()),
  }),
  responseSchema: z.string(),
})
import { defineInterrupt } from '@tanstack/ai'
import { z } from 'zod'

export const choosePlan = defineInterrupt({
  id: 'choosePlan',
  payloadSchema: z.object({
    options: z.array(z.string()),
  }),
  responseSchema: z.string(),
})

The server sends a list of plan names. You send one name back.

Render it

Add the interrupt to chatOptions, then register a component under interruptsComponents.generic. The key is the interrupt id.

tsx
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'

const chatOptions = {
  connection: fetchServerSentEvents('/api/chat'),
  interrupts: [choosePlan],
}

const { useAppChat } = createChatHook({
  options: chatOptions,
  components: {
    layout: ({ Messages, Interrupts }) => (
      <main>
        <Messages />
        <Interrupts />
      </main>
    ),
    message: ({ Parts }) => <article><Parts /></article>,
  },
  partsComponents: {
    text: ({ part }) => <p>{part.content}</p>,
    fallback: () => null,
  },
  interruptsComponents: {
    generic: {
      choosePlan: ({ interrupt }) => (
        <fieldset>
          <legend>Pick a plan</legend>
          {interrupt.payload?.options.map((plan) => (
            <button key={plan} onClick={() => interrupt.resolveInterrupt(plan)}>
              {plan}
            </button>
          ))}
        </fieldset>
      ),
      fallback: ({ interrupt }) => <p>{interrupt.reason}</p>,
    },
  },
})

export function PlanChat() {
  const chat = useAppChat()
  return <chat.AppChat />
}
import { fetchServerSentEvents } from '@tanstack/ai-react'
import { createChatHook } from '@tanstack/ai-react/ui'

const chatOptions = {
  connection: fetchServerSentEvents('/api/chat'),
  interrupts: [choosePlan],
}

const { useAppChat } = createChatHook({
  options: chatOptions,
  components: {
    layout: ({ Messages, Interrupts }) => (
      <main>
        <Messages />
        <Interrupts />
      </main>
    ),
    message: ({ Parts }) => <article><Parts /></article>,
  },
  partsComponents: {
    text: ({ part }) => <p>{part.content}</p>,
    fallback: () => null,
  },
  interruptsComponents: {
    generic: {
      choosePlan: ({ interrupt }) => (
        <fieldset>
          <legend>Pick a plan</legend>
          {interrupt.payload?.options.map((plan) => (
            <button key={plan} onClick={() => interrupt.resolveInterrupt(plan)}>
              {plan}
            </button>
          ))}
        </fieldset>
      ),
      fallback: ({ interrupt }) => <p>{interrupt.reason}</p>,
    },
  },
})

export function PlanChat() {
  const chat = useAppChat()
  return <chat.AppChat />
}

interrupt.payload matches your payloadSchema, so options is a string array. resolveInterrupt accepts only what your responseSchema allows, so passing a number here is a compile error.

Generic interrupts always render in the list

A tool approval can render next to its tool. A generic interrupt cannot, because no tool owns it. Render Interrupts in your layout or it never appears.

Always register a fallback

fallback catches two things:

  1. An interrupt id you did not register.
  2. An unbound interrupt, which arrives with no id at all.

Branch on interrupt.kind === 'unbound' when the copy must differ.

Next