TanStack
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()

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

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

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

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