Skip to content
Elements

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

pnpm vitest run --changed
fig. 01

Installation

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 init

Then 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.

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

app/toolkit.tsx
"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

app/MyRuntimeProvider.tsx
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.

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" })
}

API reference

Tool-call render props

SourceTypeDescription
args.title / args.subtitlestringRequired framing copy for the card header.
args.commandstring | undefinedOptional command displayed in the monospace panel.
args.descriptionstring | undefinedOptional summary mapped to description.
args.detailsreadonly { label: string; value: string }[] | undefinedOptional structured detail rows mapped to details.
Renderer mapping"default" | "destructive", ReactNode | undefinedChoose variant and icon from the action type. An icon is renderer UI, not a tool argument.
Renderer labelsstring | undefinedMap action-specific labels to allowOnceLabel, alwaysAllowLabel, and denyLabel; their card defaults are available when omitted.
Result mappingstring | undefinedPass statusLabel when the running, finished, or denied receipt needs action-specific copy.
approval?.approvedboolean | undefinedundefined maps to "request", false to "denied", true to "running" or "done" depending on result.
resultunknownPresence after approval maps to "done".
respondToApproval(response)(response: ToolApprovalResponse) => Promise<void>Sends the decision. It is legal only while approval.approved is undefined.