Approval card
Human in the loop: the agent asks before it runs anything with side effects.
Run command
The agent wants to run a shell command
Installation
npx assistant-ui@latest add elements-approval-cardThe CLI reads react-native from your package.json and installs from the native registry tree. The element takes the same props as the React one; the React Native elements guide covers setup and what changes on a phone.
Install manually
npx shadcn@latest add "@assistant-ui/elements-approval-card"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-approval-card"Props-driven: no runtime or provider required.
An approval card explains a consequential action before it happens. It can show a shell command, but it also works for actions with a short description and structured details, such as deleting records or publishing a release. With a runtime the decision flows through the tool call's own approval gate; standalone you hold the state and answer the callbacks yourself.
Getting started
A server-side tool approval gate keeps the request open until the user answers. Map the tool arguments to the card and send the selected response through respondToApproval.
Render the tool call
"use client";
import { Trash2Icon } from "lucide-react";
import { defineToolkit, type ToolApprovalResponse } from "@assistant-ui/react";
import { ApprovalCard } from "@/components/assistant-ui/elements/approval-card";
export const toolkit = defineToolkit({
manage_records: {
type: "backend",
render: ({ args, approval, respondToApproval, result }) => {
const answer = async (response: ToolApprovalResponse) => {
try {
await respondToApproval(response);
} catch (failure) {
console.error(failure);
}
};
const destructive = args.action === "delete";
return (
<ApprovalCard
state={
approval?.approved === false
? "denied"
: approval?.approved === undefined
? "request"
: result === undefined
? "running"
: "done"
}
command={args.command}
title={args.title}
subtitle={args.subtitle}
description={args.description}
details={args.details}
variant={destructive ? "destructive" : "default"}
icon={destructive ? <Trash2Icon className="size-4" /> : undefined}
onAllowOnce={() => void answer({ optionId: "once" })}
onAlwaysAllow={() => void answer({ optionId: "always" })}
onDeny={() => void answer({ optionId: "deny" })}
/>
);
},
},
});approval.approved is undefined until answered, so that is the only window in which respondToApproval is legal. Sending an optionId records which of the host's declared options was chosen rather than a plain yes or no. See Approval options for declaring "once", "always", and "deny" as allow-once, allow-always, and reject-once.
Register the toolkit
import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react";
import { toolkit } from "./toolkit";
const config = AuiConfig({ tools: Tools({ toolkit }) });
export function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
return (
<AssistantRuntimeProvider runtime={runtime} config={config}>
{children}
</AssistantRuntimeProvider>
);
}Approval gates require a runtime that emits them; the AI SDK v7 runtime does for toolApproval-gated tools, and LocalRuntime does for gates your ChatModelAdapter emits.
Standalone, the element is a controlled display: you own state and decide what each button does.
Hold the approval state
"use client";
import { useState } from "react";
import {
ApprovalCard,
type ApprovalState,
} from "@/components/assistant-ui/elements/approval-card";
export function Approval() {
const [state, setState] = useState<ApprovalState>("request");
return (
<ApprovalCard
state={state}
command="pnpm vitest run --changed"
title="Run checks"
subtitle="The agent wants to run a shell command"
description="This runs the tests affected by the current changes."
onAllowOnce={() => setState("running")}
onAlwaysAllow={() => setState("running")}
onDeny={() => setState("denied")}
/>
);
}Resolve the action
async function run() {
await runChecks();
setState("done");
}state="done" reads "Finished" by default. Pass statusLabel when the completed action needs a more specific receipt.
Anatomy
<div data-slot="approval-card" data-variant="default | destructive">
<div>
<span>{/* terminal icon or icon */}</span>
<p>{/* title */}</p>
<p>{/* subtitle */}</p>
</div>
<p>{/* optional description */}</p>
<div>{/* optional command, monospace */}</div>
<dl>{/* optional detail label and value pairs */}</dl>
<div>{/* request actions or a status receipt */}</div>
</div>The title labels the card for assistive technology, and description becomes its accessible description when present. The footer becomes a live status receipt once the state changes to "running", "done", or "denied".
Examples
Approve a destructive action
command is optional, so an approval can describe an application action without pretending it is a terminal command. The destructive variant changes the icon tile and the primary action to the failure tone.
import { Trash2Icon } from "lucide-react";
<ApprovalCard
state="request"
variant="destructive"
icon={<Trash2Icon className="size-4" />}
title="Delete 12 archived conversations"
subtitle="This action cannot be undone"
description="The selected conversations and their generated files will be permanently removed."
details={[
{ label: "Conversations", value: "12 archived" },
{ label: "Generated files", value: "38 files" },
]}
allowOnceLabel="Delete conversations"
alwaysAllowLabel="Always delete"
denyLabel="Keep conversations"
onAllowOnce={deleteConversations}
onAlwaysAllow={alwaysDeleteConversations}
onDeny={closeApproval}
/>Restyle the card
Both lanes take className on the root. The optional command and details panels use the field surface. Request buttons use ghostButton and inkButton from surfaces.tsx.
<ApprovalCard className="max-w-md" /* ... */ />Denying with a reason
respondToApproval accepts a reason alongside the decision, which the host can show back to the model:
onDeny={() =>
void answer({ optionId: "deny", reason: "not in this session" })
}The element has no reason field; collect one in your own UI before calling onDeny, or route the reason through your app state instead.
onDeny={() => {
logDenyReason("not in this session");
setState("denied");
}}API reference
Tool-call render props
| Source | Type | Description |
|---|---|---|
args.title / args.subtitle | string | Required framing copy for the card header. |
args.command | string | undefined | Optional command displayed in the monospace panel. |
args.description | string | undefined | Optional summary mapped to description. |
args.details | readonly { label: string; value: string }[] | undefined | Optional structured detail rows mapped to details. |
| Renderer mapping | "default" | "destructive", ReactNode | undefined | Choose variant and icon from the action type. An icon is renderer UI, not a tool argument. |
| Renderer labels | string | undefined | Map action-specific labels to allowOnceLabel, alwaysAllowLabel, and denyLabel; their card defaults are available when omitted. |
| Result mapping | string | undefined | Pass statusLabel when the running, finished, or denied receipt needs action-specific copy. |
approval?.approved | boolean | undefined | undefined maps to "request", false to "denied", true to "running" or "done" depending on result. |
result | unknown | Presence after approval maps to "done". |
respondToApproval(response) | (response: ToolApprovalResponse) => Promise<void> | Sends the decision. It is legal only while approval.approved is undefined. |
ApprovalCard
| Prop | Type | Default | Description |
|---|---|---|---|
state | "request" | "running" | "done" | "denied" | required | Selects the request controls or receipt. |
command | string | undefined | Displays a monospace command panel when supplied. | |
title | string | required | Labels the card and names the action. |
subtitle | string | required | Secondary header copy. |
description | string | undefined | Short explanation of what will happen. | |
details | readonly { label: string; value: string }[] | undefined | Structured action details, displayed as label and value pairs. | |
variant | "default" | "destructive" | "default" | Uses the destructive tone for the icon tile and primary action. |
icon | ReactNode | undefined | terminal icon | Replaces the terminal icon in the header tile. |
allowOnceLabel | string | undefined | "Allow once" | Label for the primary request action. |
alwaysAllowLabel | string | undefined | "Always allow" | Label for the persistent request action. |
denyLabel | string | undefined | "Deny" | Label for the refusal action. |
statusLabel | string | undefined | state-specific copy | Replaces the receipt text for any non-request state. |
onAllowOnce | () => void | Renders the primary request action and fires when it is selected. | |
onAlwaysAllow | () => void | Renders the persistent request action and fires when it is selected. | |
onDeny | () => void | Renders the refusal action and fires when it is selected. | |
className | string | Merged onto the root. |
All other div props are forwarded to the root.