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.
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-jsWire 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.
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:
Two things to note:
- The payload (
{"topic": topic, "attendee": attendee}) is what the frontend receives asevent.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:
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.valueis the payload you passed tointerrupt(...)on the server.renderInChat— whentrue(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#
- Tool-based HITL with
useHumanInTheLoop— for LLM-initiated pauses. - Headless interrupts — compose the lower-level primitives
(
useAgent,agent.subscribe,copilotkit.runAgent) to resolve interrupts outside a chat surface.