showcase/shell-docs/src/content/docs/channels/interactive/human-in-the-loop.mdx
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 }).
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>
);
}
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.
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. </Callout>
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.