Approval
AIProHuman in the loop: an agent asks to perform an action and a person approves, edits or rejects it, with what, why, affected items, parameters and risk in view.
npx prodular-ui add approvalPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Inline request
Approve, or reject with a reason the agent reads before it tries again. The actions turn into a record of who decided and when.
Send 14 payment reminders
Medium riskThese invoices are more than 30 days past due and have had no reply to the first notice. Each customer gets the second notice template with the invoice attached.
- Northwind StudioINV-2041, 42 days$4,180.00
- Halcyon FreightINV-2017, 38 days$7,950.00
- Brightwater DentalINV-1996, 61 days$2,310.00
- Okafor & LaneINV-2052, 33 days$1,640.00
and 10 more
- Template
- Second notice, friendly
- From
- ar@ledgerline.app
- Send at
- Today, 15:00 in each customer's time zone
- Batch id
- rmd_2026_10_21_a7f3
"use client"
import * as React from "react"import { PencilIcon } from "lucide-react"
import { Approval, ApprovalActions, ApprovalDescription, ApprovalHeader, ApprovalItem, ApprovalItems, ApprovalParam, ApprovalParams, ApprovalRequester, ApprovalResolution, ApprovalRisk, ApprovalSection, ApprovalTitle,} from "@/components/ui/approval"import { Button } from "@/components/ui/button"import { Textarea } from "@/components/ui/textarea"
const invoices = [ { name: "Northwind Studio", invoice: "INV-2041, 42 days", amount: "$4,180.00" }, { name: "Halcyon Freight", invoice: "INV-2017, 38 days", amount: "$7,950.00" }, { name: "Brightwater Dental", invoice: "INV-1996, 61 days", amount: "$2,310.00" }, { name: "Okafor & Lane", invoice: "INV-2052, 33 days", amount: "$1,640.00" },]
function clock() { return new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}
export default function ApprovalInline() { const [status, setStatus] = React.useState<"pending" | "approved" | "rejected">("pending") const [rejecting, setRejecting] = React.useState(false) const [reason, setReason] = React.useState("") const [at, setAt] = React.useState("")
return ( <div className="flex w-full flex-col items-center gap-3"> <Approval status={status} risk="medium" className="max-w-lg"> <ApprovalHeader> <ApprovalRequester agent="Collections agent" time="2 min ago" /> <ApprovalTitle>Send 14 payment reminders</ApprovalTitle> <ApprovalRisk /> <ApprovalDescription> These invoices are more than 30 days past due and have had no reply to the first notice. Each customer gets the second notice template with the invoice attached. </ApprovalDescription> </ApprovalHeader> <ApprovalSection title="Recipients" action="$38,920.00 outstanding"> <ApprovalItems total={14}> {invoices.map((i) => ( <ApprovalItem key={i.name} title={i.name} description={i.invoice} value={i.amount} /> ))} </ApprovalItems> </ApprovalSection> <ApprovalSection title="What will be sent"> <ApprovalParams> <ApprovalParam label="Template">Second notice, friendly</ApprovalParam> <ApprovalParam label="From">ar@ledgerline.app</ApprovalParam> <ApprovalParam label="Send at">Today, 15:00 in each customer's time zone</ApprovalParam> <ApprovalParam label="Batch id" mono> rmd_2026_10_21_a7f3 </ApprovalParam> </ApprovalParams> </ApprovalSection>
{status === "pending" && rejecting && ( <ApprovalSection title="Why are you rejecting this?"> <Textarea autoFocus value={reason} onChange={(e) => setReason(e.target.value)} placeholder="The agent reads this before it tries again" className="min-h-16" /> </ApprovalSection> )}
{status === "pending" ? ( <ApprovalActions> {rejecting ? ( <> <Button variant="destructive" onClick={() => { setAt(clock()) setStatus("rejected") }} > Reject request </Button> <Button variant="ghost" onClick={() => setRejecting(false)}> Cancel </Button> </> ) : ( <> <Button onClick={() => { setAt(clock()) setStatus("approved") }} > Approve and send </Button> <Button variant="outline"> <PencilIcon data-icon="inline-start" /> Edit </Button> <Button variant="ghost" className="ms-auto" onClick={() => setRejecting(true)}> Reject </Button> </> )} </ApprovalActions> ) : ( <ApprovalResolution by="Maya Chen" at={at} reason={status === "rejected" ? reason || "No reason given." : undefined} /> )} </Approval> {status !== "pending" && ( <Button variant="link" size="sm" onClick={() => { setStatus("pending") setRejecting(false) setReason("") }} > Reset demo </Button> )} </div> )}In a dialog
For high risk actions that should interrupt. Pass variant="dialog" and give DialogContent p-0.
Waiting for a decision
"use client"
import * as React from "react"
import { Approval, ApprovalActions, ApprovalDescription, ApprovalHeader, ApprovalParam, ApprovalParams, ApprovalRequester, ApprovalRisk, ApprovalSection, ApprovalTitle,} from "@/components/ui/approval"import { Button } from "@/components/ui/button"import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
export default function ApprovalDialog() { const [open, setOpen] = React.useState(false) const [approved, setApproved] = React.useState(false)
return ( <div className="flex flex-col items-center gap-3"> <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger render={<Button variant="outline" />}>Review refund request</DialogTrigger> <DialogContent className="gap-0 p-0"> <Approval variant="dialog" risk="high"> <ApprovalHeader> <ApprovalRequester agent="Support agent" time="ticket #48213" /> <ApprovalTitle render={<DialogTitle />}>Refund $1,240.00 to Northwind Studio</ApprovalTitle> <ApprovalRisk>High risk, cannot be undone</ApprovalRisk> <ApprovalDescription> Northwind was billed for 31 seats after removing 8 in September. The agent matched the seat change in the audit log to the overcharge on the last two invoices. </ApprovalDescription> </ApprovalHeader> <ApprovalSection title="Refund"> <ApprovalParams> <ApprovalParam label="Amount">$1,240.00 to Visa ending 4417</ApprovalParam> <ApprovalParam label="Invoices">INV-2004, INV-2041</ApprovalParam> <ApprovalParam label="Reason">Seat count not updated after downgrade</ApprovalParam> <ApprovalParam label="Charge" mono> ch_3Qa4Lx2eZvKYlo2C </ApprovalParam> </ApprovalParams> </ApprovalSection> <ApprovalActions> <DialogClose render={<Button variant="ghost" />}>Reject</DialogClose> <Button variant="outline">Change amount</Button> <Button variant="destructive" onClick={() => { setApproved(true) setOpen(false) }} > Approve refund </Button> </ApprovalActions> </Approval> </DialogContent> </Dialog> <p className="text-[13px] text-muted-foreground" aria-live="polite"> {approved ? "Refund approved by Maya Chen. The agent is issuing it now." : "Waiting for a decision"} </p> </div> )}Resolved states
Approved, rejected with a reason, and expired.
Move 3 visits to Thursday
Low riskWrite off $6,400.00 for Halcyon Freight
High riskThey agreed to a payment plan on the call. Set up three monthly installments instead.
Pause the account for Brightwater Dental
Medium riskNobody decided within 24 hours, so nothing changed.
import { Approval, ApprovalHeader, ApprovalRequester, ApprovalResolution, ApprovalRisk, ApprovalTitle,} from "@/components/ui/approval"
export default function ApprovalResolved() { return ( <div className="mx-auto flex w-full max-w-lg flex-col gap-3"> <Approval status="approved" risk="low"> <ApprovalHeader> <ApprovalRequester agent="Dispatch agent" time="09:41" /> <ApprovalTitle>Move 3 visits to Thursday</ApprovalTitle> <ApprovalRisk /> </ApprovalHeader> <ApprovalResolution by="Maya Chen" at="14:02" /> </Approval> <Approval status="rejected" risk="high"> <ApprovalHeader> <ApprovalRequester agent="Billing agent" time="11:15" /> <ApprovalTitle>Write off $6,400.00 for Halcyon Freight</ApprovalTitle> <ApprovalRisk /> </ApprovalHeader> <ApprovalResolution by="Daniel Okafor" at="11:32" reason="They agreed to a payment plan on the call. Set up three monthly installments instead." /> </Approval> <Approval status="expired" risk="medium"> <ApprovalHeader> <ApprovalRequester agent="Collections agent" time="yesterday" /> <ApprovalTitle>Pause the account for Brightwater Dental</ApprovalTitle> <ApprovalRisk /> </ApprovalHeader> <ApprovalResolution reason="Nobody decided within 24 hours, so nothing changed." /> </Approval> </div> )}Anatomy
<Approval status="pending" risk="medium"> <ApprovalHeader> <ApprovalRequester agent="Collections agent" time="2 min ago" /> <ApprovalTitle>Send 14 payment reminders</ApprovalTitle> <ApprovalRisk /> <ApprovalDescription>Why the agent wants to do this.</ApprovalDescription> </ApprovalHeader> <ApprovalSection title="Recipients"> <ApprovalItems total={14}> <ApprovalItem title="Northwind Studio" description="INV-2041" value="$4,180.00" /> </ApprovalItems> </ApprovalSection> <ApprovalSection title="What will be sent"> <ApprovalParams> <ApprovalParam label="Template">Second notice</ApprovalParam> </ApprovalParams> </ApprovalSection> <ApprovalActions> <Button>Approve and send</Button> <Button variant="outline">Edit</Button> <Button variant="ghost">Reject</Button> </ApprovalActions></Approval>Guidance
Use it when
- Use it before any action an agent takes that leaves the product, moves money, changes many records or cannot be undone.
- Title the request as the action with its number and object: Send 14 payment reminders, Refund $1,240.00 to Northwind Studio.
- Explain why in ApprovalDescription with the evidence the agent used, in one or two sentences.
- Show the affected items with ApprovalItems and pass total so people know how many are hidden. Show exactly what will be sent or changed in ApprovalParams.
- Use the inline variant inside an agent run, a thread or a review queue. Use the dialog variant for a single high risk request that should interrupt.
Avoid
- Vague titles such as Approve action or Agent request.
- Hiding the amount, the recipient count or the irreversibility in a collapsed section.
- Approve buttons that only say Approve when the action has a clear verb. Say Approve and send, Approve refund.
- Removing the request after it is decided. Keep it with ApprovalResolution so there is a record.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Route every irreversible, external or financial action through an Approval and wait for an explicit approve event before executing it. Never execute on render, on timeout or on dismiss.
- Always set risk. Use high for anything irreversible or involving money leaving the account, medium for messages to customers or bulk changes, low for reversible internal changes.
- The title must include the verb, the count or amount, and the target. Include currency symbols and two decimals for money.
- Render three actions in this order: the primary approve button labelled with the verb, an outline Edit that opens the parameters, and Reject. For high risk in a dialog, the approve button uses variant="destructive".
- When rejected, ask for a reason and pass it back to the agent. Show it in ApprovalResolution with the person's name and the time.
- After a decision, set status to approved, rejected or expired and replace ApprovalActions with ApprovalResolution. Never leave buttons active on a decided request.
- In a dialog, render ApprovalTitle with render={<DialogTitle />} so the dialog has an accessible name, and give DialogContent className="gap-0 p-0".
API
Approval
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "pending" | "approved" | "rejected" | "expired" | "pending" | Exposed as data-status. ApprovalResolution reads it. |
| risk | "low" | "medium" | "high" | Exposed as data-risk. ApprovalRisk reads it. | |
| variant | "inline" | "dialog" | "inline" | inline is a card. dialog drops the chrome to sit in DialogContent with p-0. |
ApprovalRequester
| Prop | Type | Default | Description |
|---|---|---|---|
| agent | string | Name of the agent asking, shown with the AI mark. | |
| time | string | When it asked. |
ApprovalTitle
| Prop | Type | Default | Description |
|---|---|---|---|
| render | ReactElement | Render as DialogTitle inside a dialog. |
ApprovalRisk
| Prop | Type | Default | Description |
|---|---|---|---|
| level | "low" | "medium" | "high" | Defaults to the risk on Approval. Children replace the label. |
ApprovalItems
| Prop | Type | Default | Description |
|---|---|---|---|
| total | number | Total affected items. Adds an and N more line. |
ApprovalItem
| Prop | Type | Default | Description |
|---|---|---|---|
| title / description / value | ReactNode | Name, detail and a right aligned amount. |
ApprovalParam
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | The parameter name. Set mono for ids and raw values. |
ApprovalResolution
| Prop | Type | Default | Description |
|---|---|---|---|
| by / at / reason | string / string / ReactNode | Who decided, when, and why for rejections. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add approval# or, with the registry in components.jsonnpx shadcn@latest add @prodular/approval