A human-in-the-loop confirmation for agent actions: what will happen, risk level in words, the exact command, Approve, Deny and Always allow, a timeout countdown and a recorded decision.
Run a shell command
The assistant wants to remove build output before compiling again.
- Directory
- ~/projects/storefront
- Runs as
- your user
rm -rf .next dist && pnpm build"use client"
import * as React from "react"
import { ApprovalCard } from "@/components/ballmac/approval-card"
export default function ApprovalCardDemo() {
const [note, setNote] = React.useState<string>()
return (
<div className="w-full max-w-lg">
<ApprovalCard
title="Run a shell command"
description="The assistant wants to remove build output before compiling again."
risk="medium"
details={[
{ label: "Directory", value: "~/projects/storefront" },
{ label: "Runs as", value: "your user" },
]}
preview={<code>rm -rf .next dist && pnpm build</code>}
onApprove={() => setNote("by you")}
onDeny={() => setNote("by you")}
onAlwaysAllow={() => setNote("by you, and will not ask again")}
resolvedNote={note}
expiresIn={45}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/approval-cardInstall the dependencies.
$ pnpm add motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/approval-card.tsx// Ballmac UI: Approval Card. https://ui.ballmac.com/components/approval-card "use client" import * as React from "react" import { Check, OctagonAlert, ShieldAlert, ShieldCheck, Timer, X } from "lucide-react" import { motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { useMessages, defineMessage, type Message } from "@/lib/ballmac/i18n" type ApprovalRisk = "low" | "medium" | "high" type ApprovalStatus = "pending" | "approved" | "denied" | "expired" const RISK: Record<ApprovalRisk, { label: Message; hint: Message; icon: React.ComponentType<{ className?: string }>; bar: string; chip: string }> = { low: { label: defineMessage("approval-card.RISK.low", "Low risk"), hint: defineMessage("approval-card.RISK.low.hint", "Reads data or changes nothing"), icon: ShieldCheck, bar: "bg-chart-2", chip: "border-chart-2/30 bg-chart-2/10" }, medium: { label: defineMessage("approval-card.RISK.medium", "Medium risk"), hint: defineMessage("approval-card.RISK.medium.hint", "Changes files or settings you can undo"), icon: ShieldAlert, bar: "bg-chart-3", chip: "border-chart-3/30 bg-chart-3/10" }, high: { label: defineMessage("approval-card.RISK.high", "High risk"), hint: defineMessage("approval-card.RISK.high.hint", "Hard to undo or sends data out"), icon: OctagonAlert, bar: "bg-destructive", chip: "border-destructive/30 bg-destructive/10" }, } type ApprovalDetail = { /** Label, such as "Directory". */ label: string /** Value, such as a path or host. Shown in mono type. */ value: string } type ApprovalCardProps = Omit<React.ComponentProps<"section">, "title"> & { /** What the agent wants to do, as a short sentence, for example "Run a shell command". */ title: string /** Why the agent wants to do it. */ description?: string /** How much damage a mistake could do. Sets the accent color, icon and wording. */ risk?: ApprovalRisk /** Label/value rows under the description. */ details?: ApprovalDetail[] /** The exact thing that will run: a command, a diff or a request body. */ preview?: React.ReactNode /** Accessible name of the scrollable preview. */ previewLabel?: string /** Current status (controlled). Without it the card keeps its own status after a button is pressed. */ status?: ApprovalStatus /** Called when Approve is pressed. */ onApprove?: () => void /** Called when Deny is pressed. */ onDeny?: () => void /** Adds a third button that approves now and asks not to be asked again. */ onAlwaysAllow?: () => void /** Text of the always-allow button. */ alwaysAllowLabel?: string /** Seconds until the request is denied on its own. The card counts down and calls `onExpire`. */ expiresIn?: number /** Called when the countdown reaches zero. */ onExpire?: () => void /** Text after the decision, such as "by you at 10:42". */ resolvedNote?: string /** Approve button text. */ approveLabel?: string /** Deny button text. */ denyLabel?: string } function ApprovalCard({ title, description, risk = "medium", details, preview, previewLabel, status: statusProp, onApprove, onDeny, onAlwaysAllow, alwaysAllowLabel, expiresIn, onExpire, resolvedNote, approveLabel, denyLabel, className, ...props }: ApprovalCardProps) { const msg = useMessages() previewLabel ??= msg("approval-card.previewLabel", "Exactly what will run") alwaysAllowLabel ??= msg("approval-card.alwaysAllowLabel", "Always allow") approveLabel ??= msg("approval-card.approveLabel", "Approve") denyLabel ??= msg("approval-card.denyLabel", "Deny") const reduce = useReducedMotion() const id = React.useId() const [internal, setInternal] = React.useState<ApprovalStatus>("pending") const status = statusProp ?? internal const [left, setLeft] = React.useState(expiresIn ?? 0) const resolvedRef = React.useRef<HTMLDivElement>(null) const wasPending = React.useRef(true) const meta = RISK[risk] const RiskIcon = meta.icon const onExpireRef = React.useRef(onExpire) React.useEffect(() => { onExpireRef.current = onExpire }) React.useEffect(() => { if (expiresIn === undefined || status !== "pending") return setLeft(expiresIn) const timer = setInterval(() => { setLeft((s) => { if (s <= 1) { clearInterval(timer) return 0 } return s - 1 }) }, 1000) return () => clearInterval(timer) }, [expiresIn, status]) React.useEffect(() => { if (expiresIn !== undefined && status === "pending" && left === 0) { setInternal("expired") onExpireRef.current?.() } }, [left, expiresIn, status]) // Buttons disappear on a decision, so move focus to the result instead of dropping it on the page. React.useEffect(() => { if (status !== "pending" && wasPending.current) resolvedRef.current?.focus() wasPending.current = status === "pending" }, [status]) function decide(next: "approved" | "denied", cb?: () => void) { setInternal(next) cb?.() } const pending = status === "pending" const resolvedText = status === "approved" ? "Approved" : status === "denied" ? "Denied" : "Timed out and denied" return ( <section data-slot="approval-card" data-status={status} data-risk={risk} aria-labelledby={`${id}-title`} className={cn( "relative w-full overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs", className )} {...props} > <span aria-hidden="true" className={cn("absolute inset-y-0 start-0 w-1", pending ? meta.bar : "bg-border")} /> <div className="grid gap-3.5 p-4 ps-5 sm:p-5 sm:ps-6"> <div className="flex flex-wrap items-start justify-between gap-2"> <div className="min-w-0"> <h3 id={`${id}-title`} className="text-sm leading-6 font-semibold text-foreground"> {title} </h3> {description && <p className="mt-0.5 text-[13px] leading-5 text-muted-foreground">{description}</p>} </div> <span title={msg.of(meta.hint)} className={cn( "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium text-foreground", meta.chip )} > <RiskIcon aria-hidden="true" className="size-3.5" /> {msg.of(meta.label)} <span className="sr-only">. {msg.of(meta.hint)}</span> </span> </div> {details && details.length > 0 && ( <dl className="grid gap-1.5 text-[13px]"> {details.map((d) => ( <div key={d.label} className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5"> <dt className="w-24 shrink-0 text-muted-foreground">{d.label}</dt> <dd className="min-w-0 font-mono text-xs break-all text-foreground">{d.value}</dd> </div> ))} </dl> )} {preview && ( <div role="region" aria-label={previewLabel} tabIndex={0} className="max-h-44 overflow-auto rounded-lg border bg-muted/50 px-3 py-2.5 font-mono whitespace-pre-wrap break-words text-xs leading-5 text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {preview} </div> )} {pending ? ( <div className="flex flex-wrap items-center gap-2 pt-0.5"> <button type="button" onClick={() => decide("approved", onApprove)} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-[inset_0_1px_0_0_rgb(255_255_255/0.12),0_1px_2px_0_rgb(0_0_0/0.12)] outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <Check aria-hidden="true" className="size-4" /> {approveLabel} </button> <button type="button" onClick={() => decide("denied", onDeny)} className="inline-flex h-9 items-center gap-1.5 rounded-md border bg-background px-4 text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <X aria-hidden="true" className="size-4" /> {denyLabel} </button> {onAlwaysAllow && ( <button type="button" onClick={() => decide("approved", onAlwaysAllow)} className="inline-flex h-9 items-center rounded-md px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {alwaysAllowLabel} </button> )} {expiresIn !== undefined && ( <span className="ms-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground tabular-nums"> <Timer aria-hidden="true" className="size-3.5" /> {msg("approval-card.deniesItselfIn", "Denies itself in {seconds}s", { seconds: left })} </span> )} </div> ) : null} </div> {pending && expiresIn !== undefined && ( <motion.span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-0.5 origin-left bg-foreground/60" initial={false} animate={{ scaleX: expiresIn > 0 ? left / expiresIn : 0 }} transition={reduce ? { duration: 0 } : { duration: 1, ease: "linear" }} /> )} <div ref={resolvedRef} tabIndex={-1} role="status" aria-live="polite" className={cn( "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", !pending && "border-t bg-muted/40 px-5 py-2.5 sm:px-6" )} > {!pending && ( <p className="flex items-center gap-2 text-[13px] font-medium text-foreground"> {status === "approved" ? ( <Check aria-hidden="true" className="size-4" /> ) : status === "denied" ? ( <X aria-hidden="true" className="size-4" /> ) : ( <Timer aria-hidden="true" className="size-4" /> )} {resolvedText} {resolvedNote && <span className="font-normal text-muted-foreground">{resolvedNote}</span>} </p> )} </div> </section> ) } export { ApprovalCard, type ApprovalCardProps, type ApprovalRisk, type ApprovalStatus, type ApprovalDetail }Update the import paths to match your project setup.
Usage
import { ApprovalCard } from "@/components/ballmac/approval-card"The full example is in the Code tab above.
Examples
Three risk levels
Read a file
Needed to answer your question about the config.
- Path
- ./config/app.json
Edit 3 files
Applies the change you asked for in the settings module.
- Files
- settings.ts, form.tsx, settings.test.ts
Send customer data to an external API
This leaves your network and cannot be recalled.
- Host
- api.analytics.example.com
- Records
- 1,204 customers
import { ApprovalCard } from "@/components/ballmac/approval-card"
export default function ApprovalCardRisks() {
return (
<div className="grid w-full max-w-lg gap-3">
<ApprovalCard
risk="low"
title="Read a file"
description="Needed to answer your question about the config."
details={[{ label: "Path", value: "./config/app.json" }]}
/>
<ApprovalCard
risk="medium"
title="Edit 3 files"
description="Applies the change you asked for in the settings module."
details={[{ label: "Files", value: "settings.ts, form.tsx, settings.test.ts" }]}
/>
<ApprovalCard
risk="high"
title="Send customer data to an external API"
description="This leaves your network and cannot be recalled."
details={[{ label: "Host", value: "api.analytics.example.com" }, { label: "Records", value: "1,204 customers" }]}
preview={'POST /v1/import\ncontent-type: application/json\n\n{ "customers": [ … 1204 items ] }'}
approveLabel="Send data"
denyLabel="Don't send"
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
title*What the agent wants to do, as a short sentence, for example "Run a shell command". | string | — |
descriptionWhy the agent wants to do it. | string | — |
riskHow much damage a mistake could do. Sets the accent color, icon and wording. | ApprovalRisk | "medium" |
detailsLabel/value rows under the description. | ApprovalDetail[] | — |
previewThe exact thing that will run: a command, a diff or a request body. | React.ReactNode | — |
previewLabelAccessible name of the scrollable preview. | string | — |
statusCurrent status (controlled). Without it the card keeps its own status after a button is pressed. | ApprovalStatus | — |
onApproveCalled when Approve is pressed. | () => void | — |
onDenyCalled when Deny is pressed. | () => void | — |
onAlwaysAllowAdds a third button that approves now and asks not to be asked again. | () => void | — |
alwaysAllowLabelText of the always-allow button. | string | — |
expiresInSeconds until the request is denied on its own. The card counts down and calls `onExpire`. | number | — |
onExpireCalled when the countdown reaches zero. | () => void | — |
resolvedNoteText after the decision, such as "by you at 10:42". | string | — |
approveLabelApprove button text. | string | — |
denyLabelDeny button text. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Approve, Deny, Always allow, and the scrollable preview |
| Screen readers | Risk is read as words; the decision is announced and focus moves to it |
| Color | Risk uses an icon and label as well as a colored edge |
Use with AI
title, description, risk (low | medium | high), details rows and a preview of the exact action. onApprove, onDeny, optional onAlwaysAllow. expiresIn counts down and denies. After a decision the card shows the outcome and moves focus to it. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Approval Card (@ballmac/approval-card) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Before an agent runs commands, writes files or sends data out
- Any action that needs a person's explicit yes
Not for
- Confirming a destructive UI action; use alert-dialog
- Reporting what a tool already did; use tool-call-card
Registry JSON: https://ui.ballmac.com/r/approval-card.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.
Tool Call Card
Shows one agent tool call: the tool name, a pending, running, success or error status with icon and text, duration, and collapsible pretty-printed input and result.
AI Message
A chat message with user, assistant and system roles: right-aligned user bubbles, full-width assistant prose, an avatar slot, hover-revealed actions and a hydration-safe timestamp.
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.