CopilotKit

Headless Interrupts

Resolve agent interrupts from any UI, without a useInterrupt render slot.


"use client";// Headless Interrupt demo (Built-in Agent port).//// Layout: chat on the right, empty app surface on the left. The user triggers// the agent from a chat suggestion. When the agent calls `schedule_meeting`,// we render a time-picker popup IN THE APP SURFACE (left pane) — outside of// the chat. Picking a slot resolves the tool call, the popup vanishes, and// the agent confirms back in chat.//// Adaptation: the LangGraph version uses a custom `useHeadlessInterrupt` hook// built on top of `useAgent` + `useCopilotKit` that reads LangGraph's native// `interrupt()` event from the AG-UI stream and resumes via// `copilotkit.runAgent({ forwardedProps: { command: { resume, ... } } })`.// The built-in agent (TanStack AI) has no interrupt primitive, so we instead// register `schedule_meeting` as a frontend tool and gate the UI on whether// the tool is currently awaiting a user decision. The async handler returns a// Promise that only resolves when the user interacts with the external popup// — equivalent UX, different mechanism.import React, { useRef, useState } from "react";import {  CopilotKitProvider,  CopilotChat,  useConfigureSuggestions,  useFrontendTool,} from "@copilotkit/react-core/v2";import { z } from "zod";type InterruptPayload = {  topic?: string;  attendee?: string;};type TimeSlot = { label: string; iso: string };type PickerResult =  | { chosen_time: string; chosen_label: string }  | { cancelled: true };const DEFAULT_SLOTS: TimeSlot[] = [  { label: "Tomorrow 10:00 AM", iso: "2026-04-25T10:00:00-07:00" },  { label: "Tomorrow 2:00 PM", iso: "2026-04-25T14:00:00-07:00" },  { label: "Monday 9:00 AM", iso: "2026-04-28T09:00:00-07:00" },  { label: "Monday 3:30 PM", iso: "2026-04-28T15:30:00-07:00" },];export default function InterruptHeadlessDemo() {  return (    <CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>      <Layout />    </CopilotKitProvider>  );}function Layout() {  const [pending, setPending] = useState<InterruptPayload | null>(null);  // Resolver for the currently-awaiting `schedule_meeting` tool call. Set by  // the async frontend-tool handler below, called when the user picks a slot  // or cancels from the external popup.  const resolverRef = useRef<((result: PickerResult) => void) | null>(null);  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",  });  useFrontendTool({    name: "schedule_meeting",    description:      "Ask the user to pick a time slot for a meeting via a picker popup " +      "that appears outside the chat. Blocks until the user chooses a " +      "slot or cancels.",    parameters: z.object({      topic: z        .string()        .describe("Short human-readable description of the meeting."),      attendee: z        .string()        .optional()        .describe("Who the meeting is with (optional)."),    }),    // Async handler: sets the pending payload so the popup renders, then    // returns a Promise that only resolves once the user interacts with the    // popup. This is the built-in agent shim for the LangGraph headless    // interrupt `resume` flow.    handler: async ({      topic,      attendee,    }: {      topic: string;      attendee?: string;    }): Promise<string> => {      setPending({ topic, attendee });      const result = await new Promise<PickerResult>((resolve) => {        resolverRef.current = resolve;      });      setPending(null);      if ("cancelled" in result && result.cancelled) {        return "User cancelled. Meeting NOT scheduled.";      }      if ("chosen_label" in result) {        return `Meeting scheduled for ${result.chosen_label}.`;      }      return "User did not pick a time. Meeting NOT scheduled.";    },    // Render nothing inside the chat — the UI lives in the app surface.    render: () => null,  });  const resolve = (result: PickerResult) => {    const fn = resolverRef.current;    resolverRef.current = null;    fn?.(result);  };  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 className="h-full" />      </div>    </div>  );}type AppSurfaceProps = {  pending: InterruptPayload | null;  resolve: (result: PickerResult) => void;};function AppSurface({ pending, resolve }: AppSurfaceProps) {  return (    <div      data-testid="interrupt-headless-app-surface"      className="relative flex h-full flex-col overflow-hidden"    >      <header className="border-b border-[#DBDBE5] bg-white px-8 py-5">        <div className="text-[10px] font-medium uppercase tracking-[0.14em] text-[#57575B]">          Headless interrupt        </div>        <h1 className="text-xl font-semibold text-[#010507]">Scheduling</h1>      </header>      <div className="relative flex flex-1 items-center justify-center p-8">        {pending ? (          <TimeSlotPopup            payload={pending}            onPick={(slot) =>              resolve({ chosen_time: slot.iso, chosen_label: slot.label })            }            onCancel={() => resolve({ cancelled: true })}          />        ) : (          <EmptyState />        )}      </div>    </div>  );}function EmptyState() {  return (    <div      data-testid="interrupt-headless-empty"      className="max-w-sm text-center"    >      <div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full border border-[#DBDBE5] bg-white text-[#85ECCE]">        <svg          xmlns="http://www.w3.org/2000/svg"          width="18"          height="18"          viewBox="0 0 24 24"          fill="none"          stroke="currentColor"          strokeWidth="2"          strokeLinecap="round"          strokeLinejoin="round"        >          <rect x="3" y="4" width="18" height="18" rx="2" />          <path d="M16 2v4M8 2v4M3 10h18" />        </svg>      </div>      <div className="text-sm font-medium text-[#010507]">        Nothing scheduled yet      </div>      <p className="mt-1 text-sm text-[#57575B]">        Ask the assistant to book something. When it needs your input, a picker        will appear right here.      </p>    </div>  );}type TimeSlotPopupProps = {  payload: InterruptPayload;  onPick: (slot: TimeSlot) => void;  onCancel: () => void;};function TimeSlotPopup({ payload, onPick, onCancel }: TimeSlotPopupProps) {  return (    <div      role="dialog"      aria-modal="false"      data-testid="interrupt-headless-popup"      className="w-full max-w-md rounded-2xl border border-[#DBDBE5] bg-white p-6 shadow-[0_20px_40px_-20px_rgba(1,5,7,0.25)]"    >      <div className="mb-2 flex items-center gap-2">        <span className="inline-flex h-1.5 w-1.5 animate-pulse rounded-full bg-[#85ECCE]" />        <span className="text-[10px] font-medium uppercase tracking-[0.14em] text-[#57575B]">          Pick a time        </span>      </div>      <h2 className="mb-1 text-lg font-semibold text-[#010507]">        {payload.topic ?? "Meeting"}      </h2>      {payload.attendee ? (        <p className="mb-5 text-sm text-[#57575B]">          with{" "}          <span className="font-medium text-[#010507]">{payload.attendee}</span>        </p>      ) : (        <div className="mb-5" />      )}      <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">        {DEFAULT_SLOTS.map((slot) => (          <button            key={slot.iso}            type="button"            data-testid={`interrupt-headless-slot-${slot.iso}`}            onClick={() => onPick(slot)}            className="rounded-xl border border-[#DBDBE5] bg-white px-3 py-3 text-sm font-medium text-[#010507] transition-colors hover:border-[#BEC2FF] hover:bg-[#BEC2FF1A]"          >            {slot.label}          </button>        ))}      </div>      <button        type="button"        data-testid="interrupt-headless-cancel"        onClick={onCancel}        className="mt-4 w-full rounded-xl border border-[#DBDBE5] bg-white px-3 py-2 text-xs font-medium uppercase tracking-[0.12em] text-[#57575B] transition-colors hover:bg-[#FAFAFC]"      >        Cancel      </button>    </div>  );}

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.

Not available on this framework. Headless interrupts are built on top of useInterrupt / useFrontendTool patterns that require the runtime to expose either a native interrupt(...) primitive (LangGraph) or a Promise-resolving frontend-tool path (Microsoft Agent Framework). For all other integrations, use useHumanInTheLoop instead — it's the standard hook for tool-call-based pause/resume flows and works on every framework that supports tool calls.

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.

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.