CopilotKit

Pausing the Agent for Input

Pause an agent run mid-tool, hand control to a custom React component, and resume with the user's answer.


/** * 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 lets your agent pause mid-run, hand control to the user through a custom React component, and resume with whatever the user returns. How that pause is implemented depends on the framework's runtime.

LangGraph ships a first-class interrupt() primitive that lets a running node suspend itself and hand control to the client. The run is frozen server-side until the client resolves the interrupt with a payload, at which point the node resumes as if interrupt() had simply returned that payload.

CopilotKit's useInterrupt is the frontend half of that contract: it subscribes to the paused run, renders whatever component you give it, and calls the agent back with the user's answer.

When should I use this?#

Reach for useInterrupt when the pause is a graph-enforced checkpoint where the code path must stop and wait for a human, not an LLM-initiated tool call. Typical cases:

  • A sensitive action (payments, irreversible writes) must be approved
  • A required piece of state isn't known and can only be collected from the user
  • The agent explicitly reaches an approval node in a longer workflow
  • You want the server-side contract to be interrupt(...) and resume with a payload

For LLM-initiated pauses where the model decides on the fly to ask the user, prefer useHumanInTheLoop.

The backend: interrupt() inside a tool#

The example agent exposes a schedule_meeting tool. When the model calls it, the tool issues a langgraph.interrupt(...) with the meeting context. The run freezes here until the client resolves; the resolution becomes the return value of interrupt(), which the tool then turns into a final string for the model:

interrupt-agent.ts
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)."),    }),  },);

Two things to note:

  • The payload ({"topic": topic, "attendee": attendee}) is what the frontend receives as event.value. Keep it a plain, serializable object. It's the "pause-time context" the UI needs to render.
  • The return-side contract ({chosen_label, chosen_time} or {cancelled: true}) is entirely yours. The client can send anything as the resolve payload; the tool is the one that gives it meaning.

The frontend: useInterrupt render prop#

On the client you register a useInterrupt hook per agent. When the paused run arrives, its payload is handed to render as event.value, and resolve(...) is how you resume the run:

page.tsx
import {  CopilotKit,  CopilotChat,  useInterrupt,} from "@copilotkit/react-core/v2";import type { TimeSlot } from "./_components/time-picker-card";import { TimePickerCard } from "./_components/time-picker-card";import { generateFallbackSlots } from "../_shared/interrupt-fallback-slots";import { useGenUiInterruptSuggestions } from "./suggestions";export default function GenUiInterruptDemo() {  return (    <CopilotKit runtimeUrl="/api/copilotkit" agent="gen-ui-interrupt">      <div className="flex justify-center items-center h-screen w-full">        <div className="h-full w-full max-w-4xl">          <Chat />        </div>      </div>    </CopilotKit>  );}function Chat() {  useGenUiInterruptSuggestions();  // `useInterrupt` is the low-level primitive for handling LangGraph  // `interrupt(...)` events. The backend's `schedule_meeting` tool surfaces  // a structured payload — `{ topic, attendee, slots }` — which we render  // inline in the chat as a message bubble. Calling `resolve(...)` resumes  // the LangGraph run with the user's selection.  useInterrupt({    agentId: "gen-ui-interrupt",    renderInChat: true,    render: ({ event, resolve }) => {      // The AG-UI adapter JSON-stringifies interrupt values, so parse      // when needed to extract the structured payload.      const raw = event.value ?? {};      const payload = (typeof raw === "string" ? JSON.parse(raw) : raw) as {        topic?: string;        attendee?: string;        slots?: TimeSlot[];      };      const slots =        payload.slots && payload.slots.length > 0          ? payload.slots          : generateFallbackSlots();      return (        <TimePickerCard          topic={payload.topic ?? "a call"}          attendee={payload.attendee}          slots={slots}          onSubmit={(result) => {            // Defer resolve so React commits the picked/cancelled state            // before useInterrupt clears the interrupt element. A single            // requestAnimationFrame is not reliable — rAF fires before            // React's commit in some scheduling scenarios. Using a short            // setTimeout ensures the commit lands first and the user sees            // the "Booked"/"Cancelled" badge before the card unmounts.            setTimeout(() => resolve(result), 500);          }}        />      );    },  });

Whatever you pass to resolve is round-tripped back to the agent as the return value of the matching interrupt(...) call.

Key props#

  • agentId — must match a runtime-registered agent. If omitted, the hook assumes "default". A mismatch means the interrupt never fires.
  • render — receives { event, resolve }. event.value is the payload you passed to interrupt(...) on the server.
  • renderInChat — when true (as above), the picker appears inline in the chat transcript, between the paused assistant turn and the still-pending continuation.

Multiple interrupts? Add a type and gate with enabled#

If your graph issues more than one kind of interrupt (e.g. "ask" vs "approval"), tag each with a type field on the payload and install one useInterrupt per shape, each gated by an enabled predicate:

useInterrupt({
  agentId: "gen-ui-interrupt",
  enabled: ({ eventValue }) => eventValue.type === "ask",
  render: ({ event, resolve }) => (
    <AskCard question={event.value.content} onAnswer={resolve} />
  ),
});

useInterrupt({
  agentId: "gen-ui-interrupt",
  enabled: ({ eventValue }) => eventValue.type === "approval",
  render: ({ event, resolve }) => (
    <ApproveCard content={event.value.content} onAnswer={resolve} />
  ),
});

Preprocess with handler#

For cases where the interrupt can sometimes be resolved without user input (e.g. the current user already has permission), pass a handler that runs before render. The handler can call resolve(...) itself to short-circuit the UI, or return a value that render receives as result:

useInterrupt({
  agentId: "gen-ui-interrupt",
  handler: async ({ event, resolve }) => {
    const dept = await lookupUserDepartment();
    if (event.value.accessDepartment === dept || dept === "admin") {
      resolve({ code: "AUTH_BY_DEPARTMENT" });
      return; // skip render
    }
    return { dept };
  },
  render: ({ result, event, resolve }) => (
    <RequestAccessCard
      dept={result.dept}
      onRequest={() => resolve({ code: "REQUEST_AUTH" })}
      onCancel={() => resolve({ code: "CANCEL" })}
    />
  ),
});

Going further#