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.

confirm-write.tsx
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.