pleach
Architecture

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.

  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.

import {
  InterruptManager,
  InterruptScratchpad,
  GraphInterrupt,
} from "@pleach/core";
import type {
  HumanInterrupt,
  HumanResponse,
  PerToolApproval,
} from "@pleach/core";
Subpath@pleach/core/guestInterruptBusSourcesrc/interrupt/Sourcesrc/guestInterruptBus.tsSourcesrc/guestInterruptCallback.ts

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):

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 fieldTypePurpose
interruptBeforestring[]?Tool names that require approval before execution
interruptAfterstring[]?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
}
IntentApprovalDecisionEffect
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 modifiedArgumentsnot 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 argument

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.

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:

MethodSignatureUse
shouldInterruptBefore(toolCall)booleanHonors interruptBefore list + interruptOn predicate
shouldInterruptAfter(toolCall)booleanHonors interruptAfter list
requestApproval(sessionId, toolCall, timeoutMs?)Promise<ApprovalDecision>Pause + emit interrupt.requested; resolves on resume
resume(interruptId, decision)InterruptResolveReceiptSubmit a decision; handled: false when the id is unknown
cancel(interruptId)InterruptCancelReceiptReject the pending promise with "Interrupt cancelled"; receipt echoes cancelled + toolCall + sessionId + state
destroy()InterruptDestroyReceiptTear down the manager; receipt carries cancelledCount + cancelledIds
getPendingInterrupts()PendingInterrupt[]Snapshot of every outstanding pause
getPendingInterrupt(id)PendingInterrupt | nullSingle-lookup variant

Each receipt is a typed audit record — the interrupt.requestedinterrupt.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.

Where to go next

On this page