AI Patterns

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.