Tool Approval
Approve or deny an AI SDK tool call with a gesture that matches its risk.
// Nothing has run yet
npx shadcn@latest add @sureui/tool-approval
Mark a tool as needing approval on the server (AI SDK tool approval):
import { ToolLoopAgent } from "ai"const agent = new ToolLoopAgent({ model, tools: { deleteProject }, toolApproval: { deleteProject: "user-approval" },})Render ToolApproval for its tool part and pass addToolApprovalResponse from useChat:
import { useChat } from "@ai-sdk/react"import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai"import { ToolApproval } from "@/components/ui/sureui/tool-approval"const { messages, addToolApprovalResponse } = useChat({ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,}){ message.parts.map((part) => part.type === "tool-deleteProject" ? ( <ToolApproval key={part.toolCallId} part={part} risk="critical" phrase={part.input.name} onRespond={addToolApprovalResponse} /> ) : null )}It shows Approve and Deny while the part is approval-requested, and the outcome once it's answered. sendAutomaticallyWhen continues the conversation after you respond.
Low risk
Approve runs after an undo window, so a misclick can be taken back before the agent hears about it.
// Nothing has run yet
Medium risk
The default. Approve arms on the first click and responds on the second.
// Nothing has run yet
High risk
Approve responds once the hold completes. Letting go early cancels.
// Nothing has run yet
Critical risk
Approve unlocks once phrase is typed, like a project or repository name.
// Nothing has run yet
Deny is one click at every level.
Arm delay
armDelay ignores Approve for that many milliseconds after it appears and after it arms, so a click meant for something that was there a moment ago doesn't approve the call.
// Nothing has run yet
Errors
If onRespond throws or rejects, the buttons unlock so you can answer again. With onConfirmError, the error goes to your handler, and errorLabel replaces the Approve label until the next attempt.
// Nothing has run yet
Batch
ToolApprovalBatch answers every pending call at once. Its Approve uses the gesture of the riskiest call, and it calls onRespond once per call. Pass risk as a function to grade each part. Parts that aren't approval-requested are skipped.
// Nothing has run yet
import { isToolUIPart } from "ai"import { ToolApprovalBatch } from "@/components/ui/sureui/tool-approval"<ToolApprovalBatch parts={message.parts.filter(isToolUIPart)} risk={(part) => (part.type === "tool-deleteProject" ? "high" : "low")} onRespond={addToolApprovalResponse}/>Scope
Pass scopes to let people choose how long an answer holds: once, this session or always. The first entry starts selected, and onRespond receives it as scope with Approve and Deny. SureUI doesn't store permissions: keep the answer for the session or save it, and skip ToolApproval for that tool next time. addToolApprovalResponse ignores scope, so you can pass it on unchanged.
// Nothing has run yet
Note
Pass note to add a field above Approve and Deny. Whatever people type goes out as reason with either answer. addToolApprovalResponse passes it to the model, so the agent can read "only the staging table" or "use the archive tool instead". The outcome shows the note after the answer.
// Nothing has run yet
ToolApproval
onRespond takes the same argument as addToolApprovalResponse, and part takes any AI SDK tool part.
| Prop | Type | Default |
|---|---|---|
| part | { state, approval?: { id, approved?, reason? } } | required |
| onRespond | ({ id, approved, reason?, scope? }) => void | PromiseLike<unknown> | required |
| risk | "low" | "medium" | "high" | "critical" | "medium" |
| phrase | string | required with "critical" |
| approveLabel | ReactNode | "Approve", or "Hold to approve" for "high" |
| denyLabel | ReactNode | "Deny" |
| approvedLabel | ReactNode | "Approved" |
| deniedLabel | ReactNode | "Denied" |
| errorLabel | ReactNode | — |
| onConfirmError | (error: unknown) => void | — |
| undo | boolean | number | "manual" | true |
| timeout | number | 3000 |
| duration | number | 1200 |
| armDelay | number | 0 |
| scopes | ("once" | "session" | "always")[] | — |
| scopeLabels | { group?, once?, session?, always? } | { group: "Remember", once: "Once", session: "This session", always: "Always" } |
| note | boolean | false |
| noteLabel | string | "Add a note for the agent" |
| className | string | — |
undo applies to "low", timeout to "medium" and duration to "high". scope is only sent when scopes is set.
ToolApprovalBatch
Takes the same props as ToolApproval, with parts in place of part.
| Prop | Type | Default |
|---|---|---|
| parts | ToolApprovalPart[] | required |
| onRespond | ({ id, approved, reason?, scope? }) => void | PromiseLike<unknown> | required |
| risk | ToolApprovalRisk | (part) => ToolApprovalRisk | "medium" |
| phrase | string | "approve all" |
| approveLabel | ReactNode | "Approve all", or "Hold to approve all" for "high" |
| denyLabel | ReactNode | "Deny all" |
| approvedLabel | ReactNode | "Approved" |
| deniedLabel | ReactNode | "Denied" |
| errorLabel | ReactNode | — |
| onConfirmError | (error: unknown) => void | — |
| undo | boolean | number | "manual" | true |
| timeout | number | 3000 |
| duration | number | 1200 |
| armDelay | number | 0 |
| scopes | ("once" | "session" | "always")[] | — |
| scopeLabels | { group?, once?, session?, always? } | { group: "Remember", once: "Once", session: "This session", always: "Always" } |
| note | boolean | false |
| noteLabel | string | "Add a note for the agent" |
| className | string | — |
phrase applies when the riskiest call is "critical". The outcome shows once every part is answered the same way. When the pending calls change, a gesture in progress starts over.
Data attributes
The root sets data-slot="tool-approval" (or "tool-approval-batch") and data-state. The scope choice sets data-slot="tool-approval-scope", and the note field sets data-slot="tool-approval-note".
| Value | When |
|---|---|
| requested | Waiting for Approve or Deny. |
| approved | The part was approved. |
| denied | The part was denied. |
- Approve uses the same announcements as Confirm Button and Type to Confirm, including the second-click fallback for a hold.
- Only one response is ever sent per call. Deny is disabled during a low-risk undo window and while a response is pending, and both buttons come back if the response fails. In a batch, only the calls whose response failed come back.
- The scope choice is a radio group named by
scopeLabels.group, so arrow keys move between options. - The note field is named by
noteLabel, and it is disabled with the buttons while a response is pending.
WCAG
- 2.2.1 Timing Adjustable:
undo="manual"meets it for"low", since Undo has no time limit. - 2.5.2 Pointer Cancellation:
"high"meets it, because releasing early or moving off Approve cancels the hold.