Approval Request
betaAn 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?
npm run db:migrate -- --env production --to 0042_add_team_guestsdiff
Edit config/plans.ts
@@ -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?
page keys
Deploy web to production?
npm run deploy -- --env productionagent run
tone
Send the weekly report (neutral)
Send the weekly report (brand)
Send the weekly report (danger)
Send the weekly report (success)
Send the weekly report (warning)
size
Delete 3 draft invoices, sm
Delete 3 draft invoices, md
states
Sending your answer
Run migration 0041 on staging
npm run db:migrate -- --env staging --to 0041Email 2,400 customers about the outage
Rotate the API key for billing
Refund order 1187
Merge pull request 212
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. 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
| Prop | Type | Default | What it does |
|---|---|---|---|
| title | ReactNode | - | Required. What the agent wants to do. It names the request. |
| reason | ReactNode | - | Why it wants to, in plain words. |
| command | string | - | A shell command it will run. Shows in a code block that scrolls sideways. |
| diff | string | - | A unified diff. Added lines show green and removed lines red. The + and - stay in the text. |
| detail | ReactNode | - | Anything else to look at first, like a list of recipients. |
| icon | ReactNode | - | Replaces the shield in the tile. Pass null for no tile. |
| tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | How risky the action is. Colors the tile and the Approve button. |
| outcome | "approved" | "rejected" | "expired" | null | - | The outcome, when you hold it. null while it waits. |
| defaultOutcome | "approved" | "rejected" | "expired" | null | null | The outcome it starts with, when it holds its own. |
| onApprove | (answer: { always: boolean }) => void | - | Called on Approve or ⌘ Enter. always is true when Always allow was ticked. |
| onReject | () => void | - | Called on Reject or Esc. |
| expiresIn | number | - | 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. |
| onExpire | () => void | - | Called when the countdown runs out. Holding the outcome yourself, set it to "expired" here. |
| keys | boolean | "page" | true | ⌘ or Ctrl + Enter approves and Esc rejects, while focus is in the card. "page" makes them work anywhere on the page. |
| allowAlways | boolean | false | Shows an Always allow checkbox before the buttons. |
| defaultAlways | boolean | false | Ticks Always allow at the start. |
| alwaysLabel | ReactNode | "Always allow this action" | The checkbox's words. |
| approveLabel | ReactNode | "Approve" | The Approve button's words. |
| rejectLabel | ReactNode | "Reject" | The Reject button's words. |
| outcomeNote | ReactNode | - | A short line after the outcome, like "Ran in 4 s". |
| error | ReactNode | - | In red under the buttons, like an answer that failed to send. Announced once. |
| loading | boolean | false | The pressed button shows a spinner, and nothing else can be pressed. |
| disabled | boolean | false | Blocks both buttons and the checkbox. |
| size | "sm" | "md" | "md" | Text, tile, and button size. sm fits a chat thread. |
| className | string | - | Classes for the card. Later classes win. |
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} />}
</>
);
}