Tool Approval
A human-in-the-loop permission card for reviewing tool details, allowing once, remembering access, or denying execution.
Preview
Allow this tool to run?
terminal.run
The agent wants to run the project test suite in the current workspace.
- Command
bun test tests/a11y.test.tsx- Directory
- ui-components
TSXcomponents/previews/agents/tool-approval.preview.tsx
"use client";
import { RotateCcw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ToolApproval,
ToolApprovalCode,
type ToolApprovalStatus,
} from "@/components/agents/tool-approval";
export function ToolApprovalPreview() {
const [status, setStatus] = useState<ToolApprovalStatus>("pending");
const [detailsOpen, setDetailsOpen] = useState(true);
const timers = useRef<number[]>([]);
const clearTimers = useCallback(() => {
timers.current.forEach(window.clearTimeout);
timers.current = [];
}, []);
useEffect(() => () => clearTimers(), [clearTimers]);
const finish = (next: ToolApprovalStatus) => {
clearTimers();
setStatus(next);
};
const approve = () => {
clearTimers();
setStatus("approving");
timers.current = [
window.setTimeout(() => setStatus("approved"), 600),
window.setTimeout(() => setStatus("running"), 1150),
window.setTimeout(() => setStatus("complete"), 2200),
];
};
const replay = () => {
clearTimers();
setStatus("pending");
setDetailsOpen(true);
};
return (
<div className="relative h-[360px] w-full max-w-lg">
<ToolApproval
tool="terminal.run"
title={status === "pending" ? "Allow this tool to run?" : "Terminal access"}
description="The agent wants to run the project test suite in the current workspace."
status={status}
open={detailsOpen}
onOpenChange={setDetailsOpen}
parameters={[
{
id: "command",
label: "Command",
value: (
<ToolApprovalCode
code="bun test tests/a11y.test.tsx"
language="bash"
/>
),
},
{ id: "directory", label: "Directory", value: "ui-components" },
]}
onApprove={approve}
onAlwaysAllow={approve}
onDeny={() => finish("denied")}
/>
<button
type="button"
onClick={replay}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
TSXcomponents/agents/tool-approval.tsx
"use client";
// beui.dev/components/agents/tool-approval
import {
Check,
ChevronDown,
CircleAlert,
LoaderCircle,
ShieldCheck,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import {
AgentCode,
type AgentCodeLanguage,
} from "@/components/agents/agent-code";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ToolApprovalStatus =
| "pending"
| "approving"
| "approved"
| "denied"
| "running"
| "complete"
| "error";
export interface ToolApprovalParameter {
id: string;
label: ReactNode;
value: ReactNode;
}
export interface ToolApprovalCodeProps {
code: string;
language?: AgentCodeLanguage;
className?: string;
}
export interface ToolApprovalProps {
tool: ReactNode;
title?: ReactNode;
description?: ReactNode;
parameters?: ToolApprovalParameter[];
status?: ToolApprovalStatus;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onApprove?: () => void;
onAlwaysAllow?: () => void;
onDeny?: () => void;
className?: string;
}
function getStatusCopy(status: ToolApprovalStatus) {
if (status === "approving") return "Approving";
if (status === "approved") return "Approved";
if (status === "denied") return "Denied";
if (status === "running") return "Running";
if (status === "complete") return "Completed";
if (status === "error") return "Failed";
return "Approval required";
}
function getStatusBadgeClass(status: ToolApprovalStatus) {
if (status === "pending") {
return "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400";
}
if (status === "approving" || status === "running") {
return "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400";
}
if (status === "approved" || status === "complete") {
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
}
return "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400";
}
export function ToolApprovalCode({
code,
language = "bash",
className,
}: ToolApprovalCodeProps) {
return (
<AgentCode
code={code}
language={language}
className={cn(
"rounded-lg border border-border/50 bg-muted/30 px-2.5 py-2",
className,
)}
/>
);
}
export function ToolApproval({
tool,
title = "Allow this tool to run?",
description,
parameters = [],
status = "pending",
open,
defaultOpen = false,
onOpenChange,
onApprove,
onAlwaysAllow,
onDeny,
className,
}: ToolApprovalProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const detailsId = `${baseId}-details`;
const previousStatus = useRef(status);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
const busy = status === "approving" || status === "running";
const pending = status === "pending";
const error = status === "error";
useEffect(() => {
if (previousStatus.current === "pending" && status !== "pending") {
setOpen(false);
}
previousStatus.current = status;
}, [setOpen, status]);
return (
<div
data-state={status}
aria-busy={busy}
className={cn(
"w-full overflow-hidden rounded-2xl border border-border/60 bg-muted/20 text-sm",
className,
)}
>
<div className="flex items-start gap-3 p-4">
<span
aria-hidden="true"
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-xl border border-border/60 bg-background text-muted-foreground",
error && "text-destructive",
)}
>
{busy ? (
<LoaderCircle className={cn("size-4", !reduce && "animate-spin")} />
) : error ? (
<CircleAlert className="size-4" />
) : status === "denied" ? (
<X className="size-4" />
) : status === "approved" || status === "complete" ? (
<Check className="size-4" />
) : (
<ShieldCheck className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-foreground">{title}</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool}
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors",
getStatusBadgeClass(status),
)}
>
{getStatusCopy(status)}
</span>
</div>
{description ? (
<p className="mt-2 leading-5 text-muted-foreground">{description}</p>
) : null}
{parameters.length ? (
<button
type="button"
aria-expanded={currentOpen}
aria-controls={detailsId}
onClick={() => setOpen(!currentOpen)}
className="mt-2 inline-flex items-center gap-1 rounded-md text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
View details
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
) : null}
</div>
</div>
<AgentDisclosure
id={detailsId}
open={currentOpen}
>
<dl className="mx-4 mb-4 grid gap-2 rounded-xl border border-border/50 bg-background/70 p-3">
{parameters.map((parameter) => (
<div
key={parameter.id}
className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-center gap-3 text-xs"
>
<dt className="text-muted-foreground">{parameter.label}</dt>
<dd className="min-w-0 break-words font-mono text-foreground/85">
{parameter.value}
</dd>
</div>
))}
</dl>
</AgentDisclosure>
<AnimatePresence initial={false}>
{pending ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}
className="flex flex-wrap items-center gap-2 border-t border-border/60 px-4 py-3"
>
<motion.button
type="button"
onClick={onApprove}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className="rounded-xl bg-foreground px-3 py-1.5 text-xs font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Allow once
</motion.button>
{onAlwaysAllow ? (
<motion.button
type="button"
onClick={onAlwaysAllow}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className="rounded-xl border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
Always allow
</motion.button>
) : null}
<button
type="button"
onClick={onDeny}
className="rounded-xl px-3 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
Deny
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/tool-approval
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion shiki tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/agents/tool-approval.tsx
"use client";
// beui.dev/components/agents/tool-approval
import {
Check,
ChevronDown,
CircleAlert,
LoaderCircle,
ShieldCheck,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import {
AgentCode,
type AgentCodeLanguage,
} from "@/components/agents/agent-code";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ToolApprovalStatus =
| "pending"
| "approving"
| "approved"
| "denied"
| "running"
| "complete"
| "error";
export interface ToolApprovalParameter {
id: string;
label: ReactNode;
value: ReactNode;
}
export interface ToolApprovalCodeProps {
code: string;
language?: AgentCodeLanguage;
className?: string;
}
export interface ToolApprovalProps {
tool: ReactNode;
title?: ReactNode;
description?: ReactNode;
parameters?: ToolApprovalParameter[];
status?: ToolApprovalStatus;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onApprove?: () => void;
onAlwaysAllow?: () => void;
onDeny?: () => void;
className?: string;
}
function getStatusCopy(status: ToolApprovalStatus) {
if (status === "approving") return "Approving";
if (status === "approved") return "Approved";
if (status === "denied") return "Denied";
if (status === "running") return "Running";
if (status === "complete") return "Completed";
if (status === "error") return "Failed";
return "Approval required";
}
function getStatusBadgeClass(status: ToolApprovalStatus) {
if (status === "pending") {
return "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400";
}
if (status === "approving" || status === "running") {
return "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400";
}
if (status === "approved" || status === "complete") {
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
}
return "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400";
}
export function ToolApprovalCode({
code,
language = "bash",
className,
}: ToolApprovalCodeProps) {
return (
<AgentCode
code={code}
language={language}
className={cn(
"rounded-lg border border-border/50 bg-muted/30 px-2.5 py-2",
className,
)}
/>
);
}
export function ToolApproval({
tool,
title = "Allow this tool to run?",
description,
parameters = [],
status = "pending",
open,
defaultOpen = false,
onOpenChange,
onApprove,
onAlwaysAllow,
onDeny,
className,
}: ToolApprovalProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const detailsId = `${baseId}-details`;
const previousStatus = useRef(status);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
const busy = status === "approving" || status === "running";
const pending = status === "pending";
const error = status === "error";
useEffect(() => {
if (previousStatus.current === "pending" && status !== "pending") {
setOpen(false);
}
previousStatus.current = status;
}, [setOpen, status]);
return (
<div
data-state={status}
aria-busy={busy}
className={cn(
"w-full overflow-hidden rounded-2xl border border-border/60 bg-muted/20 text-sm",
className,
)}
>
<div className="flex items-start gap-3 p-4">
<span
aria-hidden="true"
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-xl border border-border/60 bg-background text-muted-foreground",
error && "text-destructive",
)}
>
{busy ? (
<LoaderCircle className={cn("size-4", !reduce && "animate-spin")} />
) : error ? (
<CircleAlert className="size-4" />
) : status === "denied" ? (
<X className="size-4" />
) : status === "approved" || status === "complete" ? (
<Check className="size-4" />
) : (
<ShieldCheck className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-foreground">{title}</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool}
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors",
getStatusBadgeClass(status),
)}
>
{getStatusCopy(status)}
</span>
</div>
{description ? (
<p className="mt-2 leading-5 text-muted-foreground">{description}</p>
) : null}
{parameters.length ? (
<button
type="button"
aria-expanded={currentOpen}
aria-controls={detailsId}
onClick={() => setOpen(!currentOpen)}
className="mt-2 inline-flex items-center gap-1 rounded-md text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
View details
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
) : null}
</div>
</div>
<AgentDisclosure
id={detailsId}
open={currentOpen}
>
<dl className="mx-4 mb-4 grid gap-2 rounded-xl border border-border/50 bg-background/70 p-3">
{parameters.map((parameter) => (
<div
key={parameter.id}
className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-center gap-3 text-xs"
>
<dt className="text-muted-foreground">{parameter.label}</dt>
<dd className="min-w-0 break-words font-mono text-foreground/85">
{parameter.value}
</dd>
</div>
))}
</dl>
</AgentDisclosure>
<AnimatePresence initial={false}>
{pending ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}
className="flex flex-wrap items-center gap-2 border-t border-border/60 px-4 py-3"
>
<motion.button
type="button"
onClick={onApprove}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className="rounded-xl bg-foreground px-3 py-1.5 text-xs font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Allow once
</motion.button>
{onAlwaysAllow ? (
<motion.button
type="button"
onClick={onAlwaysAllow}
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className="rounded-xl border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
Always allow
</motion.button>
) : null}
<button
type="button"
onClick={onDeny}
className="rounded-xl px-3 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
Deny
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
TSXcomponents/agents/agent-code.tsx
"use client";
import {
type CSSProperties,
Fragment,
useEffect,
useState,
} from "react";
import { createHighlighter, type Highlighter } from "shiki";
import { cn } from "@/lib/utils";
export type AgentCodeLanguage =
| "bash"
| "diff"
| "json"
| "text"
| "tsx"
| "typescript";
export interface AgentCodeToken {
content: string;
offset: number;
light?: string;
dark?: string;
}
export type AgentCodeTokenLines = AgentCodeToken[][];
export interface AgentCodeProps {
code: string;
language?: AgentCodeLanguage;
className?: string;
}
export interface AgentCodeLineProps {
code: string;
tokens?: AgentCodeToken[];
className?: string;
}
const LIGHT_THEME = "github-light-high-contrast";
const DARK_THEME = "github-dark-high-contrast";
let agentCodeHighlighter: Promise<Highlighter> | null = null;
const tokenCache = new Map<string, AgentCodeTokenLines>();
function getAgentCodeHighlighter() {
if (!agentCodeHighlighter) {
agentCodeHighlighter = createHighlighter({
themes: [LIGHT_THEME, DARK_THEME],
langs: ["bash", "diff", "json", "tsx", "typescript"],
});
}
return agentCodeHighlighter;
}
function tokenCacheKey(code: string, language: AgentCodeLanguage) {
return `${language}\u0000${code}`;
}
export function useAgentCodeTokens(
code: string,
language: AgentCodeLanguage,
) {
const key = tokenCacheKey(code, language);
const cached = tokenCache.get(key);
const [result, setResult] = useState<{
key: string;
code: string;
language: AgentCodeLanguage;
lines: AgentCodeTokenLines;
} | null>(cached ? { key, code, language, lines: cached } : null);
useEffect(() => {
const current = tokenCache.get(key);
if (current) {
setResult({ key, code, language, lines: current });
return;
}
let cancelled = false;
getAgentCodeHighlighter().then((highlighter) => {
if (cancelled) return;
const lines = highlighter
.codeToTokensWithThemes(code, {
lang: language,
themes: {
light: LIGHT_THEME,
dark: DARK_THEME,
},
})
.map((line) =>
line.map((token) => ({
content: token.content,
offset: token.offset,
light: token.variants.light?.color,
dark: token.variants.dark?.color,
})),
);
tokenCache.set(key, lines);
setResult({ key, code, language, lines });
});
return () => {
cancelled = true;
};
}, [code, key, language]);
if (result?.key === key) return result.lines;
if (result?.language === language && code.startsWith(result.code)) {
return result.lines;
}
return null;
}
export function AgentCodeLine({
code,
tokens,
className,
}: AgentCodeLineProps) {
return (
<span className={className}>
{tokens
? tokens.map((token) => (
<span
key={`${token.offset}-${token.content}`}
style={
{
"--agent-code-light": token.light ?? "currentColor",
"--agent-code-dark": token.dark ?? token.light ?? "currentColor",
} as CSSProperties
}
className="text-[var(--agent-code-light)] dark:text-[var(--agent-code-dark)]"
>
{token.content}
</span>
))
: code}
</span>
);
}
export function AgentCode({
code,
language = "bash",
className,
}: AgentCodeProps) {
const tokens = useAgentCodeTokens(code, language);
let offset = 0;
const lines = code.split("\n").map((content) => {
const line = { content, offset };
offset += content.length + 1;
return line;
});
return (
<pre
className={cn(
"m-0 overflow-x-auto whitespace-pre font-mono text-xs leading-5 text-foreground/85",
className,
)}
>
<code>
{lines.map((line, index) => (
<Fragment key={line.offset}>
<AgentCodeLine code={line.content} tokens={tokens?.[index]} />
{index < lines.length - 1 ? "\n" : null}
</Fragment>
))}
</code>
</pre>
);
}
TSXcomponents/agents/agent-disclosure.tsx
"use client";
import { motion, type HTMLMotionProps, useReducedMotion } from "motion/react";
import type { CSSProperties } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AgentDisclosureProps
extends Omit<HTMLMotionProps<"div">, "animate" | "initial"> {
open: boolean;
openHeight?: CSSProperties["height"];
}
/** Shared transform-only reveal for collapsible agent content. */
export function AgentDisclosure({
open,
openHeight = "auto",
className,
style,
transition,
...props
}: AgentDisclosureProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
{...props}
aria-hidden={!open}
inert={!open}
initial={false}
animate={
reduce
? { opacity: open ? 1 : 0 }
: {
opacity: open ? 1 : 0,
clipPath: open ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
y: open ? 0 : -4,
}
}
transition={
transition ?? {
duration: reduce ? 0 : open ? 0.22 : 0.14,
ease: EASE_OUT,
}
}
className={cn("overflow-hidden", className)}
style={{
...style,
height: open ? openHeight : 0,
pointerEvents: open ? undefined : "none",
transformOrigin: "top",
}}
/>
);
}
API Reference
ToolApprovalCode
codestring—language?"text" | "bash" | "diff" | "json" | "tsx" | "typescript"bashclassName?string—ToolApproval
toolReactNode—title?ReactNodeAllow this tool to run?description?ReactNode—parameters?ToolApprovalParameter[][]status?"complete" | "error" | "pending" | "approved" | "approving" | "denied" | "running"pendingopen?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—onApprove?(() => void)—onAlwaysAllow?(() => void)—onDeny?(() => void)—className?string—Composition
Place executable source inside the approval request when the decision depends on reviewing it.
Message
└── MessageContent
└── ToolApproval
└── ToolApprovalCodeNote: Approval Card handles broader questions and review decisions. Agent Activity shows the pending and resumed execution context. Tool Result displays the outcome after an approved tool runs.
How it works
Tool approval is a permission boundary. The person must understand what will run, what access it needs, and whether their choice applies once or changes future behavior.
Updated