Interrupts
Pause a turn for human approval — the `HumanInterrupt` envelope, response shapes, per-tool approval, and how interrupts compose with the stream.
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 for
interrupt.requested and interrupt.resolved on the wire, and
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
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.
- Don't
try/catchthe interrupt path. The runtime uses a thrown exception to unwind the stack at the interrupt point. Wrapping intry/catchswallows the exception and turns the interrupt into a no-op, leaving the turn in an inconsistent state. If you need cleanup, register it onruntime.onAbort, not in a try-block around the interrupt call site. - 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. - 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.cacheto memoize. - 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, aRecordwith non-deterministic key order) breaks this. Sort the collection before the loop. interruptBefore/interruptAfterare debug-only. Static interrupt points configured at graph-compile time don't compose with mid-nodeinterrupt()calls and can't carry payloads. Use them for stepping through a graph during dev; useinterrupt()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.
import {
InterruptManager,
InterruptScratchpad,
GraphInterrupt,
} from "@pleach/core";
import type {
HumanInterrupt,
HumanResponse,
PerToolApproval,
} from "@pleach/core";@pleach/core/guestInterruptBusSourcesrc/interrupt/Sourcesrc/guestInterruptBus.tsSourcesrc/guestInterruptCallback.tsEnabling 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):
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
What the runtime emits when it pauses. The shape mirrors
LangGraph's interrupt() payload exactly.
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
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 for the broader facet
migration and runtime.interrupts.manager for direct access to
the InterruptManager.
Responding
runtime.interrupts.resolve (and the underlying
InterruptManager.resume it delegates to) consume an
ApprovalDecision — the runtime branches on decision.approved:
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.
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 argumentThe 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.
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
The most common interrupt pattern. The runtime tracks every
pending tool call as a PerToolApproval and gates dispatch on
the user's decision.
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:
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
@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.
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
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:
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
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.
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
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.
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
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 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
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 and Event log projections.