Back to Copilotkit

Headless Interrupts

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

1.57.06.5 KB
Original Source

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> <InlineDemo demo="interrupt-headless" />

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

Under the hood, useInterrupt composes two public APIs:

  1. agent.subscribe({ onCustomEvent, onRunStartedEvent, onRunFinalized, onRunFailed }) — every AbstractAgent exposes an AG-UI event subscription. LangGraph sends the interrupt through as a custom event named on_interrupt with the interrupt(...) payload as event.value.
  2. copilotkit.runAgent({ agent, forwardedProps: { command: { resume, interruptEvent } } }) — the same call useInterrupt's resolve() makes to resume a paused run. Pass your response as resume and the original interrupt event as interruptEvent.

Wrap those in your own hook and you get a render-less equivalent of useInterrupt:

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

A few things this hook is careful about:

  • It stages the incoming custom 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

Once useHeadlessInterrupt returns { pending, resolve }, the rest is just React. The example below uses two buttons to kick off the agent and a button grid to resolve, with no <CopilotChat> and no render prop:

tsx
function HeadlessInterruptPanel() {
  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.