Approval Gate
AI / agentA standalone human gate for agent actions: the expiry bar drains in real time, and every terminal state is designed — approved, rejected, modified-then-approved, and expired.
Agent requests approval
Send refund confirmation to the customer
Hi Asha — your refund of ₹2,499 for order #8412 has been initiated and will reach your account in 5–7 working days.
- Recipient
- asha@example.com
- Channel
- Transactional email
- Triggered by
- agent · refund workflow
Expires in 45s — the agent stays paused, nothing is sent without a decision.
Install
npx shadcn@latest add https://labs.duku.design/r/approval-gate.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Approval Gate component (get_component "approval-gate", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Watch the bar | Drains linearly; turns destructive in the final third; hitting zero lands the designed expired state |
| Modify | The payload becomes editable; approving sends your version, marked 'edited by reviewer' |
| Reject | Nothing is sent; the copy states what the agent does next |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| action / payload | string / string | — | What the agent wants to do and the exact content being approved. |
| details | [string, string][] | — | Key-value rows (recipients, cost…). |
| expiresInSec | number | 60 | Countdown to the expired state. |
| onDecision | (state, payload) => void | — | approved / modified / rejected / expired with the final payload. |
Source — registry/default/ai/approval-gate.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import { Button } from "@/registry/default/ui/button";
/* ------------------------------------------------------------------ */
/* Model */
/* ------------------------------------------------------------------ */
export type ApprovalState =
| "pending"
| "approved"
| "modified"
| "rejected"
| "expired";
export interface ApprovalGateProps
extends React.HTMLAttributes<HTMLDivElement> {
/** What the agent wants to do, imperative. */
action: string;
/** The risky payload the human is approving — editable via Modify. */
payload: string;
details?: [string, string][];
/** Seconds until the request expires. Default 60. */
expiresInSec?: number;
onDecision?: (state: ApprovalState, payload: string) => void;
ref?: React.Ref<HTMLDivElement>;
}
const STATE_META: Record<
ApprovalState,
{ label: string; cls: string; border: string }
> = {
pending: { label: "Awaiting approval", cls: "text-agent-waiting", border: "border-agent-waiting/50" },
approved: { label: "Approved", cls: "text-success", border: "border-success/50" },
modified: { label: "Approved with edits", cls: "text-success", border: "border-success/50" },
rejected: { label: "Rejected", cls: "text-muted-foreground", border: "border-border" },
expired: { label: "Expired — agent paused", cls: "text-destructive", border: "border-destructive/50" },
};
/* ------------------------------------------------------------------ */
/* Gate */
/* ------------------------------------------------------------------ */
/**
* The human approval gate: an agent's outward-facing action paused for a
* person. The expiry bar drains in real time; Approve, Reject, or Modify
* the payload and approve the edited version — every terminal state is
* designed, including "nobody answered".
*/
export function ApprovalGate({
action,
payload,
details = [],
expiresInSec = 60,
onDecision,
className,
ref,
...rest
}: ApprovalGateProps) {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const barRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
const [state, setState] = React.useState<ApprovalState>("pending");
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(payload);
const [remaining, setRemaining] = React.useState(expiresInSec);
const decide = (next: ApprovalState) => {
setState(next);
setEditing(false);
onDecision?.(next, draft);
};
/* Countdown: the bar drains linearly; hitting zero expires the gate. */
React.useEffect(() => {
if (state !== "pending") return;
const id = setInterval(() => {
setRemaining((r) => {
if (r <= 1) {
clearInterval(id);
setState("expired");
onDecision?.("expired", draft);
return 0;
}
return r - 1;
});
}, 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
useGSAP(
() => {
if (!barRef.current) return;
const pct = (remaining / expiresInSec) * 100;
if (reduced) gsap.set(barRef.current, { width: `${pct}%` });
else gsap.to(barRef.current, { width: `${pct}%`, duration: 1, ease: "none", overwrite: true });
},
{ dependencies: [remaining, expiresInSec, reduced] }
);
const meta = STATE_META[state];
const settled = state !== "pending";
return (
<div
ref={rootRef}
data-slot="approval-gate"
className={cn(
"w-full max-w-md rounded-2xl border bg-card p-4 transition-colors duration-300",
meta.border,
className
)}
{...rest}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Agent requests approval
</p>
<p className="text-sm font-semibold leading-5 text-foreground">{action}</p>
</div>
<span className={cn("flex shrink-0 items-center gap-1.5 text-xs font-semibold", meta.cls)} aria-live="polite">
{state === "pending" ? (
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-agent-waiting opacity-60" />
<span className="relative inline-flex size-2 rounded-full bg-agent-waiting" />
</span>
) : null}
{meta.label}
</span>
</div>
{/* Payload */}
<div className="mt-3">
{editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={4}
aria-label="Edit the payload before approving"
className="w-full rounded-lg border border-input bg-background p-2.5 text-[12px] leading-5 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
) : (
<blockquote
className={cn(
"rounded-lg border-l-2 bg-muted/50 px-3 py-2 text-[12px] leading-5",
draft !== payload ? "border-info text-foreground" : "border-border text-foreground",
state === "rejected" && "opacity-50"
)}
>
{draft}
{draft !== payload ? (
<span className="mt-1 block text-[10px] font-medium text-info">
edited by reviewer
</span>
) : null}
</blockquote>
)}
</div>
{/* Details */}
{details.length > 0 ? (
<dl className="mt-2 flex flex-col gap-0.5 text-[11px] tabular-nums">
{details.map(([k, v]) => (
<div key={k} className="flex justify-between gap-2">
<dt className="text-muted-foreground">{k}</dt>
<dd className="text-right text-foreground">{v}</dd>
</div>
))}
</dl>
) : null}
{/* Expiry / outcome */}
{state === "pending" ? (
<>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<span
ref={barRef}
className={cn(
"block h-full rounded-full",
remaining / expiresInSec > 0.33 ? "bg-agent-waiting" : "bg-destructive"
)}
style={{ width: "100%" }}
/>
</div>
<p className="mt-1 text-[10px] tabular-nums text-muted-foreground">
Expires in {remaining}s — the agent stays paused, nothing is sent
without a decision.
</p>
<div className="mt-3 flex gap-2">
{editing ? (
<>
<Button size="sm" variant="outline" onClick={() => { setDraft(payload); setEditing(false); }}>
Discard edits
</Button>
<Button size="sm" className="flex-1" onClick={() => decide("modified")}>
Approve edited version
</Button>
</>
) : (
<>
<Button size="sm" variant="outline" onClick={() => decide("rejected")}>
Reject
</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
Modify
</Button>
<Button size="sm" className="flex-1" onClick={() => decide("approved")}>
Approve
</Button>
</>
)}
</div>
</>
) : (
<p className="mt-3 rounded-lg bg-muted/50 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground">
{state === "approved" && "The agent is executing the approved action."}
{state === "modified" && "The agent is executing your edited version — the original was not sent."}
{state === "rejected" && "Nothing was sent. The agent will report the rejection and stop this branch."}
{state === "expired" && "No decision arrived in time. The action was not taken; the agent is waiting for a human to resume."}
</p>
)}
</div>
);
}