A run log for agent tool calls: the step in flight opens itself, writes its output a line at a time, then folds away and goes still.
Approval Gate
An inline request from an agent to run a command: the tool, the command, a clock that holds while you read, and two keys to answer with.
ApprovalGate is the moment an agent stops and asks. Where ToolTrace is what a run looks like from the inside, this is the one step in it that cannot happen without a person: the tool it wants, the command it would give, and two ways to answer. At rest it is a still card, and a quiet one — a title, a word beside it, the command on a wash of the page's own ink, and the two answers. The one thing that moves while it waits is the clock, a hairline ring around the marker draining clockwise from twelve with the seconds left set inside it, and it holds whenever the reader is plainly reading: pointer over the card, focus inside it, or the card scrolled out of view, because a timeout is a safety net for a gate nobody is looking at rather than a race against the person in front of it. Hue is spent only where there is risk. A request that cannot break anything waits in the page's own ink with the word “Awaiting” beside it; one that might waits in caution and says so; one that will waits in critical and says “Destructive”. A decision is one move each way: allowing lets the buttons go and sets a tick in the ring; denying draws a strike through the command, left to right, and takes the ink out of it as it goes; letting the clock run out does neither — the ring goes dashed, the command dims, and the word says so. The keys are declared on the buttons through aria-keyshortcuts and shown beside the labels only while they would actually work, every outcome carries a glyph and a spoken word as well as a hue, and the whole thing resolves instantly under prefers-reduced-motion.
Preview
The agent has stopped and asked. The ring is the time it will wait, and it holds while the pointer is over the card or focus is inside it. Click into the card and the keys come on: Y allows, N denies. Denying strikes the command through.
Approval: Reinstall the dependencies
DestructiveonDecision — waiting
risk="low", icon, no clockA request that cannot break anything waits in the page's own ink, without a clock, for as long as it takes. An icon of your own sits in the marker instead of the pip. Small, and plain, so it lives inside a surface you already own.
Approval: Read the open pull requests
AwaitingA gate rendered with its outcome already known is a still drawing: nothing replays. The struck command is what a refusal leaves behind, and the dashed ring is a clock that ran out with nobody there.
Approval: Rewrite the remote history
DeniedApproval: Publish the package
ExpiredInstallation
Install Approval Gate with the shadcn CLI. The registry item resolves its own dependencies and writes the file to components/joinui/approval-gate.tsx.
pnpm dlx shadcn@latest add @joinui/approval-gateUsage
import { ApprovalGate } from "@/components/joinui/approval-gate"
export function Example() {
return (
<ApprovalGate
title="Reinstall the dependencies"
reason="The lockfile no longer matches node_modules."
tool="bash"
meta="~/join-ui"
prefix="$"
command="rm -rf node_modules .next && pnpm install"
risk="high"
timeout={30_000}
onDecision={(decision) => {
if (decision === "allow") run()
}}
/>
)
}Props
ApprovalGate
| Prop | Type | Default | Description |
|---|---|---|---|
| title (required) | string | — | What the agent wants to do, in a few words. Names the group. |
| reason | React.ReactNode | — | Why it wants to. One line under the title, in the muted face. |
| tool | string | — | The tool the agent would call — bash, git, fetch. Set in mono under the command. |
| command | string | string[] | — | The command itself. An array is one entry per line; a long entry wraps. Leave it out for a request that is not a command. |
| meta | string | — | Set beside the tool under the command — a working directory, a branch, a host. |
| prefix | string | — | A prompt glyph set before each line — `$`, `›`. Kept out of the selection, so a copied command does not arrive with it, and out of the strike. |
| risk | "low" | "medium" | "high" | "low" | Picks the hue and the word beside the title: the page's own ink and “Awaiting”, caution and “Caution”, critical and “Destructive”. |
| riskLabel | string | — | Overrides the word set beside the title while the gate waits. |
| icon | React.ReactNode | — | Replaces the marker's pip while the gate waits without a clock. Sized by the component, so pass a bare icon element. |
| timeout | number | — | Milliseconds the reader has before the gate expires on its own. Unset, it waits for as long as it takes. |
| pauseWhileReading | boolean | true | Holds the clock while the pointer is over the gate or focus is inside it. The clock always holds while the gate is off screen. |
| decision | "allow" | "deny" | "expire" | null | — | Controlled outcome. `null` is a gate still waiting; setting it back to `null` asks again, with the clock re-armed. |
| defaultDecision | "allow" | "deny" | "expire" | null | null | Where an uncontrolled gate starts. |
| onDecision | (decision: "allow" | "deny" | "expire") => void | — | Fires with what was decided — by hand, by key, or by the clock, which reports `expire`. |
| allowLabel | string | "Allow" | The label of the button that says yes. |
| denyLabel | string | "Deny" | The label of the button that says no. |
| shortcuts | { allow?: string[]; deny?: string[] } | { allow: ["y"], deny: ["n", "Escape"] } | The keys. A single character matches in either case; anything else is compared to `event.key` exactly. The first of each list is the hint shown after the button's label. |
| hotkeys | "focus" | "global" | "off" | "focus" | Where the keys are listened for: inside the gate, anywhere on the page, or nowhere. Global leaves fields alone and stops listening once the gate has resolved. |
| autoFocus | boolean | false | Focuses the gate on mount so the keys work at once. Off by default, because a gate that arrives mid-conversation should not pull focus from what the reader was doing. |
| label | string | "Approval" | The first word of the accessible name, and of the announcement. |
| announce | boolean | true | Announces the request and its outcome through a polite live region. Turn it off for a gate rendered at rest, or when several share a page. |
| footer | React.ReactNode | — | Content placed below a rule — a note, a link to the run. |
| variant | "card" | "plain" | "card" | Plain drops the surrounding rule, background and padding so the gate can sit inside your own container. |
| size | "sm" | "md" | "md" | Marker, type, button and spacing scale. |
| className | string | — | Merged onto the root element through `cn`. |
Dependencies
npm packages
- motion
- lucide-react
Registry items
- utils
Installed automatically by the CLI when they are missing.
Accessibility
- The gate is a `group` named by its title, with the `label` — “Approval” by default — spoken first, so a screen reader hears “Approval: Reinstall the dependencies” before anything else.
- Its arrival is announced. A live region reports changes rather than content that was there on load, so the request is written into it a frame after the gate renders — which is what gets a gate that appears mid-conversation announced at all — and the outcome replaces it once there is one.
- Both answers are real `button` elements, and each declares its keys through `aria-keyshortcuts`, which is the attribute assistive technology reads shortcuts from. The hint set after each label is decoration and hidden; it is shown only while the keys would work — always for global keys, otherwise once focus is inside the gate — so it never promises a key that does nothing.
- The keys never fight the buttons: a key that would activate the focused button anyway — Enter, Space — is left to it, so an answer is never given twice, and a shortcut typed into a field is left alone entirely.
- The countdown is a `timer` whose visible number is followed by a hidden “of 30 seconds left”, and it is not live: a clock announcing every second is noise, and the time allowed is already in the announcement.
- A time limit on a decision is only acceptable if the person can defeat it, which is WCAG 2.2.1. The clock holds while focus is inside the gate, so a keyboard user who has reached the buttons is never timed out of them, and it holds under the pointer for the same reason. Leave `timeout` unset and there is no limit at all.
- Colour is never the only signal, which satisfies WCAG 1.4.1: the risk is a word beside the title as well as a hue, and every outcome is a glyph in the ring and a word beside the title — a tick, a cross, an hourglass; “Allowed”, “Denied”, “Expired”.
- A denied command is struck through, not merely dimmed, so the refusal survives a monochrome print. The struck copy is a second rendering laid over the first and hidden from assistive technology; the plain text underneath is what gets read, and it is the text you can select.
- Focus is kept. When a decision removes the buttons, focus that was on one of them is handed to the gate itself rather than dropped to the document — the gate is focusable for that reason, and so that the keys work after a click on it.
- The card does not change height under a decision: the row the answers sat in stays, with the tool and its meta still in it, so nothing below the gate jumps as it resolves.
- The clock runs on the frame loop, so it stops in a background tab and picks up where it left off, and each step is capped so that one long frame on return cannot expire a gate the reader never saw.
- Nothing animates on mount: a gate rendered with its outcome already known is a still drawing, and a struck command does not strike itself again on load.
- Under `prefers-reduced-motion` every transition resolves at zero duration — the ring steps once a second instead of sweeping, the strike appears whole, and the answers simply go.
- Both themes are covered by the tokens rather than by `dark:` variants, so the component keeps its contrast inside a forced-theme subtree.
Keyboard interactions
| Key | Behaviour |
|---|---|
| Y | Allows. The default; set your own through `shortcuts`. |
| NEsc | Denies. |
| Tab | Moves between Deny and Allow. The gate itself is never a tab stop — it takes focus only from a click, from `autoFocus`, or when a decision removes the button that had it — so it adds nothing to the page's tab order. |
| EnterSpace | Activates the focused button, as a button does. Never read as a shortcut while a button has focus, so nothing is answered twice. |
Customization
Wire it into a run
The gate knows nothing about the run — it reports what was decided and draws what you tell it — so wiring it into an agent loop is a matter of resolving the pending tool call from onDecision. Expire is the third answer and it is a no: treat it as a deny unless your policy says otherwise. Keep decision controlled, so a gate that survives a re-render still shows what was chosen, and so asking again is a matter of clearing it.
const [decision, setDecision] = React.useState<ApprovalGateDecision | null>(null)
<ApprovalGate
title={request.title}
reason={request.reason}
tool={request.tool}
command={request.command}
risk={request.risk}
timeout={60_000}
decision={decision}
onDecision={(next) => {
setDecision(next)
resume(request.id, next === "allow")
}}
/>Answer from anywhere
In a console-shaped app the reader expects Enter to say yes without first finding the card. Global keys listen on the document while the gate waits and leave anything typed into a field alone, and the hints stay on because the keys are always live. Pair them with the keys your shell would use, and let the gate take focus on arrival so the outline shows where an answer is going.
<ApprovalGate
title="Run the test suite"
tool="bash"
command="pnpm test"
hotkeys="global"
autoFocus
shortcuts={{ allow: ["Enter", "y"], deny: ["Escape", "n"] }}
/>No clock, no hurry
A request that cannot break anything has no business expiring. Leave timeout unset and the gate waits in the page's own ink for as long as it takes, with an icon of your own in the marker instead of the pip. Small and plain, it sits inside a message bubble or a sidebar without bringing a card of its own.
import { Globe } from "lucide-react"
<ApprovalGate
size="sm"
variant="plain"
title="Read the open pull requests"
tool="fetch"
meta="read-only"
command="GET https://api.github.com/repos/d1maash/join-ui/pulls?state=open"
icon={<Globe />}
risk="low"
/>Say what the answer does
“Allow” and “Deny” are the honest defaults, but for a request with consequences the buttons read better when they name them, and the word beside the title can be the consequence too. The hue and the ring stay with the risk; only the words change.
<ApprovalGate
title="Rewrite the remote history"
tool="git"
meta="main"
prefix="$"
command="git push --force origin main"
risk="high"
riskLabel="Irreversible"
allowLabel="Push anyway"
denyLabel="Keep history"
/>Render the receipt
A gate loaded from history is a document, not a question. Pass the decision it ended with and it renders at rest — struck through if it was denied, dashed if it lapsed — with no buttons, no clock and, with announce off, nothing said. Nothing replays on mount.
<ApprovalGate
size="sm"
variant="plain"
title="Publish the package"
tool="bash"
command={["pnpm build", "pnpm publish --access public"]}
risk="medium"
timeout={20_000}
decision="expire"
announce={false}
/>Retint the risks, and the surface
The hues read the component palette rather than literal colours, so a brand's warning colour is a token override — no props to thread and no variants to add. The command's surface is a wash of the theme's own ink over the card, which is what lets one declaration cover both themes; redeclare it only if you want a console that is a surface in its own right.
/* app/globals.css */
:root,
.light {
/* An amber caution instead of the default ochre. */
--caution: oklch(0.6 0.15 60);
--caution-soft: oklch(0.965 0.04 70);
--caution-foreground: oklch(0.99 0.008 60);
/* Any CSS colour. The default is 4% of the foreground over whatever is behind it. */
--approval-gate-command: oklch(0.21 0.011 48 / 0.04);
}
.dark {
--caution: oklch(0.84 0.14 70);
--caution-soft: oklch(0.27 0.05 60);
--caution-foreground: oklch(0.18 0.035 60);
--approval-gate-command: oklch(0 0 0 / 0.25);
}Related components
A honeycomb of frosted glass with one tinted tile that travels to whatever you pick, an action, and the queue of runs it dispatches.