# Interrupts (/docs/interrupts)



An interrupt is a turn-level pause: the runtime stops
mid-execution, emits a structured envelope to the consumer, and
waits for a human to respond. The shape is LangGraph-compatible so
external tooling (inspectors, dashboards) interops without
translation. See [Stream events](/docs/stream-events) for
`interrupt.requested` and `interrupt.resolved` on the wire, and
[Checkpointing](/docs/checkpointing) for how a paused turn survives
reloads.

Typical uses: approving a destructive tool call before it fires,
clarifying ambiguous user intent, gating expensive operations
behind a confirm step.

## Five rules for safe interrupts [#five-rules-for-safe-interrupts]

The rules below apply to both pleach interrupts and LangGraph
interrupts — they're properties of the underlying replay model,
not framework-specific. Violating any of the five produces a
non-replayable turn that may behave differently across pause /
resume.

1. **Don't `try` / `catch` the interrupt path.** The runtime
   uses a thrown exception to unwind the stack at the
   interrupt point. Wrapping in `try` / `catch` swallows the
   exception and turns the interrupt into a no-op, leaving
   the turn in an inconsistent state. If you need cleanup,
   register it on `runtime.onAbort`, not in a try-block
   around the interrupt call site.
2. **Payloads must be JSON-serializable.** Interrupt
   envelopes survive serialization to the checkpoint store
   and back. `Date`, `Map`, `Set`, `BigInt`, `Symbol`,
   functions, and non-finite numbers either drop information
   or fail outright. Stick to JSON-safe primitives + plain
   objects + arrays.
3. **Pre-interrupt side effects must be idempotent.** A
   resume re-runs the node body up to the interrupt point.
   If the body sent an email, charged a card, or wrote a
   file before hitting the interrupt, that side effect fires
   twice. Move side effects to **after** the interrupt
   resolves, or use `runtime.cache` to memoize.
4. **Interrupt ordering must be stable across resume.** If a
   node body fires three interrupts in a loop, the same loop
   on resume must fire them in the same order. Random
   iteration order on an unordered collection (a `Set`, a
   `Record` with non-deterministic key order) breaks this.
   Sort the collection before the loop.
5. **`interruptBefore` / `interruptAfter` are debug-only.**
   Static interrupt points configured at graph-compile time
   don't compose with mid-node `interrupt()` calls and
   can't carry payloads. Use them for stepping through a
   graph during dev; use `interrupt()` for production
   human-in-the-loop.

The same rules surface in LangGraph's docs; pleach inherits
them by being LangGraph-shape-compatible at the envelope
level.

```typescript
import {
  InterruptManager,
  InterruptScratchpad,
  GraphInterrupt,
} from "@pleach/core";
import type {
  HumanInterrupt,
  HumanResponse,
  PerToolApproval,
} from "@pleach/core";
```

<SourceMeta
  subpath="@pleach/core/guestInterruptBus"
  source="[
  { label: &#x22;src/interrupt/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/interrupt&#x22; },
  { label: &#x22;src/guestInterruptBus.ts&#x22;, href: &#x22;https://github.com/pleachhq/core/blob/main/src/guestInterruptBus.ts&#x22; },
  { label: &#x22;src/guestInterruptCallback.ts&#x22;, href: &#x22;https://github.com/pleachhq/core/blob/main/src/guestInterruptCallback.ts&#x22; },
]"
/>

## Enabling interrupts [#enabling-interrupts]

Pass `InterruptConfig` at runtime construction. The config declares
*which* tool calls pause the turn — by name (`interruptBefore` /
`interruptAfter`) or by a runtime predicate (`interruptOn`):

```typescript
import { SessionRuntime } from "@pleach/core";

const runtime = new SessionRuntime({
  storage: supabaseAdapter,
  userId:  "user_123",
  interrupt: {
    // Pause BEFORE these tools dispatch, for approval:
    interruptBefore: ["delete_file", "send_email", "execute_sql"],
    // Pause AFTER these tools complete, to review the result:
    interruptAfter: ["run_migration"],
    // Or gate dynamically on the tool call itself:
    interruptOn: (toolCall) => toolCall.name.startsWith("prod_"),
  },
});
```

| `InterruptConfig` field | Type                        | Purpose                                           |
| ----------------------- | --------------------------- | ------------------------------------------------- |
| `interruptBefore`       | `string[]?`                 | Tool names that require approval before execution |
| `interruptAfter`        | `string[]?`                 | Tool names that pause execution after completion  |
| `interruptOn`           | `(toolCall) => boolean` `?` | Runtime predicate; pause when it returns `true`   |

## The `HumanInterrupt` envelope [#the-humaninterrupt-envelope]

What the runtime emits when it pauses. The shape mirrors
LangGraph's `interrupt()` payload exactly.

```typescript
interface HumanInterrupt {
  action_request: {
    action: string;                      // e.g. "approve_tool_call"
    args:   Record<string, unknown>;     // tool args, context, justification
  };
  config: {
    allow_ignore:  boolean;              // skip + continue
    allow_respond: boolean;              // freeform response back to the LLM
    allow_edit:    boolean;              // user edits args before dispatch
    allow_accept:  boolean;              // approve as-is
  };
  description?: string;                  // human-readable why
}
```

`config` declares which response types are valid for this
interrupt. A confirm-or-deny gate sets `allow_accept: true,
allow_ignore: true` and the others false; a "review and edit"
flow sets `allow_edit: true` instead. A UI that renders an Approve
button against `allow_accept: false` is a bug — the manager will
reject the submitted `HumanResponse` and the interrupt stays
pending. Read the `config` block and disable buttons whose
corresponding flag is `false`.

## Receiving an interrupt in the stream [#receiving-an-interrupt-in-the-stream]

```typescript
for await (const event of runtime.executeMessage(sessionId, prompt)) {
  if (event.type === "interrupt.requested") {
    const decision = await showApprovalUI(event.interrupt);
    const receipt = runtime.interrupts.resolve(event.interrupt.id, decision);
    if (!receipt.handled) {
      // No matching pending interrupt (already resolved / expired / unknown id).
    }
  }
}
```

The stream pauses on `interrupt.requested`. The next event after
the consumer calls `runtime.interrupts.resolve` is
`interrupt.resolved`, then the turn resumes from where it paused.

`runtime.interrupts.resolve` is the canonical surface; the flat
`runtime.resolveInterrupt` method remains callable but is
`@deprecated`. See [Facets](/docs/facets) for the broader facet
migration and `runtime.interrupts.manager` for direct access to
the `InterruptManager`.

## Responding [#responding]

`runtime.interrupts.resolve` (and the underlying
`InterruptManager.resume` it delegates to) consume an
`ApprovalDecision` — the runtime branches on `decision.approved`:

```typescript
interface ApprovalDecision {
  approved:           boolean;                  // approve or deny the paused call
  note?:              string;                   // optional freeform note
  modifiedArguments?: Record<string, unknown>;  // edited args, applied on approve
}
```

| Intent             | `ApprovalDecision`                             | Effect                                         |
| ------------------ | ---------------------------------------------- | ---------------------------------------------- |
| Approve as-is      | `{ approved: true }`                           | Dispatch the action with its original args     |
| Approve with edits | `{ approved: true, modifiedArguments: {...} }` | Dispatch with the modified args                |
| Deny               | `{ approved: false }`                          | Skip the action; the turn continues without it |

An **approval-with-edit** is `approved: true` plus
`modifiedArguments` — *not* a separate response type. A payload
that omits `approved` (for example an `{ type: "edit", args }`-shaped
object) leaves `decision.approved === undefined`, which the manager
treats as a **rejection**.

```typescript
const receipt = runtime.interrupts.resolve(interruptId, {
  approved: true,
  modifiedArguments: { ...originalArgs, dryRun: true },
});
// receipt.handled          → true when the pending interrupt was found and resolved
// receipt.resolvedDecision → echoes the decision payload on success
// receipt.interruptId      → always echoes the caller's argument
```

### The resolve receipt [#the-resolve-receipt]

`runtime.interrupts.resolve` returns an `InterruptResolveReceipt`
rather than a bare boolean — the receipt echoes the decision that
was filed so audit logs, optimistic UI confirmations, and plugin
hooks can read the resolution without re-querying the manager.

```typescript
interface InterruptResolveReceipt {
  handled:           boolean;             // strict parity with the legacy boolean
  resolvedDecision?: ApprovalDecision;    // present when handled === true
  interruptId:       string;              // always echoes the argument
}
```

The receipt is always a truthy object — callers previously
branching on the bare boolean (`if (result) ...`,
`Boolean(result)`) continue to behave correctly. Switch on
`receipt.handled` for the original boolean semantics.

The companion `InterruptCancelReceipt` (from
`runtime.interrupts.manager?.cancel(id)`) and `InterruptDestroyReceipt`
(from `manager.destroy()`) follow the same shape — see the
`InterruptManager` section below.

## Per-tool approval [#per-tool-approval]

The most common interrupt pattern. The runtime tracks every
pending tool call as a `PerToolApproval` and gates dispatch on
the user's decision.

```typescript
interface PerToolApproval {
  toolCallId:  string;
  toolName:    string;
  args:        Record<string, unknown>;
  decision:    "pending" | "approved" | "rejected" | "edited";
  editedArgs?: Record<string, unknown>;
}
```

A typical UI iterates the pending approvals and exposes accept /
edit / reject buttons per tool:

```tsx
function ApprovalQueue({ interrupt }: { interrupt: HumanInterrupt }) {
  const approvals = interrupt.action_request.args.approvals as PerToolApproval[];
  return approvals.map((a) => (
    <div key={a.toolCallId}>
      <h3>{a.toolName}</h3>
      <pre>{JSON.stringify(a.args, null, 2)}</pre>
      <button onClick={() => approve(a)}>Approve</button>
      <button onClick={() => reject(a)}>Reject</button>
      <button onClick={() => edit(a)}>Edit args</button>
    </div>
  ));
}
```

## React: `useInterruptUI` [#react-useinterruptui]

`@pleach/core/react` ships `useInterruptUI` — it subscribes to the
runtime's `interrupt.{requested,resolved,timeout}` events, tracks
the pending interrupts as React state, and routes each one to a
caller-supplied handler component via `renderActive()`. Handlers
are matched by `interruptType` (defaulting to the paused tool's
name); the matched component receives the `PendingInterrupt` plus
typed `onResolve` / `onCancel` callbacks.

```tsx
import { useInterruptUI } from "@pleach/core/react";
import type { PendingInterrupt, SessionRuntime } from "@pleach/core";

// A handler renders one interrupt type. The plugin contract types
// `interrupt` as `unknown` (core stays React- and domain-free), so
// narrow it to `PendingInterrupt` to read the paused tool call.
function ApprovalHandler({
  interrupt,
  onResolve,
  onCancel,
}: {
  interrupt: unknown;
  onResolve: (decision: unknown) => void;
  onCancel: () => void;
}) {
  const { toolCall } = interrupt as PendingInterrupt;

  return (
    <Modal open>
      <h2>Approve {toolCall.name}?</h2>
      <pre>{JSON.stringify(toolCall.arguments, null, 2)}</pre>
      {/* onResolve files an ApprovalDecision — approve as-is: */}
      <button onClick={() => onResolve({ approved: true })}>Approve</button>
      {/* approve with edited args: */}
      <button
        onClick={() =>
          onResolve({
            approved: true,
            modifiedArguments: { ...toolCall.arguments, dryRun: true },
          })
        }
      >
        Approve (dry run)
      </button>
      {/* onCancel rejects — shorthand for `{ approved: false }`: */}
      <button onClick={onCancel}>Reject</button>
    </Modal>
  );
}

function ApprovalSurface({ runtime }: { runtime: SessionRuntime | null }) {
  const { renderActive } = useInterruptUI({
    runtime,
    handlers: [{ interruptType: "approvalRequired", component: ApprovalHandler }],
  });

  // Renders nothing until an interrupt arrives; accepts `runtime: null`.
  return <>{renderActive()}</>;
}
```

`useInterruptUI` owns the event wiring and resolve plumbing —
`onResolve` forwards an `ApprovalDecision` to
`runtime.interrupts.resolve`, and `onCancel` rejects the interrupt
(`{ approved: false }`). You just render the approval UI.

## Per-tool dispatch configuration [#per-tool-dispatch-configuration]

When a batch of tool calls returns and some require approval, the
runtime splits the batch so each tool is its own dispatch unit. The
config that drives the split:

```typescript
interface PerToolDispatchConfig {
  alwaysInterrupt: string[];                 // names always paused
  neverInterrupt:  string[];                 // names always bypassed
  defaultBehavior: "interrupt" | "auto-approve";
}
```

A tool name in both lists resolves to `neverInterrupt`. Tools not
named in either list follow `defaultBehavior`. A single-tool batch
short-circuits the dispatcher and runs as-is.

## Programmatic envelope: `InterruptRequest` / `InterruptDecision` [#programmatic-envelope-interruptrequest--interruptdecision]

The lower-level envelope the runtime publishes to the guest
interrupt bus and that observer plugins consume. `HumanInterrupt`
is the UI-shaped wrapper; `InterruptRequest` is the wire shape.

```typescript
interface InterruptRequest {
  interruptId: string;
  toolCall: {
    id:           string;
    name:         string;
    parameters?:  Record<string, unknown>;
    arguments?:   Record<string, unknown>;
  };
  riskLevel: "low" | "medium" | "high";
}

interface InterruptDecision {
  approved:           boolean;
  note?:              string;                  // "always_allow" = session bypass
  modifiedArguments?: Record<string, unknown>; // edit action's new args
}
```

Both shapes carry an index signature — orchestrator-side variants
with extra fields pass through unchanged.

## Guest interrupt bus [#guest-interrupt-bus]

A chat-scoped queue + decision store for sessions that pause on a
server (e.g. an edge function) and resolve on a browser. The
package ships the bus interface and polling helpers; you wire the
KV implementation.

```typescript
import {
  setGuestInterruptStore,
  publishInterruptRequest,
  popInterruptRequest,
  recordInterruptDecision,
  waitForInterruptDecision,
} from "@pleach/core/guestInterruptBus";

setGuestInterruptStore(myRedisBackedStore);

// Server stage:
await publishInterruptRequest(chatId, request);
const decision = await waitForInterruptDecision(request.interruptId, {
  timeoutMs: 5 * 60_000,
  intervalMs: 750,
});

// SSE endpoint:
const queued = await popInterruptRequest(chatId);

// Decision sink:
await recordInterruptDecision(interruptId, decision);
```

The `GuestInterruptStore` contract is `get / setex / del / lpush /
rpop / expire` — any Redis-shaped KV satisfies it. Keys TTL at
10 minutes. `waitForInterruptDecision` returns `null` on timeout
or abort; the caller picks the fail-safe. The split between the
two failure modes matters: `null` from a timeout means "the user
never decided in the window" and the typical action is to fail the
turn closed (deny the gated action); `null` from an abort means
"the parent turn was cancelled by the user" and the action is to
let the abort propagate without surfacing a denial in the UI.

## `InterruptManager` programmatic surface [#interruptmanager-programmatic-surface]

The manager owns pending interrupts and the resolve-or-cancel
plumbing. Public methods:

| Method                                             | Signature                   | Use                                                                                                                      |
| -------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `shouldInterruptBefore(toolCall)`                  | `boolean`                   | Honors `interruptBefore` list + `interruptOn` predicate                                                                  |
| `shouldInterruptAfter(toolCall)`                   | `boolean`                   | Honors `interruptAfter` list                                                                                             |
| `requestApproval(sessionId, toolCall, timeoutMs?)` | `Promise<ApprovalDecision>` | Pause + emit `interrupt.requested`; resolves on resume                                                                   |
| `resume(interruptId, decision)`                    | `InterruptResolveReceipt`   | Submit a decision; `handled: false` when the id is unknown                                                               |
| `cancel(interruptId)`                              | `InterruptCancelReceipt`    | Reject the pending promise with `"Interrupt cancelled"`; receipt echoes `cancelled` + `toolCall` + `sessionId` + `state` |
| `destroy()`                                        | `InterruptDestroyReceipt`   | Tear down the manager; receipt carries `cancelledCount` + `cancelledIds`                                                 |
| `getPendingInterrupts()`                           | `PendingInterrupt[]`        | Snapshot of every outstanding pause                                                                                      |
| `getPendingInterrupt(id)`                          | `PendingInterrupt \| null`  | Single-lookup variant                                                                                                    |

Each receipt is a typed audit record — the `interrupt.requested`
→ `interrupt.resolved` boundary is captured by
`InterruptDecisionRecord` on the audit ledger as well. See
[Typed records](/docs/typed-records) for the persisted shape.

The manager also emits `interrupt.timeout` when `defaultTimeoutMs`
fires — a separate event from `interrupt.resolved`, so observability
can distinguish a user no-op from a denial.

Walk-through: a per-tool approval for `execute_sql` raises an
interrupt at `T+0`. The user is mid-meeting and the modal sits open
for `defaultTimeoutMs` (say, 5 minutes). At `T+5min`, the manager
fires `interrupt.timeout` with the same `interruptId`, the pending
promise from `requestApproval` rejects, and the turn lands as
`subagent.failed` (if inside a subagent) or `error` (root turn) with
code carrying the timeout origin. Compare against the user actively
clicking "Reject": that path fires `interrupt.resolved` with
`decision.approved === false` and a real human response. The two
shapes look similar in a dashboard — the timeout signal is what
tells you to surface a different remediation ("approval expired,
re-request" vs "approval denied, revise the plan").

## Terminal writes to the event log [#terminal-writes-to-the-event-log]

When the interrupt manager resolves an interrupt — approve, deny,
or edit — it writes a terminal event log row carrying the
resolution payload. The row closes the pending interrupt and
records the decision verbatim, including any edited args.

That terminal row is the deterministic boundary replay tools
depend on. Replaying an interrupted turn from the event log
re-establishes the same post-interrupt state without re-prompting
the human.

For the event log shape and the projections that fold these rows,
see [Event log](/docs/event-log) and [Event log projections](/docs/event-log-projections).

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Stream events" href="/docs/stream-events" description="`interrupt.requested` / `interrupt.resolved` payloads." />

  <Card title="Tools" href="/docs/tools" description="The tools that get gated by per-tool approval." />

  <Card title="Plugin contract" href="/docs/plugin-contract" description="How plugins observe the stream and contribute to a turn." />

  <Card title="Checkpointing" href="/docs/checkpointing" description="How a paused turn survives reloads." />
</Cards>
