CopilotKit

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:

  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:

page.tsx
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, 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.

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, with enabled gating and handler preprocessing.