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 before a tool runs

Add needsApproval: true to the tool. The run pauses and your UI gets an interrupt prop.

tsx
const deleteFile = toolDefinition({
  name: 'deleteFile',
  description: 'Delete a file',
  needsApproval: true,
  inputSchema: z.object({ path: z.string() }),
  outputSchema: z.object({ deleted: z.boolean() }),
}).client()
const deleteFile = toolDefinition({
  name: 'deleteFile',
  description: 'Delete a file',
  needsApproval: true,
  inputSchema: z.object({ path: z.string() }),
  outputSchema: z.object({ deleted: z.boolean() }),
}).client()

You choose where the approval appears. Two places are available.

Next to the tool

The tool component receives interrupt alongside part. Render the buttons there.

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

const deleteFile = toolDefinition({
  name: 'deleteFile',
  description: 'Delete a file',
  needsApproval: true,
  inputSchema: z.object({ path: z.string() }),
  outputSchema: z.object({ deleted: z.boolean() }),
}).client()

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

const { useAppChat } = createChatHook({
  options: chatOptions,
  components: {
    layout: ({ Messages }) => <main><Messages /></main>,
    message: ({ Parts }) => <article><Parts /></article>,
  },
  partsComponents: {
    text: ({ part }) => <p>{part.content}</p>,
    fallback: () => null,
  },
  toolsComponents: {
    deleteFile: ({ part, interrupt }) => {
      if (interrupt?.status === 'pending') {
        return (
          <div>
            <p>Delete {part.input?.path}?</p>
            <button onClick={() => interrupt.resolveInterrupt(true)}>
              Delete
            </button>
            <button onClick={() => interrupt.resolveInterrupt(false)}>
              Keep
            </button>
          </div>
        )
      }
      if (part.state === 'complete') {
        return <p>{part.output?.deleted ? 'Deleted' : 'Kept'}</p>
      }
      return <p>{part.input?.path}</p>
    },
  },
})

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

const deleteFile = toolDefinition({
  name: 'deleteFile',
  description: 'Delete a file',
  needsApproval: true,
  inputSchema: z.object({ path: z.string() }),
  outputSchema: z.object({ deleted: z.boolean() }),
}).client()

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

const { useAppChat } = createChatHook({
  options: chatOptions,
  components: {
    layout: ({ Messages }) => <main><Messages /></main>,
    message: ({ Parts }) => <article><Parts /></article>,
  },
  partsComponents: {
    text: ({ part }) => <p>{part.content}</p>,
    fallback: () => null,
  },
  toolsComponents: {
    deleteFile: ({ part, interrupt }) => {
      if (interrupt?.status === 'pending') {
        return (
          <div>
            <p>Delete {part.input?.path}?</p>
            <button onClick={() => interrupt.resolveInterrupt(true)}>
              Delete
            </button>
            <button onClick={() => interrupt.resolveInterrupt(false)}>
              Keep
            </button>
          </div>
        )
      }
      if (part.state === 'complete') {
        return <p>{part.output?.deleted ? 'Deleted' : 'Kept'}</p>
      }
      return <p>{part.input?.path}</p>
    },
  },
})

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

resolveInterrupt(true) approves. resolveInterrupt(false) denies. The run continues either way.

In a list instead

Some apps collect every pending decision in one place. Register the tool name under interruptsComponents.tools and the approval moves out of the message.

tsx
import type { InterruptProps } from '@tanstack/ai-react/ui'

export function DeleteApproval({
  interrupt,
}: InterruptProps<typeof chatOptions, 'deleteFile'>) {
  return (
    <div>
      <p>Delete {interrupt.originalArgs.path}?</p>
      <button onClick={() => interrupt.resolveInterrupt(true)}>Delete</button>
      <button onClick={() => interrupt.resolveInterrupt(false)}>Keep</button>
    </div>
  )
}
import type { InterruptProps } from '@tanstack/ai-react/ui'

export function DeleteApproval({
  interrupt,
}: InterruptProps<typeof chatOptions, 'deleteFile'>) {
  return (
    <div>
      <p>Delete {interrupt.originalArgs.path}?</p>
      <button onClick={() => interrupt.resolveInterrupt(true)}>Delete</button>
      <button onClick={() => interrupt.resolveInterrupt(false)}>Keep</button>
    </div>
  )
}

Then wire it up and render Interrupts in the layout:

tsx
const { useAppChat } = createChatHook({
  options: chatOptions,
  components: {
    layout: ({ Messages, Interrupts }) => (
      <main>
        <Messages />
        <Interrupts />
      </main>
    ),
    message: ({ Parts }) => <article><Parts /></article>,
  },
  partsComponents: { fallback: () => null },
  toolsComponents: {
    deleteFile: ({ part }) => <p>{part.input?.path}</p>,
  },
  interruptsComponents: {
    tools: { deleteFile: DeleteApproval },
  },
})
const { useAppChat } = createChatHook({
  options: chatOptions,
  components: {
    layout: ({ Messages, Interrupts }) => (
      <main>
        <Messages />
        <Interrupts />
      </main>
    ),
    message: ({ Parts }) => <article><Parts /></article>,
  },
  partsComponents: { fallback: () => null },
  toolsComponents: {
    deleteFile: ({ part }) => <p>{part.input?.path}</p>,
  },
  interruptsComponents: {
    tools: { deleteFile: DeleteApproval },
  },
})

The tool component no longer reads interrupt. Its approval is in the list.

Pick one place per tool

Register the tool under interruptsComponents.tools and the approval renders in the list. Leave it out and the approval reaches the tool component instead. Do not do both for one tool.

interrupt.originalArgs holds the arguments the model proposed. It is typed from the tool's inputSchema, so path is a string here.

Next