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

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.

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

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

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

API reference

Render props

SourceTypeDescription
args.command / args.title / args.subtitlestringThe command and its framing copy.
approval?.approvedboolean | undefinedundefined maps to "request", false to "denied", true to "running" or "done" depending on result.
resultunknownPresence (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.