Skip to content
txbrendan/ui

Approval Request

beta

An AI agent asks before it acts: what it wants to do, why, and the command or change. Approve or Reject, then the outcome shows in place of the buttons and cannot be pressed again.

Preview

command

Run migration 0042 on production?

It adds a guests column to teams. The table has 1.2 million rows, so writes pause for about 40 seconds.
npm run db:migrate -- --env production --to 0042_add_team_guests

diff

Edit config/plans.ts

You asked for 10 seats and 3 guests on the Team plan.
@@ -12,7 +12,8 @@ export const limits = {   uploads: 20,-  seats: 5,+  seats: 10,+  guests: 3,   storageGb: 50, };

expires

Send the October newsletter to 4,812 people?

Rest the pointer on the card, or Tab into it, and the countdown pauses. Press ⌘ or Ctrl + Enter to approve, or Esc to reject.
Expires in 1:30

page keys

Deploy web to production?

Press ⌘ or Ctrl + Enter anywhere on this page to approve, or Esc to reject. No need to click into the card first.
npm run deploy -- --env production

agent run

tone

Send the weekly report (neutral)

To 14 people on the Team list.

Send the weekly report (brand)

To 14 people on the Team list.

Send the weekly report (danger)

To 14 people on the Team list.

Send the weekly report (success)

To 14 people on the Team list.

Send the weekly report (warning)

To 14 people on the Team list.

size

Delete 3 draft invoices, sm

They are older than 90 days and were never sent.

Delete 3 draft invoices, md

They are older than 90 days and were never sent.

states

Sending your answer

Approve shows a spinner, and nothing else can be pressed.

Run migration 0041 on staging

npm run db:migrate -- --env staging --to 0041
Approved · Always allowed · Ran in 4 s

Email 2,400 customers about the outage

Rejected · The agent will draft it for review instead

Rotate the API key for billing

Expired · No one answered in 2 minutes

Refund order 1187

The customer asked twice.

Merge pull request 212

Only a project owner can approve this.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/approval-request

Use it for

  • An agent is about to do something hard to undo, like run a migration, send email, spend money, or deploy, and a person should say yes first.
  • Put the action in title, like "Run migration 0042 on production?", and why in reason.
  • Pass command for a shell command, or diff for a change to a file. Pass detail for anything else to look at first.
  • Set tone to the risk. danger colors the tile and the Approve button red.
  • Pass allowAlways for an action the person may trust from now on. onApprove gets { always }.
  • Left alone, it holds its own outcome. To send the answer first, pass outcome, set loading while it sends, then set outcome to "approved" or "rejected".
  • If the answer fails to send, keep outcome null and pass error.
  • ⌘ or Ctrl + Enter approves and Esc rejects, only while focus is in the card, so two cards on a page never both answer. The buttons' tooltips teach the keys. Pass keys={false} to turn them off.
  • Pass keys="page" when the page shows one request at a time, like a single chat. Then the keys work anywhere on the page. They do nothing while someone types in a field or a modal dialog is open.
  • With keys="page", Esc rejects only when no menu, popup, or dialog is open, since Esc closes those first. If two pending cards use it, only the one shown last answers, unless focus is in another request.
  • The tooltips load the first time someone points at or tabs into a card, so a page that never uses the keys never loads them. Call loadApprovalTooltip() early, like when an agent run starts, to have them ready.
  • Pass expiresIn, in seconds, for a request that should not wait forever. It pauses while the pointer or focus is on the card. When it runs out, the outcome is "expired" and onExpire is called.

Avoid it for

  • An action the person started themselves, like deleting a file. Use Alert Dialog.
  • An action that is easy to undo. Do it, and offer Undo in a toast.
  • Plain consent to terms, by name. Use Agreement.

Props

  • title

    Required. What the agent wants to do. It names the request.

    ReactNode

  • reason

    Why it wants to, in plain words.

    ReactNode

  • command

    A shell command it will run. Shows in a code block that scrolls sideways.

    string

  • diff

    A unified diff. Added lines show green and removed lines red. The + and - stay in the text.

    string

  • detail

    Anything else to look at first, like a list of recipients.

    ReactNode

  • icon

    Replaces the shield in the tile. Pass null for no tile.

    ReactNode

  • tone

    How risky the action is. Colors the tile and the Approve button.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • outcome

    The outcome, when you hold it. null while it waits.

    "approved" | "rejected" | "expired" | null

  • defaultOutcome

    The outcome it starts with, when it holds its own.

    "approved" | "rejected" | "expired" | null · default null

  • onApprove

    Called on Approve or ⌘ Enter. always is true when Always allow was ticked.

    (answer: { always: boolean }) => void

  • onReject

    Called on Reject or Esc.

    () => void

  • expiresIn

    Seconds to answer. Shows "Expires in 0:42" by the buttons, in warning for the last 10. Pauses while the pointer or focus is on the card.

    number

  • onExpire

    Called when the countdown runs out. Holding the outcome yourself, set it to "expired" here.

    () => void

  • keys

    ⌘ or Ctrl + Enter approves and Esc rejects, while focus is in the card. "page" makes them work anywhere on the page.

    boolean | "page" · default true

  • allowAlways

    Shows an Always allow checkbox before the buttons.

    boolean · default false

  • defaultAlways

    Ticks Always allow at the start.

    boolean · default false

  • alwaysLabel

    The checkbox's words.

    ReactNode · default "Always allow this action"

  • approveLabel

    The Approve button's words.

    ReactNode · default "Approve"

  • rejectLabel

    The Reject button's words.

    ReactNode · default "Reject"

  • outcomeNote

    A short line after the outcome, like "Ran in 4 s".

    ReactNode

  • error

    In red under the buttons, like an answer that failed to send. Announced once.

    ReactNode

  • loading

    The pressed button shows a spinner, and nothing else can be pressed.

    boolean · default false

  • disabled

    Blocks both buttons and the checkbox.

    boolean · default false

  • size

    Text, tile, and button size. sm fits a chat thread.

    "sm" | "md" · default "md"

  • className

    Classes for the card. Later classes win.

    string

Accessibility

  • The card is a group named by the title, so each Approve is heard with what it approves.
  • Once decided, the buttons are gone and focus moves to the outcome, so screen readers read Approved, Rejected, or Expired.
  • The buttons carry aria-keyshortcuts from the start, and their tooltips show the keys. The keys work only while focus is in the card, or anywhere with keys="page".
  • The tooltips load on the first hover or focus. Focus stays on the button it was on while they load in.
  • Keys never act while someone types in a field. With keys="page", they wait while a modal dialog is open, and Esc waits while any menu, popup, or dialog is open.
  • The countdown is a timer, which screen readers do not read out each second. It pauses while focus is in the card, so no one runs out of time while they read.
  • The command and the diff are focusable, named groups, so a long line scrolls from the keyboard.
  • Diff lines keep their + and -, so the change does not rest on color alone.
  • While loading, the pressed button keeps focus and says it is busy. An error is described on both buttons and announced once.
  • The tile's icon is hidden from screen readers. In forced-colors mode the tile draws a border.

Examples

A risky command

<ApprovalRequest
  tone="danger"
  title="Run migration 0042 on production?"
  reason="It adds a guests column to teams. Writes pause for about 40 seconds."
  command="npm run db:migrate -- --env production --to 0042"
  allowAlways
/>

Send the answer, then show it

"use client";

import { useState } from "react";

import { ApprovalRequest, type ApprovalOutcome } from "@/components/ui/approval-request";

export function DeployRequest({ send }: { send: (approved: boolean) => Promise<void> }) {
  const [outcome, setOutcome] = useState<ApprovalOutcome | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string>();
  async function answer(approved: boolean) {
    setLoading(true);
    setError(undefined);
    try {
      await send(approved);
      setOutcome(approved ? "approved" : "rejected");
    } catch {
      setError("The answer did not send. Try again.");
    } finally {
      setLoading(false);
    }
  }
  return (
    <ApprovalRequest
      title="Deploy web to production?"
      outcome={outcome}
      loading={loading}
      error={error}
      onApprove={() => answer(true)}
      onReject={() => answer(false)}
    />
  );
}

Load the tooltips when a run starts

"use client";

import { ApprovalRequest, loadApprovalTooltip } from "@/components/ui/approval-request";
import { Button } from "@/components/ui/button";

export function AgentRun({ start, request }: { start: () => void; request?: { title: string; command: string } }) {
  return (
    <>
      <Button
        onClick={() => {
          // A request may come, so its key tooltips are ready by then.
          loadApprovalTooltip().catch(() => {});
          start();
        }}
      >
        Start agent run
      </Button>
      {request && <ApprovalRequest title={request.title} command={request.command} />}
    </>
  );
}