Headless Interrupts
Resolve agent interrupts from any UI, without a useInterrupt render slot.
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.
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.
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.
The primitives#
Install the LangGraph Python SDK
uv add copilotkitpoetry add copilotkitpip install copilotkit --extra-index-url https://copilotkit.gateway.scarf.sh/simple/conda install copilotkit -c copilotkit-channelWire CopilotKit middleware into your graph
Programmatic control (copilotkit.runAgent, agent.subscribe,
agent.addMessage) drives runs through the same agent your chat UI
uses, so the backend wiring is the same one-line CopilotKitMiddleware
setup.
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
graph = create_agent(
model=ChatOpenAI(model="gpt-5.4"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt="You are a helpful, concise assistant.",
)For the headless useInterrupt pattern, also use LangGraph's native
interrupt(...) inside a graph node and resume with
forwardedProps: { command: { resume, interruptEvent } } from the
frontend.
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:
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:
agent.subscribe({ onCustomEvent, onRunStartedEvent, onRunFinishedEvent, onRunFinalized, onRunFailed })— everyAbstractAgentexposes an AG-UI event subscription. Standard interrupts arrive ononRunFinishedEventwith{ outcome: "interrupt", interrupts: [...] }; legacy LangGraph interrupts arrive as a custom event namedon_interrupt.copilotkit.runAgent({ agent, resume })(standard) orcopilotkit.runAgent({ agent, forwardedProps: { command: { resume, interruptEvent } } })(legacy) — the same calluseInterrupt'sresolve()/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:
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, mirroringuseInterrupt, 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#
The preferred approach uses useInterrupt with renderInChat: false — no
hand-rolled subscription, no <CopilotChat>, no render prop:
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:
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>;
}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, withenabledgating andhandlerpreprocessing.