permissions
Tool Approval
A pending tool-call prompt with allow, always-allow, and deny actions.
Bash
Run a shell command
npm install lodash"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, ChevronDown, ShieldCheck, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type ToolApprovalDecision = "allow" | "always-allow" | "deny";
export interface ToolApprovalScope {
id: string;
label: string;
}
export interface ToolApprovalProps {
icon: React.ComponentType<{ className?: string }>;
toolName: string;
summary: string;
detail?: string;
scopes?: ToolApprovalScope[];
onDecision?: (decision: ToolApprovalDecision, scope?: ToolApprovalScope) => void;
className?: string;
}
const defaultScopes: ToolApprovalScope[] = [
{ id: "command", label: "this command" },
{ id: "project", label: "this project" },
{ id: "always", label: "always" },
];
interface Resolution {
decision: ToolApprovalDecision;
scope?: ToolApprovalScope;
}
export function ToolApproval({
icon: Icon,
toolName,
summary,
detail,
scopes = defaultScopes,
onDecision,
className,
}: ToolApprovalProps) {
const [resolution, setResolution] = React.useState<Resolution | null>(null);
const [scopeOpen, setScopeOpen] = React.useState(false);
const scopeRef = useClickOutside<HTMLDivElement>(() => setScopeOpen(false));
function resolve(decision: ToolApprovalDecision, scope?: ToolApprovalScope) {
setScopeOpen(false);
setResolution({ decision, scope });
onDecision?.(decision, scope);
}
if (resolution) {
return <ResolvedRow toolName={toolName} resolution={resolution} className={className} />;
}
return (
<div className={cn("w-full overflow-hidden rounded-2xl border bg-card", className)}>
<div className="flex items-start gap-3 px-4 py-3.5">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground">
<Icon className="size-4.5" />
</span>
<div className="min-w-0 flex-1 pt-0.5">
<p className="text-sm font-semibold">{toolName}</p>
<p className="text-xs text-muted-foreground">{summary}</p>
</div>
</div>
{detail && (
<div className="px-4 pb-3.5">
<code className="block overflow-x-auto rounded-lg bg-muted px-3 py-2 font-mono text-xs">{detail}</code>
</div>
)}
<div className="flex items-center gap-2 border-t px-4 py-3">
<button
type="button"
onClick={() => resolve("deny")}
className="rounded-full px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Deny
</button>
<div className="ml-auto flex items-center gap-2">
<div ref={scopeRef} className="relative shrink-0">
<div className="flex items-center overflow-hidden rounded-full border">
<button
type="button"
onClick={() => resolve("always-allow", scopes[0])}
className="px-3.5 py-1.5 text-xs font-medium transition-colors hover:bg-accent"
>
Always allow
</button>
<button
type="button"
aria-haspopup="menu"
aria-expanded={scopeOpen}
aria-label="Choose scope for always allow"
onClick={() => setScopeOpen((v) => !v)}
className="flex h-full items-center border-l px-2 py-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronDown className={cn("size-3.5 transition-transform", scopeOpen && "rotate-180")} />
</button>
</div>
<AnimatePresence>
{scopeOpen && (
<motion.div
role="menu"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.12 }}
className="absolute bottom-full right-0 z-10 mb-2 w-44 overflow-hidden rounded-xl border bg-popover"
>
{scopes.map((scope) => (
<button
key={scope.id}
type="button"
role="menuitem"
onClick={() => resolve("always-allow", scope)}
className="block w-full px-3 py-2 text-left text-xs hover:bg-accent"
>
Always allow for {scope.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<button
type="button"
onClick={() => resolve("allow")}
className="rounded-full bg-foreground px-4 py-1.5 text-xs font-medium text-background transition-colors hover:bg-foreground/90"
>
Allow
</button>
</div>
</div>
</div>
);
}
function ResolvedRow({
toolName,
resolution,
className,
}: {
toolName: string;
resolution: Resolution;
className?: string;
}) {
const label =
resolution.decision === "deny"
? "Denied"
: resolution.decision === "always-allow"
? `Always allowed for ${resolution.scope?.label ?? "this project"}`
: "Allowed";
return (
<div className={cn("flex items-center gap-3 rounded-2xl border bg-card px-4 py-3", className)}>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full",
resolution.decision === "deny"
? "bg-red-100 text-red-600 dark:bg-red-500/15 dark:text-red-400"
: "bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400"
)}
>
{resolution.decision === "deny" ? (
<X className="size-4" />
) : resolution.decision === "always-allow" ? (
<ShieldCheck className="size-4" />
) : (
<Check className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">{toolName}</p>
<p className="text-[11px] text-muted-foreground">{label}</p>
</div>
</div>
);
}
function useClickOutside<T extends HTMLElement>(onOutside: () => void) {
const ref = React.useRef<T>(null);
React.useEffect(() => {
function handle(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) onOutside();
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [onOutside]);
return ref;
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Tool Approval
## Summary
A pending permission prompt shown before an agent executes a tool call it doesn't already have standing approval for: the tool's name, a plain-language summary of what it's about to do, the literal call (command, path, args) it will run, and three ways to respond — deny it, allow it once, or always allow it going forward. Once answered, it collapses into a compact resolved row so the transcript keeps moving.
## When to use
- Before any tool call whose effect is irreversible, external, or otherwise outside the trust the agent already has (running a shell command, calling a paid API, writing outside the project, sending a message on the user's behalf).
- Inline in an agent transcript or chat UI, at the point the call would happen — not as an app-blocking modal, since the user usually wants the surrounding conversation still visible while deciding.
## When not to use
- For actions the user already granted standing permission for — show them running, not asking again. Re-prompting after "always allow" erodes trust in the setting.
- For read-only, side-effect-free calls (e.g. re-reading a file already in context) where the friction outweighs the risk — gate only what actually needs gating.
- As a generic confirm dialog for non-tool actions (e.g. "delete this message?"). Use a plain confirmation pattern instead; this one is specifically a pending tool call with a scope decision attached.
## Anatomy
- Icon: a small tinted box identifying the tool (terminal, globe, file, etc.).
- Title + summary: the tool's name and a one-line plain-language description of the action.
- Detail block: the literal call being made (a shell command, a URL, a file path) in monospace, so the user can verify exactly what will run rather than trusting the summary alone.
- Action row: Deny, Always allow (with a scope picker), and Allow — deny nearest the reading start, the two affirmative actions grouped on the trailing side.
- Resolved state: once answered, the whole card collapses to a single row — a status icon and a short label ("Allowed", "Always allowed for this project", "Denied") — replacing the action row entirely.
## Behavior
- "Always allow" is a split control: clicking the label applies a default scope immediately; the attached chevron opens a short menu of narrower/wider scopes (e.g. "this command", "this project", "always") so precision doesn't cost extra clicks in the common case.
- A decision is terminal for this prompt — there's no separate confirm step after clicking one of the three actions, and none of the actions stay interactive once a decision is recorded.
- Deny doesn't carry a scope; it always applies to just this one call. Permanently blocking a tool belongs in settings, not this prompt.
- The detail block shows the actual call verbatim (real command, real path), never a paraphrase — this is the one place the user gets to verify before it runs.
## Content guidelines
- Tool names are short and literal ("Bash", "Web Search"), not a marketing name for the underlying feature.
- The summary states the action, not the agent's justification for it ("Run a shell command", not "I need to check if the tests pass").
- Scope labels in the always-allow menu name what they cover concretely ("this project", "this command"), never vague terms like "sometimes".
## Accessibility
- The three (or more, with scope options) actions must be real, focusable buttons — a keyboard-only user needs to reach Deny as easily as Allow.
- The always-allow menu follows the standard disclosure pattern: `aria-haspopup`/`aria-expanded` on the trigger, and closes on Escape or an outside click.
- Don't rely on color alone to distinguish Allow from Deny — the label text already carries the meaning, so keep it even under custom theming.
## Related patterns
- Diff Summary Card is the after-the-fact counterpart — this pattern gates a call before it runs, that one summarizes calls that already ran.
- Prompt Bar's chip-with-chevron dropdown is the same disclosure idiom used here for the scope picker.