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 agent for the Interrupt-based Generative UI demo.Defines a backend tool `schedule_meeting(topic, attendee)` that useslanggraph's `interrupt()` primitive to pause the run and surface themeeting context to the frontend. The frontend `useInterrupt` renderershows a time picker and resolves with `{chosen_time, chosen_label}` or`{cancelled: true}`, which this tool turns into a human-readable result."""from __future__ import annotationsfrom typing import Any, Optionalfrom langchain.agents import create_agentfrom langchain_core.tools import toolfrom langchain_openai import ChatOpenAIfrom langgraph.types import interruptfrom copilotkit import CopilotKitMiddlewareSYSTEM_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.")@tooldef schedule_meeting(topic: str, attendee: Optional[str] = None) -> str:    """Ask the user to pick a time slot for a call, via an in-chat picker.    Args:        topic: Short human-readable description of the call's purpose.        attendee: Who the call is with (optional).    Returns:        Human-readable result string describing the chosen slot or        indicating the user cancelled.    """    # 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.    response: Any = interrupt({"topic": topic, "attendee": attendee})    if isinstance(response, dict):        if response.get("cancelled"):            return f"User cancelled. Meeting NOT scheduled: {topic}"        chosen_label = response.get("chosen_label") or response.get("chosen_time")        if chosen_label:            return f"Meeting scheduled for {chosen_label}: {topic}"    return f"User did not pick a time. Meeting NOT scheduled: {topic}"model = ChatOpenAI(model="gpt-4o-mini")graph = create_agent(    model=model,    tools=[schedule_meeting],    middleware=[CopilotKitMiddleware()],    system_prompt=SYSTEM_PROMPT,)

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.py
from __future__ import annotationsfrom typing import Any, Optionalfrom langchain.agents import create_agentfrom langchain_core.tools import toolfrom langchain_openai import ChatOpenAIfrom langgraph.types import interruptfrom copilotkit import CopilotKitMiddlewareSYSTEM_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.")@tooldef schedule_meeting(topic: str, attendee: Optional[str] = None) -> str:    """Ask the user to pick a time slot for a call, via an in-chat picker.    Args:        topic: Short human-readable description of the call's purpose.        attendee: Who the call is with (optional).    Returns:        Human-readable result string describing the chosen slot or        indicating the user cancelled.    """    # 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.    response: Any = interrupt({"topic": topic, "attendee": attendee})    if isinstance(response, dict):        if response.get("cancelled"):            return f"User cancelled. Meeting NOT scheduled: {topic}"        chosen_label = response.get("chosen_label") or response.get("chosen_time")        if chosen_label:            return f"Meeting scheduled for {chosen_label}: {topic}"    return f"User did not pick a time. Meeting NOT scheduled: {topic}"

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 React from "react";import {  CopilotKit,  CopilotChat,  useInterrupt,  useConfigureSuggestions,} from "@copilotkit/react-core/v2";import { TimePickerCard, TimeSlot } from "./time-picker-card";const DEFAULT_SLOTS: TimeSlot[] = [  { label: "Tomorrow 10:00 AM", iso: "2026-04-19T10:00:00-07:00" },  { label: "Tomorrow 2:00 PM", iso: "2026-04-19T14:00:00-07:00" },  { label: "Monday 9:00 AM", iso: "2026-04-21T09:00:00-07:00" },  { label: "Monday 3:30 PM", iso: "2026-04-21T15:30:00-07:00" },];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() {  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",  });  useInterrupt({    agentId: "gen-ui-interrupt",    renderInChat: true,    render: ({ event, resolve }) => {      const payload = (event.value ?? {}) as {        topic?: string;        attendee?: string;      };      return (        <TimePickerCard          topic={payload.topic ?? "a call"}          attendee={payload.attendee}          slots={DEFAULT_SLOTS}          onSubmit={(result) => resolve(result)}        />      );    },  });

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#