Add needsApproval: true to the tool. The run pauses and your UI gets an interrupt prop.
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.
The tool component receives interrupt alongside part. Render the buttons there.
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.
Some apps collect every pending decision in one place. Register the tool name under interruptsComponents.tools and the approval moves out of the message.
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:
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.
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.