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.


Not supported on LangGraph (TypeScript)
LangGraph (TypeScript) doesn't support Human in the Loop: Interrupts. See the framework grid for which integrations support this feature.

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#

Install the CopilotKit LangGraph SDK

npm install @copilotkit/sdk-js

Wire CopilotKit state + tools into your graph

Tool-based HITL (useHumanInTheLoop) registers the tool on the frontend and forwards it via state.copilotkit.actions — the same wiring as frontend tools. The graph-paused pattern (useInterrupt) uses LangGraph's native interrupt(...) primitive inside a node.

frontend-tools.ts
import { RunnableConfig } from "@langchain/core/runnables";
import { SystemMessage } from "@langchain/core/messages";
import { MemorySaver, START, StateGraph } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { makeChatOpenAI } from "./openai-headers";

import {
  convertActionsToDynamicStructuredTools,
  CopilotKitStateAnnotation,
} from "@copilotkit/sdk-js/langgraph";

// CopilotKit forwards frontend tools to the agent via
// `state.copilotkit.actions`. `CopilotKitStateAnnotation` adds that
// channel to your graph's state; `convertActionsToDynamicStructuredTools`
// turns the forwarded action schemas into LangChain tools you can bind
// at model-invocation time.
const AgentStateAnnotation = CopilotKitStateAnnotation;
export type AgentState = typeof AgentStateAnnotation.State;

const SYSTEM_PROMPT = "You are a helpful, concise assistant.";

async function chatNode(state: AgentState, config: RunnableConfig) {
  const model = makeChatOpenAI(config, {
    temperature: 0,
    model: "gpt-4o-mini",
  });

  const modelWithTools = model.bindTools!([
    ...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []),
  ]);

  const response = await modelWithTools.invoke(
    [new SystemMessage({ content: SYSTEM_PROMPT }), ...state.messages],
    config,
  );

  return { messages: response };
}

const workflow = new StateGraph(AgentStateAnnotation)
  .addNode("chat_node", chatNode)
  .addEdge(START, "chat_node")
  .addEdge("chat_node", "__end__");

const memory = new MemorySaver();

export const graph = workflow.compile({
  checkpointer: memory,
});

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:

Not supported on LangGraph (TypeScript)
LangGraph (TypeScript) doesn't support Human in the Loop: Interrupts. See the framework grid for which integrations support this feature.

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:

Not supported on LangGraph (TypeScript)
LangGraph (TypeScript) doesn't support Human in the Loop: Interrupts. See the framework grid for which integrations support this feature.

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: (event) => event.value.type === "ask",
  render: ({ event, resolve }) => (
    <AskCard question={event.value.content} onAnswer={resolve} />
  ),
});

useInterrupt({
  agentId: "gen-ui-interrupt",
  enabled: (event) => event.value.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 resume the agent early — the interrupt card unmounts when the resume run starts. 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; // agent will resume; card unmounts when the run starts
    }
    return { dept };
  },
  render: ({ result, event, resolve }) => (
    <RequestAccessCard
      dept={result?.dept}
      onRequest={() => resolve({ code: "REQUEST_AUTH" })}
      onCancel={() => resolve({ code: "CANCEL" })}
    />
  ),
});

AG-UI standard interrupt flow vs. legacy#

useInterrupt supports two interrupt transports. Understanding which one your agent uses helps you write the right render code.

Standard flow (RUN_FINISHED with outcome.type === "interrupt")#

When the agent backend conforms to the AG-UI protocol, it signals an interrupt by emitting a RUN_FINISHED event whose outcome carries the interrupts array:

outcome.type === "interrupt"
outcome.interrupts  // Interrupt[]

The hook detects this on onRunFinishedEvent and exposes the interrupts on the render props after onRunFinalized fires. Your render function receives:

  • interrupt — the primary Interrupt (interrupts[0]), with shape { id, reason, message?, toolCallId?, responseSchema?, expiresAt?, metadata? }.
  • interrupts — the full open set (usually one, but multi-interrupt is supported — see below).
  • resolve(payload?, interruptId?) — records { status: "resolved", payload } for the targeted interrupt (defaults to the primary). The agent run resumes once every open interrupt has a response.
  • cancel(interruptId?) — records { status: "cancelled" } for the targeted interrupt. Same accumulate-then-submit logic applies.

Legacy flow (on_interrupt custom event)#

Older agents (or agents not yet migrated to the AG-UI interrupt spec) emit a custom on_interrupt event. The hook detects this on onCustomEvent and sets interrupt to null and interrupts to []. The payload is in event.value. Calling resolve(payload) resumes via forwardedProps.command (the legacy resume mechanism). cancel() dismisses the interrupt without resuming — the agent never receives a response.

Priority#

If both signals appear on the same run (unlikely but possible during migration), the standard flow wins.

Approve / Cancel example#

function ApprovalInterrupt() {
  useInterrupt({
    render: ({ interrupt, resolve, cancel }) => (
      <div className="p-3 border rounded">
        <p>{interrupt?.message ?? "Approve this action?"}</p>
        <div className="mt-2 flex gap-2">
          <button onClick={() => resolve({ approved: true })}>Approve</button>
          <button onClick={() => cancel()}>Cancel</button>
        </div>
      </div>
    ),
  });
  return null;
}

resolve({ approved: true }) records a resolved entry and submits the resume array to the agent. cancel() records a cancelled entry and does the same. Both return the RunAgentResult once the run restarts.

Multi-interrupt behavior#

Some agents issue more than one interrupt in a single run (e.g. two independent approvals). Each interrupt has its own id. Address them individually:

useInterrupt({
  render: ({ interrupts, resolve, cancel }) => (
    <ul>
      {interrupts.map((i) => (
        <li key={i.id}>
          {i.message}
          <button onClick={() => resolve({ ok: true }, i.id)}>Approve</button>
          <button onClick={() => cancel(i.id)}>Cancel</button>
        </li>
      ))}
    </ul>
  ),
});

The agent run only resumes once every open interrupt has been addressed. Calling resolve or cancel with a specific interruptId marks that interrupt done; the hook auto-submits the accumulated responses when the last one is addressed. If you omit interruptId, the primary interrupt (interrupts[0]) is targeted.

responseSchema — surface only, no client-side validation#

The Interrupt type exposes a responseSchema field (a JSON Schema object) that the agent can use to describe the expected payload shape. useInterrupt surfaces this field on interrupt.responseSchema for your UI to read (e.g. to drive a form), but it does not validate resolve payloads against it. Validation is the agent's responsibility on resume.

Going further#