tool-ui
blockapproval-cardsource: ok · hit
Approval Card
Binary confirmation for agent actions.
npx shadcn add "https://www.tool-ui.com/r/approval-card.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
formszod
Registry dependencies
buttonseparator
Files11 · source cached 10/9/2026, 10:49:18 AM
components/tool-ui/approval-card/_adapter.tsx
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
*/
export { cn } from "@/lib/utils";
export { Separator } from "@/components/ui/separator";
components/tool-ui/approval-card/approval-card.tsx
"use client";
import * as React from "react";
import { cn, Separator } from "./_adapter";
import type { ApprovalCardProps, ApprovalDecision } from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import { type Action } from "../shared/schema";
import { icons, Check, X } from "lucide-react";
type LucideIcon = React.ComponentType<{ className?: string }>;
function getLucideIcon(name: string): LucideIcon | null {
const pascalName = name
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
const Icon = icons[pascalName as keyof typeof icons];
return Icon ?? null;
}
interface ApprovalCardReceiptProps {
id: string;
title: string;
choice: ApprovalDecision;
actionLabel?: string;
className?: string;
}
function ApprovalCardReceipt({
id,
title,
choice,
actionLabel,
className,
}: ApprovalCardReceiptProps) {
const isApproved = choice === "approved";
const displayLabel = actionLabel ?? (isApproved ? "Approved" : "Denied");
return (
<div
className={cn(
"flex w-full min-w-64 max-w-md flex-col",
"text-foreground",
"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:zoom-in-95 motion-safe:duration-300 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:fill-mode-both",
className,
)}
data-slot="approval-card"
data-tool-ui-id={id}
data-receipt="true"
role="status"
aria-label={displayLabel}
>
<div
className={cn(
"bg-card/60 flex w-full items-center gap-3 rounded-2xl border px-4 py-3 shadow-xs",
)}
>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full bg-muted",
isApproved ? "text-primary" : "text-muted-foreground",
)}
>
{isApproved ? <Check className="size-4" /> : <X className="size-4" />}
</span>
<div className="flex flex-col">
<span className="text-sm font-medium">{displayLabel}</span>
<span className="text-muted-foreground text-sm">{title}</span>
</div>
</div>
</div>
);
}
export function ApprovalCard({
id,
title,
description,
icon,
metadata,
variant,
confirmLabel,
cancelLabel,
className,
choice,
onConfirm,
onCancel,
}: ApprovalCardProps) {
const resolvedVariant = variant ?? "default";
const resolvedConfirmLabel = confirmLabel ?? "Approve";
const resolvedCancelLabel = cancelLabel ?? "Deny";
const Icon = icon ? getLucideIcon(icon) : null;
const handleAction = React.useCallback(
async (actionId: string) => {
if (actionId === "confirm") {
await onConfirm?.();
} else if (actionId === "cancel") {
await onCancel?.();
}
},
[onConfirm, onCancel],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel?.();
}
},
[onCancel],
);
const isDestructive = resolvedVariant === "destructive";
const actions: Action[] = [
{
id: "cancel",
label: resolvedCancelLabel,
variant: "ghost",
},
{
id: "confirm",
label: resolvedConfirmLabel,
variant: isDestructive ? "destructive" : "default",
},
];
const viewKey = choice ? `receipt-${choice}` : "interactive";
return (
<div key={viewKey} className="contents">
{choice ? (
<ApprovalCardReceipt
id={id}
title={title}
choice={choice}
className={className}
/>
) : (
<article
className={cn(
"flex w-full min-w-64 max-w-md flex-col gap-3",
"text-foreground",
className,
)}
data-slot="approval-card"
data-tool-ui-id={id}
role="dialog"
aria-labelledby={`${id}-title`}
aria-describedby={description ? `${id}-description` : undefined}
onKeyDown={handleKeyDown}
>
<div className="bg-card flex w-full flex-col gap-4 rounded-2xl border p-5 shadow-xs">
<div className="flex items-start gap-3">
{Icon && (
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl",
isDestructive
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
<Icon className="size-5" />
</span>
)}
<div className="flex flex-1 flex-col gap-1">
<h2
id={`${id}-title`}
className="text-base font-semibold leading-tight"
>
{title}
</h2>
{description && (
<p
id={`${id}-description`}
className="text-muted-foreground text-sm"
>
{description}
</p>
)}
</div>
</div>
{metadata && metadata.length > 0 && (
<>
<Separator />
<dl className="flex flex-col gap-2 text-sm">
{metadata.map((item, index) => (
<div key={index} className="flex justify-between gap-4">
<dt className="text-muted-foreground shrink-0">
{item.key}
</dt>
<dd className="min-w-0 truncate">{item.value}</dd>
</div>
))}
</dl>
</>
)}
</div>
<div className="@container/actions">
<ActionButtons actions={actions} onAction={handleAction} />
</div>
</article>
)}
</div>
);
}
components/tool-ui/approval-card/index.tsx
export { ApprovalCard } from "./approval-card";
export {
type SerializableApprovalCard,
type ApprovalCardProps,
type ApprovalDecision,
type MetadataItem,
} from "./schema";
components/tool-ui/approval-card/README.md
# Approval Card
Implementation for the "approval-card" Tool UI surface.
## Files
- public exports: components/tool-ui/approval-card/index.tsx
- serializable schema + parse helpers: components/tool-ui/approval-card/schema.ts
## Companion assets
- Docs page: app/docs/approval-card/content.mdx
- Preset payload: lib/presets/approval-card.ts
## Quick check
Run this after edits:
pnpm test
components/tool-ui/approval-card/schema.ts
import { z } from "zod";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
export const MetadataItemSchema = z.object({
key: z.string().min(1),
value: z.string(),
});
export type MetadataItem = z.infer<typeof MetadataItemSchema>;
export const ApprovalDecisionSchema = z.enum(["approved", "denied"]);
export type ApprovalDecision = z.infer<typeof ApprovalDecisionSchema>;
export const SerializableApprovalCardSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
title: z.string().min(1),
description: z.string().optional(),
icon: z.string().optional(),
metadata: z.array(MetadataItemSchema).optional(),
variant: z.enum(["default", "destructive"]).optional(),
confirmLabel: z.string().optional(),
cancelLabel: z.string().optional(),
choice: ApprovalDecisionSchema.optional(),
});
export type SerializableApprovalCard = z.infer<
typeof SerializableApprovalCardSchema
>;
const SerializableApprovalCardSchemaContract = defineToolUiContract(
"ApprovalCard",
SerializableApprovalCardSchema,
);
export const parseSerializableApprovalCard: (
input: unknown,
) => SerializableApprovalCard = SerializableApprovalCardSchemaContract.parse;
export const safeParseSerializableApprovalCard: (
input: unknown,
) => SerializableApprovalCard | null =
SerializableApprovalCardSchemaContract.safeParse;
export interface ApprovalCardProps extends SerializableApprovalCard {
className?: string;
onConfirm?: () => void | Promise<void>;
onCancel?: () => void | Promise<void>;
}
components/tool-ui/shared/_adapter.tsx
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
*/
export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
components/tool-ui/shared/action-buttons.tsx
"use client";
import type { Action } from "./schema";
import { cn, Button } from "./_adapter";
import { useActionButtons } from "./use-action-buttons";
export interface ActionButtonsProps {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
align?: "left" | "center" | "right";
className?: string;
}
export function ActionButtons({
actions,
onAction,
onBeforeAction,
confirmTimeout = 3000,
align = "right",
className,
}: ActionButtonsProps) {
const { actions: resolvedActions, runAction } = useActionButtons({
actions,
onAction,
onBeforeAction,
confirmTimeout,
});
return (
<div
className={cn(
"flex flex-col gap-3",
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
align === "left" && "@sm/actions:justify-start",
align === "center" && "@sm/actions:justify-center",
align === "right" && "@sm/actions:justify-end",
className,
)}
>
{resolvedActions.map((action) => {
const label = action.currentLabel;
const variant = action.variant || "default";
return (
<Button
key={action.id}
variant={variant}
onClick={() => runAction(action.id)}
disabled={action.isDisabled}
className={cn(
"rounded-full px-4!",
"justify-center",
"min-h-11 w-full text-base",
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
action.isConfirming &&
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
)}
aria-label={
action.shortcut ? `${label} (${action.shortcut})` : label
}
>
{action.isLoading && (
<svg
className="mr-2 h-4 w-4 motion-safe:animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{action.icon && !action.isLoading && (
<span className="mr-2">{action.icon}</span>
)}
{label}
{action.shortcut && !action.isLoading && (
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
{action.shortcut}
</kbd>
)}
</Button>
);
})}
</div>
);
}
components/tool-ui/shared/contract.ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";
export interface ToolUiContract<T> {
schema: z.ZodType<T>;
parse: (input: unknown) => T;
safeParse: (input: unknown) => T | null;
}
export function defineToolUiContract<T>(
componentName: string,
schema: z.ZodType<T>,
): ToolUiContract<T> {
return {
schema,
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
};
}
components/tool-ui/shared/parse.ts
import { z } from "zod";
function formatZodPath(path: Array<string | number | symbol>): string {
if (path.length === 0) return "root";
return path
.map((segment) =>
typeof segment === "number" ? `[${segment}]` : String(segment),
)
.join(".");
}
/**
* Format Zod errors into a compact `path: message` string.
*/
export function formatZodError(error: z.ZodError): string {
const parts = error.issues.map((issue) => {
const path = formatZodPath(issue.path);
return `${path}: ${issue.message}`;
});
return Array.from(new Set(parts)).join("; ");
}
/**
* Parse unknown input and throw a readable error.
*/
export function parseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
name: string,
): T {
const res = schema.safeParse(input);
if (!res.success) {
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
}
return res.data;
}
/**
* Parse unknown input, returning `null` instead of throwing on failure.
*
* Use this in assistant-ui `render` functions where `args` stream in
* incrementally and may be incomplete until the tool call finishes.
*/
export function safeParseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
): T | null {
const res = schema.safeParse(input);
return res.success ? res.data : null;
}
components/tool-ui/shared/schema.ts
import { z } from "zod";
import type { ReactNode } from "react";
/**
* Tool UI conventions:
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
* - Schema: `SerializableXSchema`
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
* - Root attrs: `data-tool-ui-id` + `data-slot`
*/
/**
* Schema for tool UI identity.
*
* Every tool UI should have a unique identifier that:
* - Is stable across re-renders
* - Is meaningful (not auto-generated)
* - Is unique within the conversation
*
* Format recommendation: `{component-type}-{semantic-identifier}`
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
*/
export const ToolUIIdSchema = z.string().min(1);
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
/**
* Primary role of a Tool UI surface in a chat context.
*/
export const ToolUIRoleSchema = z.enum([
"information",
"decision",
"control",
"state",
"composite",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
/**
* Optional receipt metadata: a durable summary of an outcome.
*/
export const ToolUIReceiptSchema = z.object({
outcome: ToolUIReceiptOutcomeSchema,
summary: z.string().min(1),
identifiers: z.record(z.string(), z.string()).optional(),
at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
/**
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
/**
* Canonical narration the assistant can use after this action is taken.
*
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
*/
sentence: z.string().optional(),
confirmLabel: z.string().optional(),
variant: z
.enum(["default", "destructive", "secondary", "ghost", "outline"])
.optional(),
icon: z.custom<ReactNode>().optional(),
loading: z.boolean().optional(),
disabled: z.boolean().optional(),
shortcut: z.string().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;
export const DecisionResultSchema = z.object({
kind: z.literal("decision"),
version: z.literal(1),
decisionId: z.string().min(1),
actionId: z.string().min(1),
actionLabel: z.string().min(1),
at: z.string().datetime(),
payload: z.record(z.string(), z.unknown()).optional(),
});
export type DecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
payload?: TPayload;
};
export function createDecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
decisionId: string;
action: { id: string; label: string };
payload?: TPayload;
}): DecisionResult<TPayload> {
return {
kind: "decision",
version: 1,
decisionId: args.decisionId,
actionId: args.action.id,
actionLabel: args.action.label,
at: new Date().toISOString(),
payload: args.payload,
};
}
export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
items: Action[];
align?: "left" | "center" | "right";
confirmTimeout?: number;
}
export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
});
export type SerializableActionsConfig = z.infer<
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
components/tool-ui/shared/use-action-buttons.tsx
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Action } from "./schema";
export type UseActionButtonsOptions = {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
};
export type UseActionButtonsResult = {
actions: Array<
Action & {
currentLabel: string;
isConfirming: boolean;
isExecuting: boolean;
isDisabled: boolean;
isLoading: boolean;
}
>;
runAction: (actionId: string) => Promise<void>;
confirmingActionId: string | null;
executingActionId: string | null;
};
type ActionExecutionLock = {
tryAcquire: () => boolean;
release: () => void;
};
export function createActionExecutionLock(): ActionExecutionLock {
let locked = false;
return {
tryAcquire: () => {
if (locked) return false;
locked = true;
return true;
},
release: () => {
locked = false;
},
};
}
export function useActionButtons(
options: UseActionButtonsOptions,
): UseActionButtonsResult {
const { actions, onAction, onBeforeAction, confirmTimeout = 3000 } = options;
const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
null,
);
const [executingActionId, setExecutingActionId] = useState<string | null>(
null,
);
const executionLockRef = useRef<ActionExecutionLock>(
createActionExecutionLock(),
);
useEffect(() => {
if (!confirmingActionId) return;
const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
return () => clearTimeout(id);
}, [confirmingActionId, confirmTimeout]);
useEffect(() => {
if (!confirmingActionId) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setConfirmingActionId(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [confirmingActionId]);
const runAction = useCallback(
async (actionId: string) => {
const action = actions.find((a) => a.id === actionId);
if (!action) return;
const isAnyActionExecuting = executingActionId !== null;
if (action.disabled || action.loading || isAnyActionExecuting) {
return;
}
if (action.confirmLabel && confirmingActionId !== action.id) {
setConfirmingActionId(action.id);
return;
}
if (!executionLockRef.current.tryAcquire()) {
return;
}
if (onBeforeAction) {
const shouldProceed = await onBeforeAction(action.id);
if (!shouldProceed) {
setConfirmingActionId(null);
executionLockRef.current.release();
return;
}
}
try {
setExecutingActionId(action.id);
await onAction(action.id);
} finally {
executionLockRef.current.release();
setExecutingActionId(null);
setConfirmingActionId(null);
}
},
[actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
);
const resolvedActions = useMemo(
() =>
actions.map((action) => {
const isConfirming = confirmingActionId === action.id;
const isThisActionExecuting = executingActionId === action.id;
const isLoading = action.loading || isThisActionExecuting;
const isDisabled =
action.disabled ||
(executingActionId !== null && !isThisActionExecuting);
const currentLabel =
isConfirming && action.confirmLabel
? action.confirmLabel
: action.label;
return {
...action,
currentLabel,
isConfirming,
isExecuting: isThisActionExecuting,
isDisabled,
isLoading,
};
}),
[actions, confirmingActionId, executingActionId],
);
return {
actions: resolvedActions,
runAction,
confirmingActionId,
executingActionId,
};
}