---
title: Headless Interrupts
icon: "lucide/SquareDashedBottomCode"
description: Resolve agent interrupts from any UI, without a useInterrupt render slot.
hideTOC: true
snippet_cell: interrupt-headless
---

## What is this?

`useInterrupt`'s `render` callback is the 80% path: it keeps the UI
glued to a `<CopilotChat>` transcript and handles "when to show the
picker" logic for you. This page covers the escape hatch: a
**render-less** interrupt resolver you assemble from the same
primitives `useInterrupt` uses internally — a pattern that lives
anywhere in your React tree, takes any shape you like (button grid,
form, modal, keyboard shortcut), and resolves the interrupt without
mounting a chat at all.

<WhenFrameworkHas flag="interrupt_pattern" equals="native">

On LangGraph the underlying primitive is the framework's
`interrupt()` call, surfaced to the client as an `on_interrupt`
custom event. The headless variant subscribes to that event directly
and resumes the run by calling `copilotkit.runAgent({...})` with the
matching `resume` payload — no chat surface required.

</WhenFrameworkHas>

<WhenFrameworkHas flag="interrupt_pattern" equals="promise-based">

On Microsoft Agent Framework there's no native interrupt primitive,
so the headless variant uses `useFrontendTool` with a Promise-based
handler. The handler exposes its pending payload via React state — so
a separate "app surface" can render the picker outside the chat — and
resolves the Promise once the user interacts. Same UX, different
mechanism.

</WhenFrameworkHas>

<WhenFrameworkHas flag="interrupt_pattern" absent>

> **Not available on this framework.** Headless interrupts are built on
> top of `useInterrupt` / `useFrontendTool` patterns that require the
> runtime to expose either a native `interrupt(...)` primitive
> (LangGraph) or a Promise-resolving frontend-tool path (Microsoft Agent
> Framework). For all other integrations, use
> [`useHumanInTheLoop`](../human-in-the-loop) instead — it's the
> standard hook for tool-call-based pause/resume flows and works on
> every framework that supports tool calls.

</WhenFrameworkHas>

<InlineDemo demo="interrupt-headless" />

## When should I use this?

- **Testing / Playwright fixtures** — a deterministic, chat-less button
  grid is easier to drive than a chat surface where the picker only
  appears after an LLM call.
- **Non-chat UIs** — dashboards, side panels, inspector surfaces, or
  any place where you want the _agent's interrupt_ without the _chat
  transcript_.
- **Custom flow control** — when you need to know exactly when the
  interrupt arrived (e.g. to gate other UI) and when it was resolved.
- **Research / debugging** — when you want to observe the raw AG-UI
  custom events without the abstraction layer.

If you just want "a picker in chat", just use
[`useInterrupt`](./useInterrupt).

<WhenFrameworkHas flag="interrupt_pattern" equals="native">

## The primitives

Under the hood, `useInterrupt` composes two public APIs:

1. **`agent.subscribe({ onCustomEvent, onRunStartedEvent, onRunFinalized, onRunFailed })`**
   — every `AbstractAgent` exposes an AG-UI event subscription. LangGraph
   sends the interrupt through as a custom event named `on_interrupt`
   with the `interrupt(...)` payload as `event.value`.
2. **`copilotkit.runAgent({ agent, forwardedProps: { command: { resume, interruptEvent } } })`**
   — the same call `useInterrupt`'s `resolve()` makes to resume a paused
   run. Pass your response as `resume` and the original interrupt event
   as `interruptEvent`.

Wrap those in your own hook and you get a render-less equivalent of
`useInterrupt`:

<Snippet region="headless-useinterrupt-primitives" title="frontend/src/app/page.tsx — useHeadlessInterrupt" />

A few things this hook is careful about:

- It stages the incoming custom event in a local ref and only commits
  it to React state on `onRunFinalized`, mirroring `useInterrupt`,
  which doesn't surface the interrupt until the run has actually paused
  (not just when the event fires mid-stream).
- `onRunStartedEvent` clears any stale pending state, so kicking off a
  new turn always starts from a clean slate.
- `onRunFailed` drops the staged event so a transport hiccup doesn't
  leave the UI stuck showing a picker for a run that never paused.

</WhenFrameworkHas>

<WhenFrameworkHas flag="interrupt_pattern" equals="promise-based">

## The primitives

The render callback intentionally returns `null` — the picker UI lives
in the app surface, not in the chat transcript. The handler's pending
state drives whether the picker is shown:

<Snippet region="headless-promise-primitives" title="frontend/src/app/page.tsx — useFrontendTool (headless Promise-based)" />

A few things this pattern is careful about:

- The handler stages its `resolve` callback in a ref keyed by tool-call
  id, so concurrent tool calls don't trample each other's resolvers.
- `setPending` is called from inside the handler so the app surface
  re-renders the picker as soon as the agent calls the tool, and again
  with `null` after the user interacts so the picker disappears.
- `render: () => null` keeps the chat transcript clean — the headless
  variant deliberately bypasses inline rendering.

</WhenFrameworkHas>

<WhenFrameworkHas flag="interrupt_pattern" equals="native">

## Driving it from plain UI

Once `useHeadlessInterrupt` returns `{ pending, resolve }`, the rest is
just React. The example below uses two buttons to kick off the agent
and a button grid to resolve, with no `<CopilotChat>` and no render prop:

```tsx
function HeadlessInterruptPanel() {
  const { copilotkit } = useCopilotKit();
  const { agent } = useAgent({ agentId: "interrupt-headless" });
  const { pending, resolve } = useHeadlessInterrupt("interrupt-headless");

  const kickOff = (prompt: string) => {
    agent.addMessage({ id: crypto.randomUUID(), role: "user", content: prompt });
    void copilotkit.runAgent({ agent });
  };

  if (pending) {
    return (
      <div>
        <p>Pick a slot for {pending.value.topic ?? "a call"}:</p>
        {SLOTS.map((s) => (
          <button key={s.iso} onClick={() => resolve({ chosen_time: s.iso, chosen_label: s.label })}>
            {s.label}
          </button>
        ))}
        <button onClick={() => resolve({ cancelled: true })}>Cancel</button>
      </div>
    );
  }

  return <button onClick={() => kickOff("Book a call with sales.")}>Book call</button>;
}
```

</WhenFrameworkHas>

## Going further

- [Tool-based HITL with `useHumanInTheLoop`](../human-in-the-loop) — for
  LLM-initiated pauses where the model decides on the fly to ask the
  user, rather than the runtime forcing the pause itself.
- [`useInterrupt`](./useInterrupt) — the render-prop version of this
  page, with `enabled` gating and `handler` preprocessing.
