{"slug":"tool-approval","category":"permissions","job":"oversee","added":"2026-09-11T22:24Z","title":"Tool Approval","description":"A pending tool-call prompt with allow, always-allow, and deny actions.","url":"https://ai-patterns.app/patterns/permissions/tool-approval","markdown":"https://ai-patterns.app/patterns/permissions/tool-approval.md","json":"https://ai-patterns.app/api/patterns/permissions/tool-approval","seenIn":[{"team":"Claude","source":"https://code.claude.com/docs/en/permissions"},{"team":"Warp","source":"https://docs.warp.dev/agents/local-agents/interacting-with-agents/terminal-and-agent-modes"},{"team":"Notion","source":"https://www.notion.com/help/choose-how-notion-agent-asks-for-your-approval"}],"spec":"# Tool Approval\n\n## Summary\nA 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.\n\n## When to use\n- 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).\n- 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.\n\n## When not to use\n- 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.\n- 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.\n- 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.\n\n## Anatomy\n- Icon: a small tinted box identifying the tool (terminal, globe, file, etc.).\n- Title + summary: the tool's name and a one-line plain-language description of the action.\n- 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.\n- 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.\n- 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.\n\n## Behavior\n- \"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.\n- 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.\n- Deny doesn't carry a scope; it always applies to just this one call. Permanently blocking a tool belongs in settings, not this prompt.\n- 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.\n\n## Content guidelines\n- Tool names are short and literal (\"Bash\", \"Web Search\"), not a marketing name for the underlying feature.\n- 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\").\n- Scope labels in the always-allow menu name what they cover concretely (\"this project\", \"this command\"), never vague terms like \"sometimes\".\n\n## Accessibility\n- 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.\n- The always-allow menu follows the standard disclosure pattern: `aria-haspopup`/`aria-expanded` on the trigger, and closes on Escape or an outside click.\n- 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.\n\n## Related patterns\n- **Diff Summary Card** — Use it instead after the fact, to summarize calls that already ran.\n- **Human in the Loop Question** — Use it instead when the agent needs an open choice rather than a yes or no on one call.\n- **Connector Panel** — Use it as well so people can grant standing permissions and see fewer prompts.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Check, ChevronDown, ShieldCheck, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ToolApprovalDecision = \"allow\" | \"always-allow\" | \"deny\";\n\nexport interface ToolApprovalScope {\n  id: string;\n  label: string;\n}\n\nexport interface ToolApprovalProps {\n  icon: React.ComponentType<{ className?: string }>;\n  toolName: string;\n  summary: string;\n  detail?: string;\n  scopes?: ToolApprovalScope[];\n  onDecision?: (decision: ToolApprovalDecision, scope?: ToolApprovalScope) => void;\n  className?: string;\n}\n\nconst defaultScopes: ToolApprovalScope[] = [\n  { id: \"command\", label: \"this command\" },\n  { id: \"project\", label: \"this project\" },\n  { id: \"always\", label: \"always\" },\n];\n\ninterface Resolution {\n  decision: ToolApprovalDecision;\n  scope?: ToolApprovalScope;\n}\n\nexport function ToolApproval({\n  icon: Icon,\n  toolName,\n  summary,\n  detail,\n  scopes = defaultScopes,\n  onDecision,\n  className,\n}: ToolApprovalProps) {\n  const [resolution, setResolution] = React.useState<Resolution | null>(null);\n  const [scopeOpen, setScopeOpen] = React.useState(false);\n  const scopeRef = useClickOutside<HTMLDivElement>(() => setScopeOpen(false));\n\n  function resolve(decision: ToolApprovalDecision, scope?: ToolApprovalScope) {\n    setScopeOpen(false);\n    setResolution({ decision, scope });\n    onDecision?.(decision, scope);\n  }\n\n  if (resolution) {\n    return <ResolvedRow toolName={toolName} resolution={resolution} className={className} />;\n  }\n\n  return (\n    <div className={cn(\"w-full overflow-hidden rounded-2xl border bg-card\", className)}>\n      <div className=\"flex items-start gap-3 px-4 py-3.5\">\n        <span className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground\">\n          <Icon className=\"size-4.5\" />\n        </span>\n        <div className=\"min-w-0 flex-1 pt-0.5\">\n          <p className=\"text-sm font-semibold\">{toolName}</p>\n          <p className=\"text-xs text-muted-foreground\">{summary}</p>\n        </div>\n      </div>\n\n      {detail && (\n        <div className=\"px-4 pb-3.5\">\n          <code className=\"block overflow-x-auto rounded-lg bg-muted px-3 py-2 font-mono text-xs\">{detail}</code>\n        </div>\n      )}\n\n      <div className=\"flex items-center gap-2 border-t px-4 py-3\">\n        <button\n          type=\"button\"\n          onClick={() => resolve(\"deny\")}\n          className=\"rounded-full px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n        >\n          Deny\n        </button>\n\n        <div className=\"ml-auto flex items-center gap-2\">\n          <div ref={scopeRef} className=\"relative shrink-0\">\n            <div className=\"flex items-center overflow-hidden rounded-full border\">\n              <button\n                type=\"button\"\n                onClick={() => resolve(\"always-allow\", scopes[0])}\n                className=\"px-3.5 py-1.5 text-xs font-medium transition-colors hover:bg-accent\"\n              >\n                Always allow\n              </button>\n              <button\n                type=\"button\"\n                aria-haspopup=\"menu\"\n                aria-expanded={scopeOpen}\n                aria-label=\"Choose scope for always allow\"\n                onClick={() => setScopeOpen((v) => !v)}\n                className=\"flex h-full items-center border-l px-2 py-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n              >\n                <ChevronDown className={cn(\"size-3.5 transition-transform\", scopeOpen && \"rotate-180\")} />\n              </button>\n            </div>\n            <AnimatePresence>\n              {scopeOpen && (\n                <motion.div\n                  role=\"menu\"\n                  initial={{ opacity: 0, y: 4 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, y: 4 }}\n                  transition={{ duration: 0.12 }}\n                  className=\"absolute bottom-full right-0 z-10 mb-2 w-44 overflow-hidden rounded-xl border bg-popover\"\n                >\n                  {scopes.map((scope) => (\n                    <button\n                      key={scope.id}\n                      type=\"button\"\n                      role=\"menuitem\"\n                      onClick={() => resolve(\"always-allow\", scope)}\n                      className=\"block w-full px-3 py-2 text-left text-xs hover:bg-accent\"\n                    >\n                      Always allow for {scope.label}\n                    </button>\n                  ))}\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n\n          <button\n            type=\"button\"\n            onClick={() => resolve(\"allow\")}\n            className=\"rounded-full bg-foreground px-4 py-1.5 text-xs font-medium text-background transition-colors hover:bg-foreground/90\"\n          >\n            Allow\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ResolvedRow({\n  toolName,\n  resolution,\n  className,\n}: {\n  toolName: string;\n  resolution: Resolution;\n  className?: string;\n}) {\n  const label =\n    resolution.decision === \"deny\"\n      ? \"Denied\"\n      : resolution.decision === \"always-allow\"\n        ? `Always allowed for ${resolution.scope?.label ?? \"this project\"}`\n        : \"Allowed\";\n\n  return (\n    <div className={cn(\"flex items-center gap-3 rounded-2xl border bg-card px-4 py-3\", className)}>\n      <span\n        className={cn(\n          \"flex size-8 shrink-0 items-center justify-center rounded-full\",\n          resolution.decision === \"deny\"\n            ? \"bg-red-100 text-red-600 dark:bg-red-500/15 dark:text-red-400\"\n            : \"bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400\"\n        )}\n      >\n        {resolution.decision === \"deny\" ? (\n          <X className=\"size-4\" />\n        ) : resolution.decision === \"always-allow\" ? (\n          <ShieldCheck className=\"size-4\" />\n        ) : (\n          <Check className=\"size-4\" />\n        )}\n      </span>\n      <div className=\"min-w-0 flex-1\">\n        <p className=\"text-xs font-medium\">{toolName}</p>\n        <p className=\"text-[11px] text-muted-foreground\">{label}</p>\n      </div>\n    </div>\n  );\n}\n\nfunction useClickOutside<T extends HTMLElement>(onOutside: () => void) {\n  const ref = React.useRef<T>(null);\n  React.useEffect(() => {\n    function handle(e: MouseEvent) {\n      if (ref.current && !ref.current.contains(e.target as Node)) onOutside();\n    }\n    document.addEventListener(\"mousedown\", handle);\n    return () => document.removeEventListener(\"mousedown\", handle);\n  }, [onOutside]);\n  return ref;\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}