Build an AI agent tool approval flow in React
Connect a tool approval prompt to an activity log, with distinct allow, deny, running and completed states.
I can check the authentication tests before continuing.
Allow Shell to run this?
/workspaceRun the authentication tests
$ pnpm test authInstall the components
Add these to a React project with Tailwind CSS configured.
npx shadcn@latest add https://gradientdeck.com/ai-components/r/tool-approval-card.json
npx shadcn@latest add https://gradientdeck.com/ai-components/r/tool-call-inline.jsonComponents used: Tool approval, Tool call.
1. Present the proposed action
Render ToolApprovalCard with the exact tool, command, working directory and reason supplied by your agent. The pending decision is null. Keep a stable tool-call ID in your application so the response can only resume the action being reviewed.
2. Record the decision before running
onDecide returns deny, once or always. Denial leaves the action unexecuted. In this demo, either allow choice starts a timer that produces a sample result; it never executes a shell command. In your app, send the decision to the server and wait for an authorized execution event.
3. Show execution separately
ToolCallLine displays the actual running or completed state. An approval is not a successful execution: a permitted command may still fail. Use its error state and real tool output when your backend reports failure.
Complete example
Save this as a client component after installing the components above.
"use client";
import { useEffect, useRef, useState } from "react";
import { ToolApprovalCard, type ApprovalDecision } from "@/components/ai/tool-approval-card";
import { ToolCallLine } from "@/components/ai/tool-call-inline";
export default function ToolApprovalExample() {
const [decision, setDecision] = useState<ApprovalDecision | null>(null);
const [status, setStatus] = useState<"running" | "done" | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
function decide(value: ApprovalDecision) {
if (decision) return;
setDecision(value);
if (value === "deny") return;
// Demo only: no command is executed. In production, send a decision tied to
// this tool-call ID to your server, which checks scope before resuming it.
setStatus("running");
timer.current = setTimeout(() => setStatus("done"), 1600);
}
return <div className="w-full max-w-xl space-y-4 text-sm">
<p>I can check the authentication tests before continuing.</p>
<ToolApprovalCard tool="Shell" command="pnpm test auth" cwd="/workspace" reason="Run the authentication tests" decision={decision} onDecide={decide} />
{status && <ToolCallLine verb={status === "running" ? "Running" : "Ran"} target="pnpm test auth" status={status} detail={status === "done" ? "12 tests passed (sample result)" : undefined} />}
{decision === "deny" && <p className="text-zinc-500">The command was not run.</p>}
{decision && <button type="button" className="text-xs underline" onClick={() => { clearTimeout(timer.current); setDecision(null); setStatus(null); }}>Reset example</button>}
</div>;
}Connect your backend
Enforce permissions on the server. An always decision needs a clearly defined scope, an audit trail and revocation. Never treat a browser state variable as permission to execute arbitrary commands. The sample deliberately stores no lasting grants.
All guides →