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.

Live example

I can check the authentication tests before continuing.

Allow Shell to run this?

Run the authentication tests

$ pnpm test auth

Install 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.json

Components 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 →