showcase/shell-docs/src/content/docs/human-in-the-loop/useInterrupt.mdx
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.
The Microsoft Agent Framework runtime can't pause a run mid-tool the
way LangGraph's interrupt() does, so this demo uses useFrontendTool
with a Promise-based handler instead. The agent calls schedule_meeting
like any other tool; the client-side handler renders the picker, holds
the request open, and only resolves the Promise once the user picks a
slot or cancels. Same UX from the reader's perspective — agent pauses,
user answers, agent resumes — different mechanism underneath.
</WhenFrameworkHas>Not available on this framework.
useInterruptis only meaningful when the underlying runtime exposes either a nativeinterrupt(...)primitive (LangGraph) or a Promise-resolving frontend tool path (Microsoft Agent Framework). For all other integrations, useuseHumanInTheLoopinstead — it's the standard hook for tool-call-based pause/resume flows and works on every framework that supports tool calls.
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:
interrupt(...) and resume with a payloadFor LLM-initiated pauses where the model decides on the fly to ask
the user, prefer useHumanInTheLoop.
interrupt() inside a toolThe 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:
{"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.{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.useInterrupt render propOn 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.
useFrontendTool with a Promise-resolving handlerThe handler stores its resolve callback in a ref, returns a Promise
that the user's pick eventually resolves, and renders the picker
inline in the chat. This is the MS Agent equivalent of
useInterrupt's event / resolve pair:
The agent has no local schedule_meeting implementation — the tool is
registered entirely on the frontend. The backend's only job is to
instruct the model to call schedule_meeting whenever the user wants
to book a meeting. AG-UI routes the tool call to the client, where
the Promise-returning handler takes over:
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.enabledIf 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} />
),
});
handlerFor 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" })}
/>
),
});
useInterrupt supports two interrupt transports. Understanding which one your
agent uses helps you write the right render code.
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.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.
If both signals appear on the same run (unlikely but possible during migration), the standard flow wins.
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.
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 validationThe 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.
useHumanInTheLoop — for
LLM-initiated pauses.useAgent, agent.subscribe, copilotkit.runAgent) to resolve
interrupts outside a chat surface.