/** * Gatekeeper Example — Client * * Split-panel layout: * Left: AI chat (talk to the agent, ask it to query/modify the database) * Right: Approval queue + customer data (see pending actions, approve/reject/revert) * * The chat and the approval queue are connected through the Agent's state sync. * When the agent proposes an action, it appears in the queue in real-time. * When a human approves/rejects, the database updates and the state syncs back. * * This demonstrates the Gatekeeper pattern's key UX property: the human always * has a clear view of what the agent wants to do, and full control over whether * it happens. */ import { Suspense, useCallback, useState, useEffect, useRef } from "react"; import { useAgent } from "agents/react"; import { useAgentChat } from "@cloudflare/ai-chat/react"; import { isToolUIPart, getToolName } from "ai"; import type { UIMessage } from "ai"; import { Button, Badge, InputArea, Empty, Surface, Text } from "@cloudflare/kumo"; import { ConnectionIndicator, ModeToggle, PoweredByAgents, type ConnectionStatus } from "@cloudflare/agents-ui"; import { PaperPlaneRightIcon, TrashIcon, CheckCircleIcon, XCircleIcon, GearIcon, ShieldCheckIcon, ClockIcon, ArrowCounterClockwiseIcon, DatabaseIcon, EyeIcon, WarningIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; import { Streamdown } from "streamdown"; import type { GatekeeperState, ActionEntry, CustomerRecord } from "./server"; // ─── Helpers ─────────────────────────────────────────────────────────────── function getMessageText(message: UIMessage): string { return message.parts .filter((part) => part.type === "text") .map((part) => (part as { type: "text"; text: string }).text) .join(""); } function timeAgo(dateStr: string): string { const seconds = Math.floor( (Date.now() - new Date(dateStr + "Z").getTime()) / 1000 ); if (seconds < 60) return "just now"; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; } // ─── Action Queue Panel ──────────────────────────────────────────────────── /** * The approval queue UI. This is the human's view into the Gatekeeper. * * Each pending action shows: * - What it does (title + description) * - The exact SQL that will run (full transparency) * - Approve / Reject buttons * * Approved actions show a Revert button if the action is revertable. * The entire history serves as an audit log. */ function ActionQueue({ actions, onApprove, onReject, onRevert }: { actions: ActionEntry[]; onApprove: (id: number) => void; onReject: (id: number) => void; onRevert: (id: number) => void; }) { const pending = actions.filter((a) => a.state === "pending"); const resolved = actions.filter((a) => a.state !== "pending"); return (
{/* Pending actions — these need human attention */} {pending.length > 0 && (
Pending Approval ({pending.length})
{pending.map((action) => (
{action.title} Pending

{action.description}

{action.sql}

))}
)} {/* Resolved actions — the audit log */} {resolved.length > 0 && (
History
{resolved.map((action) => (
{action.type === "observation" ? ( ) : ( )} {action.title}
{action.state === "approved" && ( Approved )} {action.state === "rejected" && ( Rejected )} {action.state === "reverted" && ( Reverted )}
{/* Show revert button for approved, revertable actions */} {action.state === "approved" && action.canRevert && action.type === "action" && (
)} {action.resolvedAt && (

{timeAgo(action.resolvedAt)}

)}
))}
)} {actions.length === 0 && ( } title="No actions yet" description="Ask the agent to modify the database — actions will appear here for approval" /> )}
); } // ─── Customer Table ──────────────────────────────────────────────────────── /** * Shows the current state of the customer database. * Updates in real-time as actions are approved/reverted. */ function CustomerTable({ customers }: { customers: CustomerRecord[] }) { if (customers.length === 0) { return ( } title="No customers" description="The database is empty" /> ); } return (
{customers.map((c) => ( ))}
Name Email Tier Region
{c.name} {c.email} {c.tier} {c.region}
); } // ─── Chat Panel ──────────────────────────────────────────────────────────── function ChatPanel() { const [connectionStatus, setConnectionStatus] = useState("connecting"); const [input, setInput] = useState(""); const [gatekeeperState, setGatekeeperState] = useState(null); const messagesEndRef = useRef(null); const agent = useAgent({ agent: "GatekeeperAgent", onOpen: useCallback(() => setConnectionStatus("connected"), []), onClose: useCallback(() => setConnectionStatus("disconnected"), []), onError: useCallback( (error: Event) => console.error("WebSocket error:", error), [] ), onStateUpdate: useCallback( (state: GatekeeperState) => setGatekeeperState(state), [] ) }); const { messages, sendMessage, clearHistory, status } = useAgentChat({ agent }); const isStreaming = status === "streaming"; const isConnected = connectionStatus === "connected"; useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); const send = useCallback(() => { const text = input.trim(); if (!text || isStreaming) return; setInput(""); sendMessage({ role: "user", parts: [{ type: "text", text }] }); }, [input, isStreaming, sendMessage]); // Approval queue actions — call the agent's @callable methods via RPC const handleApprove = useCallback( (id: number) => agent.call("approveAction", [id]), [agent] ); const handleReject = useCallback( (id: number) => agent.call("rejectAction", [id]), [agent] ); const handleRevert = useCallback( (id: number) => agent.call("revertAction", [id]), [agent] ); // Active tab for the right panel const [rightTab, setRightTab] = useState<"queue" | "data">("queue"); const pendingCount = gatekeeperState?.actions.filter((a) => a.state === "pending").length ?? 0; return (
{/* ── Left Panel: Chat ─────────────────────────────────────── */}
{/* Header */}
Gatekeeper Approval Queue
{/* Messages */}
{messages.length === 0 && ( } title="Talk to the database agent" description={`Try: "Show me all Gold tier customers" or "Upgrade all East region customers to Gold"`} /> )} {messages.map((message, index) => { const isUser = message.role === "user"; const isLastAssistant = message.role === "assistant" && index === messages.length - 1; return (
{isUser ? (
{getMessageText(message)}
) : ( getMessageText(message) && (
{getMessageText(message)}
) )} {/* Tool call indicators */} {message.parts .filter((part) => isToolUIPart(part)) .map((part) => { if (!isToolUIPart(part)) return null; const toolName = getToolName(part); if (part.state === "output-available") { return (
{toolName === "queryDatabase" ? ( ) : ( )} {toolName === "queryDatabase" ? "Query executed (auto-approved)" : "Action queued for approval"}
); } if ( part.state === "input-available" || part.state === "input-streaming" ) { return (
Running {toolName}...
); } return null; })}
); })}
{/* Input */}
{ e.preventDefault(); send(); }} className="max-w-2xl mx-auto px-5 py-4" >
{ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} placeholder='Try: "Show me customers in the West region"' disabled={!isConnected || isStreaming} rows={2} className="flex-1 !ring-0 focus:!ring-0 !shadow-none !bg-transparent !outline-none" />
{/* ── Right Panel: Approval Queue + Data ───────────────────── */}
{/* Tab bar */}
{/* Tab content */}
{rightTab === "queue" && gatekeeperState && ( )} {rightTab === "data" && gatekeeperState && ( )} {!gatekeeperState && (
Connecting...
)}
); } // ─── App Root ────────────────────────────────────────────────────────────── export default function App() { return ( Loading...
} > ); }