showcase/shell-docs/src/content/docs/human-in-the-loop/governed-actions.mdx
Some agent actions are safe to run immediately, while others should be blocked or paused for user approval before they create side effects. A governed action approval UI gives the user a clear checkpoint:
Use this pattern when the agent proposes an action such as sending an email, updating a record, creating a ticket, applying a discount, or calling any write API.
Keep the approval payload small, serializable, and vendor-neutral. The agent or backend can attach any policy engine result to the action as long as the UI receives the same basic envelope:
type GovernedAction = {
id: string;
summary: string;
tool: string;
reference: string;
verdict: "allow" | "deny" | "require_approval";
arguments: Record<string, unknown>;
};
The frontend should handle each verdict deterministically:
| Verdict | UI behavior |
|---|---|
allow | Execute the action without asking again, optionally showing an audit note. |
deny | Do not execute the action. Show the reason or reference and ask the agent to choose a safer path. |
require_approval | Render a user approval card. Execute only if the user approves. |
useInterruptIf your runtime can pause an agent run, model the approval as an interrupt. The agent proposes the action, the UI renders the checkpoint, and the run resumes with the user's decision:
import { useEffect } from "react";
import { useInterrupt } from "@copilotkit/react-core/v2";
function GovernedActionApproval() {
useInterrupt({
render: ({ interrupt, resolve, cancel }) => {
const action = interrupt?.metadata?.action as GovernedAction | undefined;
if (!action) {
return null;
}
return (
<GovernedActionCard
action={action}
onApprove={() =>
resolve({
approved: true,
actionId: action.id,
reference: action.reference,
})
}
onReject={() =>
resolve({
approved: false,
actionId: action.id,
reference: action.reference,
})
}
onBlock={() => cancel()}
/>
);
},
});
return null;
}
function GovernedActionCard({
action,
onApprove,
onReject,
onBlock,
}: {
action: GovernedAction;
onApprove: () => void;
onReject: () => void;
onBlock: () => void;
}) {
useEffect(() => {
if (action.verdict === "allow") onApprove();
if (action.verdict === "deny") onBlock();
}, [action.id, action.verdict]);
const status =
action.verdict === "allow"
? "Allowed by policy"
: action.verdict === "deny"
? "Blocked by policy"
: "User approval required";
return (
<section className="rounded-lg border p-4 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium">{status}</p>
<h3 className="text-base font-semibold">{action.summary}</h3>
<p className="text-sm text-muted-foreground">Tool: {action.tool}</p>
<p className="text-sm text-muted-foreground">
Reference: {action.reference}
</p>
</div>
<pre className="mt-3 overflow-auto rounded bg-muted p-3 text-xs">
{JSON.stringify(action.arguments, null, 2)}
</pre>
{action.verdict === "require_approval" && (
<div className="mt-4 flex gap-2">
<button type="button" onClick={onApprove}>
Approve and run
</button>
<button type="button" onClick={onReject}>
Reject
</button>
</div>
)}
</section>
);
}
On resume, the agent should execute only when it receives an approved response for the same action id and reference:
type ApprovalResponse = {
approved: boolean;
actionId: string;
reference: string;
};
async function handleApproval(action: GovernedAction, response: ApprovalResponse) {
if (
response.approved &&
response.actionId === action.id &&
response.reference === action.reference
) {
return executeSideEffect(action.tool, action.arguments);
}
return {
skipped: true,
reason: "The user did not approve this action.",
};
}
useHumanInTheLoopFor LLM-initiated pauses, register the approval checkpoint as a
human-in-the-loop tool. The model asks to call approve_governed_action,
your UI renders the card, and the tool result tells the agent whether it
may continue:
import { ToolCallStatus, useHumanInTheLoop } from "@copilotkit/react-core/v2";
import { z } from "zod";
const governedActionSchema = z.object({
id: z.string(),
summary: z.string(),
tool: z.string(),
reference: z.string(),
verdict: z.enum(["allow", "deny", "require_approval"]),
arguments: z.record(z.unknown()),
});
function GovernedActionTool() {
useHumanInTheLoop(
{
name: "approve_governed_action",
description:
"Ask the user to approve a governed side-effect action before it runs.",
parameters: governedActionSchema,
render: ({ args, status, respond }) => {
if (status !== ToolCallStatus.Executing || !respond) {
return null;
}
return (
<GovernedActionCard
action={args}
onApprove={() =>
respond({
approved: true,
actionId: args.id,
reference: args.reference,
})
}
onReject={() =>
respond({
approved: false,
actionId: args.id,
reference: args.reference,
})
}
onBlock={() =>
respond({
approved: false,
actionId: args.id,
reference: args.reference,
})
}
/>
);
},
},
[],
);
return null;
}
action.id and reference so the approval cannot be
replayed for a different action.deny as terminal for that proposed action; do not execute a
denied side effect from a later UI callback.