Back to Copilotkit

Headless Interrupts

showcase/shell-docs/src/content/docs/human-in-the-loop/headless.mdx

1.61.28.9 KB
Original Source
<InlineDemo demo="interrupt-headless" />

What is this?

useInterrupt's render callback is the 80% path: it keeps the UI glued to a <CopilotChat> transcript and handles "when to show the picker" logic for you. This page covers the escape hatch: a render-less interrupt resolver you assemble from the same primitives useInterrupt uses internally — a pattern that lives anywhere in your React tree, takes any shape you like (button grid, form, modal, keyboard shortcut), and resolves the interrupt without mounting a chat at all.

<WhenFrameworkHas flag="interrupt_pattern" equals="native">

On LangGraph the underlying primitive is the framework's interrupt() call, surfaced to the client as an on_interrupt custom event. The headless variant subscribes to that event directly and resumes the run by calling copilotkit.runAgent({...}) with the matching resume payload — no chat surface required.

</WhenFrameworkHas> <WhenFrameworkHas flag="interrupt_pattern" equals="promise-based">

On Microsoft Agent Framework there's no native interrupt primitive, so the headless variant uses useFrontendTool with a Promise-based handler. The handler exposes its pending payload via React state — so a separate "app surface" can render the picker outside the chat — and resolves the Promise once the user interacts. Same UX, different mechanism.

</WhenFrameworkHas> <WhenFrameworkHas flag="interrupt_pattern" absent>

Not available on this framework. Headless interrupts are built on top of useInterrupt / useFrontendTool patterns that require the runtime to expose either a native interrupt(...) primitive (LangGraph) or a Promise-resolving frontend-tool path (Microsoft Agent Framework). For all other integrations, use useHumanInTheLoop instead — it's the standard hook for tool-call-based pause/resume flows and works on every framework that supports tool calls.

</WhenFrameworkHas>

When should I use this?

  • Testing / Playwright fixtures — a deterministic, chat-less button grid is easier to drive than a chat surface where the picker only appears after an LLM call.
  • Non-chat UIs — dashboards, side panels, inspector surfaces, or any place where you want the agent's interrupt without the chat transcript.
  • Custom flow control — when you need to know exactly when the interrupt arrived (e.g. to gate other UI) and when it was resolved.
  • Research / debugging — when you want to observe the raw AG-UI custom events without the abstraction layer.

If you just want "a picker in chat", just use useInterrupt.

<WhenFrameworkHas flag="interrupt_pattern" equals="native">

The primitives

<FrameworkSetup concept="programmatic-control-setup" />

The simplest headless pattern uses useInterrupt with renderInChat: false. Instead of publishing the element into <CopilotChat>, the hook returns the interrupt element directly so you can place it anywhere in your tree:

tsx
function ApprovalPanel() {
  const element = useInterrupt({
    renderInChat: false,
    render: ({ interrupt, resolve, cancel }) => (
      <div className="p-3 border rounded">
        <p>{interrupt?.message ?? "Approve this action?"}</p>
        <div className="mt-2 flex gap-2">
          <button onClick={() => resolve({ approved: true })}>Approve</button>
          <button onClick={() => cancel()}>Cancel</button>
        </div>
      </div>
    ),
  });

  // `element` is null while no interrupt is active; render it wherever you like.
  return <div className="approval-panel">{element}</div>;
}

interrupt carries the primary AG-UI Interrupt object ({ id, reason, message?, responseSchema?, expiresAt?, ... }). resolve(payload) submits the user's response and resumes the agent. cancel() cancels the interrupt and resumes. Both return a Promise<RunAgentResult | void> — void while waiting on further interrupts when more than one is open.

Under the hood, useInterrupt composes two public APIs:

  1. agent.subscribe({ onCustomEvent, onRunStartedEvent, onRunFinishedEvent, onRunFinalized, onRunFailed }) — every AbstractAgent exposes an AG-UI event subscription. Standard interrupts arrive on onRunFinishedEvent with { outcome: "interrupt", interrupts: [...] }; legacy LangGraph interrupts arrive as a custom event named on_interrupt.
  2. copilotkit.runAgent({ agent, resume }) (standard) or copilotkit.runAgent({ agent, forwardedProps: { command: { resume, interruptEvent } } }) (legacy) — the same call useInterrupt's resolve() / cancel() makes to resume a paused run.

For cases where you need full control over the subscription (e.g. testing fixtures, custom accumulation logic), you can still wire up the raw primitives yourself:

<Snippet region="headless-useinterrupt-primitives" title="frontend/src/app/page.tsx — useHeadlessInterrupt" />

A few things this hook is careful about:

  • It stages the incoming event in a local ref and only commits it to React state on onRunFinalized, mirroring useInterrupt, which doesn't surface the interrupt until the run has actually paused (not just when the event fires mid-stream).
  • onRunStartedEvent clears any stale pending state, so kicking off a new turn always starts from a clean slate.
  • onRunFailed drops the staged event so a transport hiccup doesn't leave the UI stuck showing a picker for a run that never paused.
</WhenFrameworkHas> <WhenFrameworkHas flag="interrupt_pattern" equals="promise-based">

The primitives

The render callback intentionally returns null — the picker UI lives in the app surface, not in the chat transcript. The handler's pending state drives whether the picker is shown:

<Snippet region="headless-promise-primitives" title="frontend/src/app/page.tsx — useFrontendTool (headless Promise-based)" />

A few things this pattern is careful about:

  • The handler stages its resolve callback in a ref keyed by tool-call id, so concurrent tool calls don't trample each other's resolvers.
  • setPending is called from inside the handler so the app surface re-renders the picker as soon as the agent calls the tool, and again with null after the user interacts so the picker disappears.
  • render: () => null keeps the chat transcript clean — the headless variant deliberately bypasses inline rendering.
</WhenFrameworkHas> <WhenFrameworkHas flag="interrupt_pattern" equals="native">

Driving it from plain UI

The preferred approach uses useInterrupt with renderInChat: false — no hand-rolled subscription, no <CopilotChat>, no render prop:

tsx
function HeadlessInterruptPanel() {
  const { copilotkit } = useCopilotKit();
  const { agent } = useAgent({ agentId: "interrupt-headless" });

  const kickOff = (prompt: string) => {
    agent.addMessage({ id: crypto.randomUUID(), role: "user", content: prompt });
    void copilotkit.runAgent({ agent });
  };

  const interruptElement = useInterrupt({
    renderInChat: false,
    render: ({ interrupt, resolve, cancel }) => (
      <div>
        <p>Pick a slot for {interrupt?.message ?? "a call"}:</p>
        {SLOTS.map((s) => (
          <button key={s.iso} onClick={() => resolve({ chosen_time: s.iso, chosen_label: s.label })}>
            {s.label}
          </button>
        ))}
        <button onClick={() => cancel()}>Cancel</button>
      </div>
    ),
  });

  if (interruptElement) {
    return interruptElement;
  }

  return <button onClick={() => kickOff("Book a call with sales.")}>Book call</button>;
}

If you need full control over the subscription (e.g. for a custom useHeadlessInterrupt fixture used in Playwright tests), you can still use the raw primitives from useHeadlessInterrupt defined above:

tsx
function HeadlessInterruptPanelRaw() {
  const { copilotkit } = useCopilotKit();
  const { agent } = useAgent({ agentId: "interrupt-headless" });
  const { pending, resolve } = useHeadlessInterrupt("interrupt-headless");

  const kickOff = (prompt: string) => {
    agent.addMessage({ id: crypto.randomUUID(), role: "user", content: prompt });
    void copilotkit.runAgent({ agent });
  };

  if (pending) {
    return (
      <div>
        <p>Pick a slot for {pending.value.topic ?? "a call"}:</p>
        {SLOTS.map((s) => (
          <button key={s.iso} onClick={() => resolve({ chosen_time: s.iso, chosen_label: s.label })}>
            {s.label}
          </button>
        ))}
        <button onClick={() => resolve({ cancelled: true })}>Cancel</button>
      </div>
    );
  }

  return <button onClick={() => kickOff("Book a call with sales.")}>Book call</button>;
}
</WhenFrameworkHas>

Going further

  • Tool-based HITL with useHumanInTheLoop — for LLM-initiated pauses where the model decides on the fly to ask the user, rather than the runtime forcing the pause itself.
  • useInterrupt — the render-prop version of this page, with enabled gating and handler preprocessing.