Human-in-the-Loop
Pause the agent for an approve or cancel decision, rendered as buttons.
Some actions should not happen without a human saying yes. Human-in-the-loop posts an approve or cancel card, waits for the click, and only then lets the agent continue.
This builds on the UI library. Make sure
JSX is turned on and your card is registered in createChannel({ components }).
The confirm card#
Two buttons. Each repaints the card in place, and the approve button resumes the agent with the decision.
import {
Message,
Header,
Section,
Actions,
Button,
type ChannelNode,
type InteractionContext,
} from "@copilotkit/channels/ui";
export function ConfirmWrite({ action }: { action: string }): ChannelNode {
return (
<Message accent="#F59E0B">
<Header>{`Confirm: ${action}`}</Header>
<Section>{"Approve to let me continue."}</Section>
<Actions>
<Button
value={{ confirmed: true }}
style="primary"
onClick={async ({ thread, message }: InteractionContext) => {
// repaint the card so the buttons can't be clicked twice
await thread.update(
message.ref,
<Message accent="#27AE60">
<Header>{`Approved: ${action}`}</Header>
</Message>,
);
// resume the agent with the answer
await thread.runAgent({
prompt: `The user approved: ${action}. Go ahead.`,
});
}}
>
Approve
</Button>
<Button
value={{ confirmed: false }}
style="danger"
onClick={async ({ thread, message }: InteractionContext) => {
await thread.update(
message.ref,
<Message accent="#E74C3C">
<Header>{"Cancelled"}</Header>
</Message>,
);
}}
>
Cancel
</Button>
</Actions>
</Message>
);
}Ask before a write#
Give the agent a tool that shows the card, then ends its turn. The agent stops there. The click resumes it.
import { defineChannelTool } from "@copilotkit/channels";
import { z } from "zod";
import { ConfirmWrite } from "./confirm-write";
export const confirmWriteTool = defineChannelTool({
name: "confirm_write",
description: "Ask the user to approve an action before doing it.",
parameters: z.object({ action: z.string() }),
async handler({ action }, { thread }) {
await thread.post(<ConfirmWrite action={action} />);
// this string is what the model reads back, so it knows to stop
return "Showed an approval card. Waiting for the user to decide.";
},
});Register both the tool and the component:
const channel = createChannel({
name: "release-bot",
agent,
tools: [confirmWriteTool],
components: [ConfirmWrite], // so the click resolves after a restart
});The flow: the agent calls confirm_write, the card appears, the agent's turn
ends. When the user clicks Approve, onClick runs the agent again with the
decision folded into the prompt, and it finishes the work.
Direct platforms can block instead
On a platform you connect with a direct adapter, thread.supportsBlockingChoice
is true and you can wait for the click inline with awaitChoice:
const { confirmed } = await thread.awaitChoice<{ confirmed?: boolean }>(
<ConfirmWrite action={action} />,
);
if (confirmed) {
// do the write
}A managed channel delivers one turn at a time and does not block, so use the post-and-resume pattern above with it.
Agent-initiated interrupts#
The pattern above is driven by a tool the agent calls. An agent can also pause
itself by emitting an on_interrupt event mid-run, for example a graph node
that needs a human answer to continue. Handle it with channel.onInterrupt,
collect the answer, and call thread.resume to hand it back.
channel.onInterrupt<{ question: string }>("ask_human", async ({ payload, thread }) => {
const { value } = await thread.awaitChoice<{ value: string }>(
<AskCard question={payload.question} />,
);
await thread.resume(value); // the run continues with the answer
});onInterrupt is keyed by the event name (here "ask_human"), and the type
argument types payload. On a managed channel, where awaitChoice does
not block, post a component whose button calls thread.resume(answer) instead.