Headless Interrupts
Resolve agent interrupts from any UI, without a useInterrupt render slot.
/** * LangGraph TypeScript agent for the Interrupt-based Generative UI demos. * * Defines a backend tool `schedule_meeting(topic, attendee)` that uses * langgraph's `interrupt()` primitive to pause the run and surface the * meeting context to the frontend. The frontend `useInterrupt` renderer * shows a time picker and resolves with `{chosen_time, chosen_label}` or * `{cancelled: true}`, which this tool turns into a human-readable result. * * Ported from `src/agents/interrupt_agent.py` in the langgraph-python package. */import { z } from "zod";import type { RunnableConfig } from "@langchain/core/runnables";import { tool } from "@langchain/core/tools";import { ToolNode } from "@langchain/langgraph/prebuilt";import type { AIMessage } from "@langchain/core/messages";import { SystemMessage } from "@langchain/core/messages";import { MemorySaver, START, StateGraph, Annotation, interrupt,} from "@langchain/langgraph";import { ChatOpenAI } from "@langchain/openai";import { convertActionsToDynamicStructuredTools, CopilotKitStateAnnotation,} from "@copilotkit/sdk-js/langgraph";// Demo-only fixed timezone offset (Pacific = UTC-7 in PDT, UTC-8 in PST).// A real app would use the user's calendar + locale; we hardcode Pacific so// screenshots are stable. Mirrors interrupt_agent.py.const DEMO_TZ_OFFSET_HOURS = -7; // PDTinterface TimeSlot { label: string; iso: string;}function candidateSlots(): TimeSlot[] { const now = new Date(); // "Tomorrow" const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1); // "Monday" — at least 2 days away to avoid collisions with "Tomorrow" const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat let daysToMonday = (1 - dayOfWeek + 7) % 7; if (daysToMonday <= 1) daysToMonday += 7; const nextMonday = new Date(now); nextMonday.setDate(now.getDate() + daysToMonday); const fmt = (d: Date, hour: number, minute = 0): string => { // Build an ISO-like string with the demo timezone offset const sign = DEMO_TZ_OFFSET_HOURS >= 0 ? "+" : "-"; const absOffset = Math.abs(DEMO_TZ_OFFSET_HOURS); const hh = String(absOffset).padStart(2, "0"); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00${sign}${hh}:00`; }; return [ { label: "Tomorrow 10:00 AM", iso: fmt(tomorrow, 10) }, { label: "Tomorrow 2:00 PM", iso: fmt(tomorrow, 14) }, { label: "Monday 9:00 AM", iso: fmt(nextMonday, 9) }, { label: "Monday 3:30 PM", iso: fmt(nextMonday, 15, 30) }, ];}const SYSTEM_PROMPT = "You are a scheduling assistant. Whenever the user asks you to book a " + "call / schedule a meeting, you MUST call the `schedule_meeting` tool. " + "Pass a short `topic` describing the purpose and `attendee` describing " + "who the meeting is with. After the tool returns, confirm briefly " + "whether the meeting was scheduled and at what time, or that the user " + "cancelled.";const AgentStateAnnotation = Annotation.Root({ ...CopilotKitStateAnnotation.spec,});export type AgentState = typeof AgentStateAnnotation.State;const scheduleMeeting = tool( async ({ topic, attendee }: { topic: string; attendee?: string | null }) => { // langgraph's `interrupt()` pauses execution and forwards the payload to // the client. The frontend v2 `useInterrupt` hook renders the picker and // calls `resolve(...)` with the user's selection, which comes back here. const response: unknown = interrupt({ topic, attendee: attendee ?? null, slots: candidateSlots(), }); if (response && typeof response === "object") { const resp = response as Record<string, unknown>; if (resp.cancelled) { return `User cancelled. Meeting NOT scheduled: ${topic}`; } const chosenLabel = (resp.chosen_label as string | undefined) ?? (resp.chosen_time as string | undefined); if (chosenLabel) { return `Meeting scheduled for ${chosenLabel}: ${topic}`; } } return `User did not pick a time. Meeting NOT scheduled: ${topic}`; }, { name: "schedule_meeting", description: "Ask the user to pick a time slot for a call, via an in-chat picker.", schema: z.object({ topic: z .string() .describe("Short human-readable description of the call's purpose."), attendee: z .string() .nullable() .optional() .describe("Who the call is with (optional)."), }), },);const tools = [scheduleMeeting];async function chatNode(state: AgentState, config: RunnableConfig) { const model = new ChatOpenAI({ temperature: 0, model: "gpt-4o-mini" }); const modelWithTools = model.bindTools!([ ...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []), ...tools, ]); const systemMessage = new SystemMessage({ content: SYSTEM_PROMPT }); const response = await modelWithTools.invoke( [systemMessage, ...state.messages], config, ); return { messages: response };}function shouldContinue({ messages, copilotkit }: AgentState) { const lastMessage = messages[messages.length - 1] as AIMessage; if (lastMessage.tool_calls?.length) { const actions = copilotkit?.actions; const toolCallName = lastMessage.tool_calls![0].name; if (!actions || actions.every((action) => action.name !== toolCallName)) { return "tool_node"; } } return "__end__";}const workflow = new StateGraph(AgentStateAnnotation) .addNode("chat_node", chatNode) .addNode("tool_node", new ToolNode(tools)) .addEdge(START, "chat_node") .addEdge("tool_node", "chat_node") .addConditionalEdges("chat_node", shouldContinue as any);const memory = new MemorySaver();export const graph = workflow.compile({ checkpointer: memory,});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#
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:
import React, { useEffect, useMemo, useState } from "react";import { CopilotKit, CopilotChat, useAgent, useConfigureSuggestions, useCopilotKit,} from "@copilotkit/react-core/v2";import { generateFallbackSlots } from "../_shared/interrupt-fallback-slots";import type { TimeSlot } from "../_shared/interrupt-fallback-slots";const INTERRUPT_EVENT_NAME = "on_interrupt";type InterruptPayload = { topic?: string; attendee?: string; slots?: TimeSlot[];};type InterruptEvent = { name: string; value: InterruptPayload;};export default function InterruptHeadlessDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="interrupt-headless"> <Layout /> </CopilotKit> );}function Layout() { const { pending, resolve } = useHeadlessInterrupt("interrupt-headless"); useConfigureSuggestions({ suggestions: [ { title: "Book a call with sales", message: "Book an intro call with the sales team to discuss pricing.", }, { title: "Schedule a 1:1 with Alice", message: "Schedule a 1:1 with Alice next week to review Q2 goals.", }, ], available: "always", }); return ( <div className="grid h-screen grid-cols-[1fr_420px] bg-[#FAFAFC]"> <AppSurface pending={pending} resolve={resolve} /> <div className="border-l border-[#DBDBE5] bg-white"> <CopilotChat agentId="interrupt-headless" className="h-full" /> </div> </div> );}function useHeadlessInterrupt(agentId: string): { pending: InterruptEvent | null; resolve: (response: unknown) => void;} { const { copilotkit } = useCopilotKit(); const { agent } = useAgent({ agentId }); const [pending, setPending] = useState<InterruptEvent | null>(null); useEffect(() => { let local: InterruptEvent | null = null; const sub = agent.subscribe({ onCustomEvent: ({ event }) => { if (event.name === INTERRUPT_EVENT_NAME) { // The AG-UI adapter JSON-stringifies interrupt values, so // parse when the value arrives as a string. const raw = event.value ?? {}; local = { name: event.name, value: (typeof raw === "string" ? JSON.parse(raw) : raw) as InterruptPayload, }; } }, onRunStartedEvent: () => { local = null; setPending(null); }, onRunFinalized: () => { if (local) { setPending(local); local = null; } }, onRunFailed: () => { local = null; }, }); return () => sub.unsubscribe(); }, [agent]); const resolve = useMemo( () => (response: unknown) => { const snapshot = pending; setPending(null); void copilotkit .runAgent({ agent, forwardedProps: { command: { resume: response, interruptEvent: snapshot?.value, }, }, }) .catch(() => {}); }, [agent, copilotkit, pending], ); return { pending, resolve };}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, 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#
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>;
}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.
