Persistence (Advanced)
- Overview
- Quick Start
- Providers
- Harnesses
- Harness Auth
- Workspace
- Tools
- Policy
- Lifecycle & Snapshots
- Portable Snapshots
- Keep Files After Reload
- Save a Named Version
- Branch From a Version
- Send a Frozen File
- Let the Agent Save and Fork
- Pick Which Files to Keep
- What a Snapshot Stores
- Instance Durability
- Durable Runs
- Events
Sandboxes (Advanced)
Ask before a tool runs
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.
Next to the tool#
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.
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.
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.
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#
- A decision that is not about a tool? See a custom interrupt.
- More on the protocol behind this: tool approval.