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.
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.
A card that names what the agent wants to run, shows the command, and waits for a decision before switching to a status line for running, done, or denied. 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 request like this is exactly what server-side tool approval gates are for: the tool call carries an approval object until the user answers, and respondToApproval sends the answer back.
Render the tool call
"use client";
import { defineToolkit, type ToolApprovalResponse } from "@assistant-ui/react";
import { ApprovalCard } from "@/components/assistant-ui/elements/approval-card";
export const toolkit = defineToolkit({
run_command: {
type: "backend",
render: ({ args, approval, respondToApproval, result }) => {
// A refused response rejects and a precondition (an unknown option, an
// answer the request does not take) throws, so `try`/`await` covers both
// and the controls stay actionable.
const answer = async (response: ToolApprovalResponse) => {
try {
await respondToApproval(response);
} catch (failure) {
console.error(failure);
}
};
return (
<ApprovalCard
state={
approval?.approved === false
? "denied"
: approval?.approved === undefined
? "request"
: result === undefined
? "running"
: "done"
}
command={args.command}
title={args.title}
subtitle={args.subtitle}
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 three options was chosen rather than a plain yes or no; see Approval options for how the host declares "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 command"
subtitle="The agent wants to run a shell command"
onAllowOnce={() => setState("running")}
onAlwaysAllow={() => setState("running")}
onDeny={() => setState("denied")}
/>
);
}Resolve the run
async function run() {
const exitCode = await runCommand();
setState("done");
}There is no prop for the exit code; state: "done" always reads as "Finished with exit 0" in this version.
Anatomy
<div data-slot="approval-card">
<div>
<span>{/* terminal icon */}</span>
<p>{/* title */}</p>
<p>{/* subtitle */}</p>
</div>
<div>{/* command, monospace */}</div>
<div>
{/* state === "request": Deny, Always allow, Allow once */}
{/* otherwise: a status line keyed on state, so it animates in */}
</div>
</div>The footer is one of two things: the three-button strip while state is "request", or a single status line once it is not. The status line's icon and text are fixed per state (a spinner for "running", an X for "denied", a check for "done") rather than driven by further props. The card holds no state of its own and runs no timers; every transition comes from the state you pass in and the callbacks fire only from the request row.
Examples
Restyle the card
Both lanes take className on the root. The command block uses the field surface and the primary button uses inkButton, both 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
Render props
| Source | Type | Description |
|---|---|---|
args.command / args.title / args.subtitle | string | The command and its framing copy. |
approval?.approved | boolean | undefined | undefined maps to "request", false to "denied", true to "running" or "done" depending on result. |
result | unknown | Presence (once approved is true) maps to "done". |
respondToApproval(response) | (response: ToolApprovalResponse) => Promise<void> | Sends the decision. Legal only while approval.approved is undefined. Rejects when the runtime could not record the response. |
ApprovalCard
| Prop | Type | Default | Description |
|---|---|---|---|
state | "request" | "running" | "done" | "denied" | required | Which footer renders. |
command | string | required | Shown in the monospace command block. |
title | string | required | Header title. |
subtitle | string | required | Header subtitle. |
onAllowOnce | () => void | Fires from the request row's primary button, which renders only when this is supplied. | |
onAlwaysAllow | () => void | Fires from the request row's secondary button, which renders only when this is supplied. | |
onDeny | () => void | Fires from the request row's Deny button, which renders only when this is supplied. | |
className | string | Merged onto the root. |
All other div props are forwarded to the root.