Status: Stable · v1.1 (2026-04-27). Comprehensive coverage of the canonical
interrupt(payload)primitive, deterministic resume keys, the eightkinddiscriminators (approval,clarification,external-event,custom,conversation.start,conversation.exchange,conversation.close,low-confidence— union completed per RFC 0094), the 5-action approval vocabulary, and the signed-token callback URL surface. Stable surface for external review. Keywords MUST, SHOULD, MAY follow RFC 2119. Seeauth.mdfor the status legend.
Why this exists
Workflow execution often needs to pause for input from outside the engine: human approval, AI clarification, an external event (webhook, scheduled time, message arrival). Without a single canonical primitive, every reason invents its own pause-and-resume semantics — leading to:
- Distributed implementations across
SuspendManager, callback URLs, approval gates, and ad-hoc executor patterns. - Inconsistent replay-determinism guarantees.
- Different correlation conventions per reason, making cross-cutting tooling (admin panels, observability) hard to build.
openwop defines a single interrupt(payload) primitive that NodeModules call to pause execution and resume on external input. Reason discrimination is via a typed kind field on the payload. The 5-action approval vocabulary becomes the canonical kind: "approval" shape; clarification, external-event, and custom interrupts share the same surface.
The interrupt(payload) idiom parallels LangGraph's interrupt — chosen for ecosystem familiarity.
Primitive
An OpenWOP-compliant engine MUST expose interrupt on the NodeContext:
interface NodeContext {
/**
* Pause execution and wait for external resume. Returns the resume value
* (validated against `payload.resumeSchema` if supplied) when an external
* caller resolves the interrupt.
*
* Throws `InterruptCancelledError` if the run is cancelled while suspended.
* Throws `InterruptTimeoutError` if `payload.timeoutMs` elapses without resume.
*/
interrupt<TResume = unknown>(payload: InterruptPayload<TResume>): Promise<TResume>;
}
suspend — accepted alias
interrupt is the canonical name. Much of the v1 pack ecosystem — the core.openwop.hitl and core.openwop.flow packs plus several vendor packs — calls the primitive suspend instead, with the field aliases reason → kind, resumeKey → key, and answerSchema → resumeSchema. A host SHOULD also expose suspend as an alias of interrupt that accepts those aliased field names, so those packs run unmodified. The two names are interchangeable and share identical semantics (including the key-based replay short-circuit below); interrupt is preferred for new code. A host that exposes only interrupt remains v1-compliant — the alias is a compatibility convenience, not a new requirement.
InterruptPayload
interface InterruptPayload<TResume = unknown> {
/** Discriminator for resume-time routing + UI rendering + observability.
* Full set per RFC 0094 — one literal per kind this document defines. */
kind:
| 'approval' | 'clarification' | 'external-event' | 'custom'
| 'conversation.start' | 'conversation.exchange' | 'conversation.close'
| 'low-confidence';
/**
* Deterministic key used to short-circuit on re-entry after process death.
* If two `interrupt()` calls in the same run emit the same key, the second
* returns the cached result of the first. Recommended:
* `${runId}:${nodeId}:${interruptCount}`.
*/
key: string;
/** Optional Zod schema (or equivalent) — resume value validated before return. */
resumeSchema?: ResumeSchemaShape;
/** Optional auto-reject after this duration. Throws `InterruptTimeoutError`. */
timeoutMs?: number;
/** Discriminated payload data (see "Per-kind payloads" below). */
data:
| ApprovalData | ClarificationData | ExternalEventData | CustomData
| ConversationStartData | ConversationExchangeData | ConversationCloseData
| LowConfidenceData;
}
The key field is what makes interrupt replay-deterministic. On a recovery via recoverRunFromEventLog, the engine sees the persisted interrupt.requested event with its key. If the executor calls interrupt() again with the same key, the engine short-circuits and returns the persisted resumeValue without prompting the external system again.
Resume value
type ResumeValue<TResume> = TResume;
External systems resume an interrupt by calling POST /v1/interrupts/{token} (signed-token surface) or POST /v1/runs/{runId}/interrupts/{nodeId} (run-scoped surface, requires approvals:respond scope). The engine validates against resumeSchema and returns the value to the suspended executor.
Per-kind payloads
kind: "approval"
The canonical 5-action approval vocabulary is part of the v1 wire contract:
interface ApprovalData {
artifactId: string;
artifactType: string;
title: string;
description?: string;
artifactData: unknown;
/** Allowed actions on this approval gate. Server enforces. */
actions: Array<'accept' | 'reject' | 'refine' | 'edit' | 'ask'>;
/** Multi-approver quorum (default 1 = single approver). */
requiredApprovals?: number;
approversList?: string[];
/** Resolution policy when one approver rejects in a multi-approver gate. */
rejectionPolicy?: 'single-veto' | 'majority';
}
interface RefineFeedback {
/** Target of the feedback. */
scope: 'whole' | 'section' | 'items';
/** JSON path into artifactData when scope === 'section'. */
sectionPath?: string;
/** Item identifiers when scope === 'items'. */
itemIds?: string[];
/** Structured chip selections — UI-aware, AI-interpreted. */
tags?: string[];
/** Free-text feedback. At least one of {tags, text} SHOULD be populated. */
text?: string;
}
type ApprovalResume =
| { action: 'accept'; feedback?: string; decidedBy?: string; decidedAt: string }
| { action: 'reject'; feedback?: string; decidedBy?: string; decidedAt: string }
| { action: 'refine'; refineFeedback: RefineFeedback; decidedBy?: string; decidedAt: string }
| { action: 'edit-accept'; editedArtifactData: unknown; decidedBy?: string; decidedAt: string }
// 'ask' does NOT exit the suspend — the engine accumulates Q&A
// exchanges on a side variable (e.g., `_askExchanges:{nodeId}`) and
// keeps the suspend pending until accept/reject/refine/edit-accept fires.
;
Layer distinction (decidedBy). The decidedBy field above is typed optional at the resume layer because it represents the wire shape _as submitted by the client_. Clients that have already authenticated to the host (the common case) MAY omit decidedBy and let the host's auth layer populate it from the request principal. Clients that submit on behalf of a different principal (e.g., admin acting-as) MAY supply it explicitly; the host MAY accept or refuse per its policy.
At the event-emission layer the contract is stricter — see decidedBy rules in §"Host-side enforcement boundary" below. Hosts MUST populate decidedBy in every emitted approval.received event. The opacity contract applies in both layers.
Action-name choice ('edit-accept'). The hyphenated form distinguishes "approve with user-edited artifact" from plain 'accept'. Hosts MAY internally collapse this to 'accept' + editedArtifactData? if their downstream pipelines don't need to distinguish, but the wire contract is 'edit-accept' for cross-host interop.
Backward-compat mapping. Legacy clients that send only decision: 'approved' | 'rejected' | 'timeout' | 'cancelled' (the pre-WORKFLOW-REQUEST-CHANGES vocabulary) without an explicit action field SHOULD be normalized by the host:
| Legacy input | Normalized action |
|---|---|
decision: 'approved' | action: 'accept' |
decision: 'rejected' + feedback non-empty | action: 'refine' with refineFeedback: { scope: 'whole', text: <legacy feedback> } |
decision: 'rejected' + no feedback | action: 'reject' |
| `decision: 'timeout' \ | 'cancelled'` |
Hosts MAY also accept legacy feedback: string alongside refineFeedback: RefineFeedback (the structured form supersedes the string when both are present).
Host-side enforcement boundary (decidedBy, role gating, quorum)
decidedBy is host-defined opaque. Hosts MAY use any string identifier — email address, UUID, JWT sub claim, OAuth principal, internal user ID — that is meaningful to the host's identity model. Other openwop consumers (clients, observability tools, conformance suites) MUST treat the value as an opaque string and MUST NOT parse it for structure. The engine emits whatever the host's resolution layer hands it.
Role / permission / quorum enforcement is the host's responsibility, performed BEFORE the engine sees the ApprovalResume. The host's resolution layer is the gate:
1. Caller submits a resume value via POST /v1/runs/{runId}/interrupts/{nodeId}. 2. Host's auth + RBAC layer verifies the principal AND checks approversList / requiredApprovals / role allowlists / budget thresholds against the host's own policy model. 3. Only if the policy check passes does the host hand the verified ApprovalResume to the engine. 4. The engine emits approval.received (or, for non-approval kinds, interrupt.resolved) with the principal recorded in decidedBy.
This factoring keeps openwop minimal — the protocol describes the lifecycle (request → response → recorded principal) and the wire shape, not the host's permission system. A host with rich RBAC (workspace roles, budget-tier approver assignment, multi-org quorum) and a host with no RBAC (single-user CLI runner) both implement the same wire contract.
approversList and requiredApprovals advertise constraint, they do not enforce it. When the engine surfaces an InterruptPayload with approversList: ['admin', 'owner'], the values are advisory metadata for clients (e.g., the UI shows "must be approved by admin or owner"). The actual enforcement at resolve time is host-side — the engine accepts whatever the host hands it. Clients that display the list MUST NOT assume the engine refuses non-listed approvers; the host's resolution layer is the only authoritative gate.
Multi-approver quorum composition is implementation-defined for v1. When requiredApprovals > 1, two valid models exist:
1. _Host-composed quorum_: the host's resolution endpoint accumulates verified resumes (one per approver), applies rejectionPolicy (single-veto / majority) and delivers ONE final ApprovalResume to the engine when quorum is reached. The engine sees a single terminal approval.received event. 2. _Engine-composed quorum_: the engine accumulates votes via per-resume calls to its resolution surface and emits one approval.received per vote (or a single terminal one — implementation choice).
Either model satisfies the v1 wire contract: the FINAL terminal approval.received MUST carry a decidedBy representing whoever closed the quorum (the last approver, or a synthetic quorum:<n>-of-<m> identifier). The intermediate event sequence (whether per-vote partial-state events appear) is NOT spec-locked at v1 — see I1 in §"Open spec gaps."
Interop note. Conformance scenarios assert the wire-level contract (decidedBy non-empty, recorded in payload, immutable across replay) but DO NOT assert that any specific principal value is honored — that's host-policy territory.
The ask action is a side channel: it accumulates Q&A exchanges via the askService callback without resuming the executor. The interrupt stays pending until one of the four exit actions fires.
kind: "clarification"
interface ClarificationData {
questions: Array<{
id: string;
question: string;
/** Optional schema for the answer (e.g., choices, free-text). */
schema?: AnswerSchemaShape;
}>;
contextType?: string;
}
type ClarificationResume = {
answers: Array<{ id: string; answer: unknown }>;
};
kind: "external-event"
interface ExternalEventData {
/** Stable description of what the engine is waiting for. Surfaced in admin
* panels and webhooks. Examples: "stripe.checkout.completed",
* "calendar.event.confirmed", "scheduled-time:2026-05-01T10:00Z". */
eventType: string;
/** Opaque correlation payload — whatever the external system needs to
* match the interrupt back to its source event. */
correlation: Record<string, unknown>;
}
type ExternalEventResume = {
eventPayload: unknown;
};
kind: "custom"
Escape hatch for kinds not yet spec'd. Servers MUST accept and persist; UI rendering and admin tooling are best-effort.
interface CustomData {
customKind: string;
payload: unknown;
}
type CustomResume = unknown;
kind: "conversation.start" (Multi-Agent Shift Phase 4)
Opens a multi-turn conversation context. The host mints a conversationId and emits conversation.opened per run-event.schema.json. Subsequent conversation.exchange suspends reuse the same id. Hosts that don't advertise capabilities.conversationPrimitive: true MUST refuse workflows that reference this kind per the canonical refusal contract in capabilities.md §"Unsupported capability — refusal contract" (refusal at workflow registration or run creation, canonical error envelope, details.requiredCapability: "conversationPrimitive"); such hosts SHOULD direct clients to clarification interrupts for multi-turn flows.
interface ConversationStartData {
conversationId: string; // tenant-unique
title?: string; // optional UI label
initialPrompt?: string; // optional opener turn
}
type ConversationStartResume = void; // start() returns immediately; the conversation context is active
The conversationId MUST be tenant-unique; the host MUST ensure no two distinct conversations in the same tenant share an id. Cross-host portability is NOT normative — conversationIds minted by one host MUST NOT be assumed resolvable by another.
kind: "conversation.exchange" (Multi-Agent Shift Phase 4)
Single-turn suspend within an open conversation. The host emits conversation.exchanged per run-event.schema.json upon resume. The resume value MUST validate against outcomeSchema when supplied — replay determinism + idempotency require validated outcomes per the Phase-4 H4 disposition.
interface ConversationExchangeData {
conversationId: string; // active conversation
prompt: string; // human-targeted question
outcomeSchema?: JSONSchema; // validation contract for the resume value
turnIndex?: number; // 0-indexed; host derives if absent
}
type ConversationExchangeResume = unknown; // shape constrained by outcomeSchema when supplied
Run-status during conversation. While suspended for conversation.exchange, the run's RunSnapshot.status is 'waiting-approval' (reusing the Phase-1 vocabulary; differentiator is payload.reason: 'conversation-input' on the node.suspended event). Per the Phase-4 H3 disposition, no new RunStatus literal is introduced.
kind: "conversation.close" (Multi-Agent Shift Phase 4)
Terminates an open conversation context. The host emits conversation.closed per run-event.schema.json. Final event in the conversation lifecycle — no further conversation.exchanged events MAY follow for this conversationId in this run.
interface ConversationCloseData {
conversationId: string;
reason?: string; // optional close rationale
}
type ConversationCloseResume = void;
kind: "low-confidence" (Multi-Agent Shift Phase 1)
Confidence-escalation contract. Emitted by an agent (typically core.orchestrator.supervisor) when its agent.decided.confidence falls below the run's resolved escalation threshold (default 0.7 per the Phase-1 disposition; per-run override via RunOptions.configurable.escalationThreshold — see run-options.md). The run transitions to RunSnapshot.status = 'waiting-approval'; an operator ratifies the decision via the resume path.
interface LowConfidenceData {
agentId: string; // suspending agent
threshold: number; // resolved escalation threshold
observed: number; // agent's reported confidence
decision?: unknown; // optional sketch of the un-ratified decision
}
type LowConfidenceResume = unknown; // operator-ratified decision shape (typically OrchestratorDecision)
Threshold-resolution policy. confidence < threshold escalates; confidence === threshold continues; confidence: undefined is treated as "no signal" (no escalation); non-finite confidence (NaN/Infinity) is treated as a buggy payload (no escalation; surfaces via debug-bundle).
Conformance pairs with the agent.decided event: when a host emits agent.decided with confidence < threshold, it MUST follow with a node.suspended { reason: 'low-confidence' } per CP-1. The agentConfidenceEscalation.test.ts scenario validates this pairing.
Wire surface
Interrupt requested (event)
When ctx.interrupt(payload) is called, the engine MUST emit:
{
type: 'interrupt.requested',
payload: {
runId: string;
nodeId: string;
interruptId: string;
kind: 'approval' | 'clarification' | 'external-event' | 'custom'
| 'conversation.start' | 'conversation.exchange' | 'conversation.close'
| 'low-confidence'; // full set per RFC 0094
key: string;
data: ApprovalData | ClarificationData | ExternalEventData | CustomData
| ConversationStartData | ConversationExchangeData | ConversationCloseData
| LowConfidenceData;
timeoutMs?: number;
requestedAt: string; // ISO 8601
}
}
The event is durable (in the event log) and surfaced via SSE (updates and debug modes — see stream-modes.md).
Interrupt resolved (event)
When an external caller resolves an interrupt, the engine MUST emit:
{
type: 'interrupt.resolved',
payload: {
runId: string;
nodeId: string;
interruptId: string;
kind: '...';
resumeValue: unknown; // validated against resumeSchema
resolvedAt: string;
resolvedBy: string; // user ID for human resolution; system ID for external events
}
}
Resolution endpoints
An OpenWOP-compliant server MUST expose:
POST /v1/runs/{runId}/interrupts/{nodeId}
Authorization: Bearer <api-key with approvals:respond scope>
Body: { resumeValue: <validated against resumeSchema> }
An OpenWOP-compliant server SHOULD also expose a signed-token surface for asynchronous callbacks where the resolving system isn't authenticated to the protocol surface (e.g., a webhook from a payment provider):
POST /v1/interrupts/{token}
Body: { resumeValue: ... }
See §"Signed resolution tokens" below for the token format, lifecycle, and the companion inspect endpoint.
Signed resolution tokens
The token is HMAC-signed by the server. Format:
token = base64url(payload) + "." + hmac_sha256(secret, payload)
payload = JSON({ runId, nodeId, interruptId, expiresAt, intent: 'resolve' | 'inspect' })
Expiry (RFC 0093). Every signed token MUST carry an expiry (expiresAt). The default SHOULD be 30 minutes. Hosts MAY allow per-interrupt lifetime configuration but MUST cap the token's lifetime at the interrupt's own deadline (timeoutMs) when one exists — a token MUST NOT outlive the interrupt it resolves. An expired token MUST be refused with the canonical 410 interrupt_expired envelope — the response both signed-token endpoints already document in api/openapi.yaml.
Invalidation (RFC 0093). A token MUST be invalidated once its interrupt is resolved, or once the owning run is cancelled or completed. Subsequent use of an invalidated token returns the existing 409 interrupt_already_resolved.
Verification (RFC 0093). Token MAC verification MUST use a constant-time comparison. Hosts SHOULD support overlapping verification secrets (key id / versioned secret) so signing secrets can rotate without orphaning outstanding tokens; the token's algorithm-version discriminator selects which verification secret applies (HMAC-SHA256 is today's only spec'd scheme — see open gap I4 for future formats).
Inspect intent (RFC 0093). GET /v1/interrupts/{token} inspects a pending interrupt (kind, data, requestedAt, expiry) without resolving it — see rest-endpoints.md §HITL. A token minted with intent: "resolve" authorizes both the GET (inspect) and POST /v1/interrupts/{token} (resolve). Hosts MAY additionally mint intent: "inspect" tokens that authorize only the GET; a resolve attempt with an inspect-only token MUST be refused with 403 forbidden.
Error responses
| HTTP | Code | Cause |
|---|---|---|
400 | validation_error | resumeValue fails schema validation |
401 / 403 | unauthenticated / forbidden | API key auth failures (see auth.md); 403 forbidden also covers a resolve attempt with an inspect-only token (RFC 0093) |
410 | interrupt_expired | Signed-token surface only — token past expiresAt (expiry MUSTs normative per RFC 0093) |
404 | interrupt_not_found | The interrupt ID doesn't exist or already resolved |
409 | interrupt_already_resolved | Concurrent duplicate resolve (the second loses); also any use of a token invalidated by resolution or run cancel/complete (RFC 0093) |
422 | interrupt_cancelled | Run was cancelled while interrupt was pending (run-scoped surface; an invalidated signed token returns the 409 above per RFC 0093) |
Cross-tab race semantics for the run-scoped surface: if Tab A and Tab B both POST to resolve the same interrupt, exactly one succeeds; the other receives 409 interrupt_already_resolved.
Replay determinism
An OpenWOP-compliant engine MUST guarantee that a ctx.interrupt(payload) call with key K is invoked at most once for the lifetime of the run, regardless of process death, retry, or replay. Specifically:
1. First call with key K emits interrupt.requested and blocks. 2. External resolve via POST /v1/interrupts/{...} emits interrupt.resolved and unblocks. 3. After process death + recovery via recoverRunFromEventLog, the executor calls ctx.interrupt(payload) with the same key K. The engine consults the event log, finds the prior interrupt.resolved, and returns the persisted resumeValue synchronously (no new interrupt.requested emitted).
Implementations MAY cache resolved interrupts in memory for in-process replays; they MUST consult the event log for cross-process replays.
Admin panel + observability
An OpenWOP-compliant server SHOULD expose a "pending interrupts" admin view listing every run with a non-resolved interrupt.requested event. Each row SHOULD surface: runId, nodeId, kind, requestedAt, age, and a deep-link to the resolution UI for kind: "approval" | "clarification".
OTel attributes per observability.md:
openwop.interrupt_kind— the discriminatoropenwop.interrupt_id— the suspension IDopenwop.interrupt_count— replay-determinism counter
Annotations are not interrupts (RFC 0056)
A run annotation (RFC 0056 — rating / correction / label / flag) is distinct from an interrupt. An interrupt blocks a run awaiting a human decision and carries a signed-token resume contract; an annotation is non-blocking, may be attached during or after a run (including a terminal run), and never gates execution. See RFCS/0056 and observability.md §"Quality signals".
Open spec gaps
| # | Gap | Owner |
|---|---|---|
| I1 | Multi-approver quorum execution semantics — order of votes, partial-state events, half-vote scenarios. v1 defines the final wire state; future minors may standardize intermediate quorum-state events. | future v1.x |
| I2 | Cancel-on-resolve semantics for cross-canvas approvals (parent waits on child interrupt — what happens when parent cancels?) | future |
| I3 | external-event correlation matching — is the spec strict (exact equality on correlation) or fuzzy (subset-match)? | future v1.x |
| I4 | Token format alternatives — JWT, paseto, etc. Currently HMAC-SHA256 is the only spec'd format. | future |
References
auth.md— auth model + scope vocabulary (approvals:respond)rest-endpoints.md—POST /v1/runs/{runId}/interrupts/{nodeId},POST /v1/interrupts/{token},GET /v1/interrupts/{token}(inspect)version-negotiation.md—ctx.getVersionis a separate primitive (versioning ≠ HITL)observability.md—openwop.interrupt_*attributesstream-modes.md—interrupt.requested/interrupt.resolvedevents inupdatesanddebugmodes- LangGraph HITL: <https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/>