Headless interrupts
Resolve LangGraph interrupts from any UI, without a useInterrupt render slot.
This feature (interrupt-headless) hasn't been tagged in any Claude Agent SDK (Python) cell yet. Try LangGraph (Python) instead, or browse the framework-agnostic version.
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.
The result is a pattern that lives anywhere in your React tree, takes
any shape you like (plain button grid, form, modal, keyboard shortcut
— it's all yours), and resolves langgraph.interrupt(...) without
mounting a chat at all.
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" — don't bother, use
useInterrupt.
The primitives
Under the hood, useInterrupt composes two public APIs:
agent.subscribe({ onCustomEvent, onRunStartedEvent, onRunFinalized, onRunFailed })— everyAbstractAgentexposes an AG-UI event subscription. LangGraph sends the interrupt through as a custom event namedon_interruptwith theinterrupt(...)payload asevent.value.copilotkit.runAgent({ agent, forwardedProps: { command: { resume, interruptEvent } } })— the same calluseInterrupt'sresolve()makes to resume a paused run. Pass your response asresumeand the original interrupt event asinterruptEvent.
Wrap those in your own hook and you get a render-less equivalent of
useInterrupt:
claude-sdk-python::interrupt-headless. Known demos are bundled from manifest demos[i]; check the cell id and framework slug.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— that mirrorsuseInterrupt, which doesn't surface the interrupt until the run has actually paused (not just when the event fires mid-stream). onRunStartedEventclears any stale pending state, so kicking off a new turn always starts from a clean slate.onRunFaileddrops the staged event so a transport hiccup doesn't leave the UI stuck showing a picker for a run that never paused.
Driving it from plain UI
Once useHeadlessInterrupt returns { pending, resolve }, the rest is
just React. The showcase cell uses two buttons to kick off the agent
and a button grid to resolve — no <CopilotChat>, 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>;
}
Going further
- Tool-based HITL with
useHumanInTheLoop— for LLM-initiated pauses that don't involve a LangGraphinterrupt(). useInterrupt— the render-prop version of this page, withenabledgating andhandlerpreprocessing.