showcase/shell-docs/src/content/docs/human-in-the-loop/headless.mdx
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.
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.
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> <InlineDemo demo="interrupt-headless" />Not available on this framework. Headless interrupts are built on top of
useInterrupt/useFrontendToolpatterns that require the runtime to expose either a nativeinterrupt(...)primitive (LangGraph) or a Promise-resolving frontend-tool path (Microsoft Agent Framework). For all other integrations, useuseHumanInTheLoopinstead — it's the standard hook for tool-call-based pause/resume flows and works on every framework that supports tool calls.
If you just want "a picker in chat", just use
useInterrupt.
Under the hood, useInterrupt composes two public APIs:
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.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:
A few things this hook is careful about:
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.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:
A few things this pattern is careful about:
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.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:
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>;
}
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.