Status: Stable · v1.1 (2026-04-27; hygiene pass 2026-05-10). Comprehensive coverage of the canonical REST surface with per-route auth + scope, formalized in
api/openapi.yamlagainst the JSON Schemas inschemas/. Replay/fork has shipped atreplay.md+ thePOST /v1/runs/{runId}:forkendpoint. Remaining gaps are additive operational conveniences (bulk operations, explicit pause/resume, and any future gRPC transport), not blockers for v1 conformance. Keywords MUST, SHOULD, MAY follow RFC 2119. Seeauth.mdfor the status legend.
Scope
This document catalogs the REST surface an OpenWOP-compliant server MUST expose, plus optional surfaces (MCP, A2A) that a server MAY expose. Path templates use {paramName} for path parameters.
Versioning
- All paths under
/v1/are versioned. Breaking changes go to/v2/. - A server MAY support multiple versions concurrently (
/v1/...and/v2/...side by side) for migration windows. - Servers MUST return
400 Bad Requestfor paths under unversioned roots.
Required endpoints
Every OpenWOP-compliant server MUST expose:
Discovery
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
GET | /.well-known/openwop | None | None | Capability declaration (see capabilities.md) |
GET | /v1/openapi.json | None | None | Self-describing OpenAPI spec |
Workflow manifest
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
GET | /v1/workflows/{workflowId} | API key | manifest:read | Workflow definition |
Runs
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
POST | /v1/runs | API key | runs:create | Create a run |
GET | /v1/runs/{runId} | API key | runs:read | Read run state |
GET | /v1/runs/{runId}/events | API key | runs:read | SSE event stream (resumable via Last-Event-ID) |
GET | /v1/runs/{runId}/events/poll | API key | runs:read | Long-poll fallback for non-SSE clients |
GET | /v1/runs/{runId}/ancestry | API key | runs:read | RFC 0040 cross-host composition parent (capability-gated; 404 when unadvertised) |
GET | /v1/agents | API key | runs:read | RFC 0072 §A manifest-agent inventory (capability-gated on agents.manifestRuntime; 404 when unadvertised) |
GET | /v1/agents/{agentId} | API key | runs:read | RFC 0072 §A one manifest agent's inventory entry; 404 when absent/unadvertised |
GET | /v1/agents/{agentId}/deployments | API key | runs:read | RFC 0082 §C/§E deployment records for a manifest agent (capability-gated on agents.deployment; 404 when unadvertised) |
POST | /v1/agents/{agentId}/deployments | API key | deploy:* | RFC 0082 §E deployment state transition (promote/pause/deprecate/rollback/adjust-canary); fail-closed authz (RFC 0049) + RFC 0051 approvalGate + RFC 0081 requiredEval; 403 eval_gate_unmet / 400 no_active_deployment |
GET | /v1/agents/roster | API key | runs:read | RFC 0086 §B standing agent roster (named instances + portfolios; capability-gated on agents.roster; tenant-scoped per RFC 0074; 404 when unadvertised) |
GET | /v1/agents/roster/{rosterId} | API key | runs:read | RFC 0086 §B one roster entry; 404 when absent/cross-tenant/unadvertised |
GET | /v1/tools | API key | runs:read | RFC 0078 §B portable tool catalog (ToolDescriptor[] across node-pack/workflow/mcp/connector/host-extension sources; capability-gated on toolCatalog; §F-2 authorization-scoped; 404 when unadvertised) |
GET | /v1/tools/{toolId} | API key | runs:read | RFC 0078 §B one ToolDescriptor; 404 when absent/unauthorized/unadvertised |
GET | /v1/agents/org-chart | API key | runs:read | RFC 0087 §C descriptive agent org-chart (capability-gated on agents.orgChart; tenant-scoped; 404 when unadvertised) |
GET | /v1/agents/org-chart/{departmentId} | API key | runs:read | RFC 0087 §D one department's subtree + responsibility roll-up (?recursive=false for direct members); 404 when unknown/cross-tenant/unadvertised |
GET | /v1/runs/{runId}/eval-summary | API key | runs:read | RFC 0081 §C the EvalSummary scorecard for a terminal mode:"eval" run (capability-gated on agents.evalSuite; 404 when unadvertised/not-an-eval-run; 409 while running) |
GET | /v1/runs/{runId}:diff | API key | runs:read | RFC 0054 deterministic structured diff of two runs (?against={otherRunId}; runs:read on both; 404 when unimplemented) |
POST | /v1/runs/{runId}/cancel | API key | runs:cancel | Cancel an in-flight run |
POST | /v1/runs:bulk-cancel | API key | runs:cancel | Bulk cancel a set of in-flight runs |
POST | /v1/runs/{runId}:fork | API key | runs:create + runs:read | Fork or replay a run from recorded state |
POST | /v1/runs/{runId}:pause | API key | runs:cancel | Administratively pause an in-flight run |
POST | /v1/runs/{runId}:resume | API key | runs:cancel | Resume a paused run |
POST | /v1/runs/{runId}/annotations | API key | runs:annotate | RFC 0056 record a quality annotation (capability-gated; 501 when capabilities.feedback unadvertised) |
GET | /v1/runs/{runId}/annotations | API key | runs:read | RFC 0056 list a run's annotations (capability-gated; 501 when unadvertised) |
GET | /v1/host/workspace/files | API key | workspace:read | RFC 0059 list workspace file metadata for the caller's tenant·workspace (capability-gated; 501 when capabilities.workspace unadvertised; optional ?prefix=) |
GET | /v1/host/workspace/files/{path} | API key | workspace:read | RFC 0059 read one workspace file (capability-gated; 404 when absent; ?version=N when versioned) |
PUT | /v1/host/workspace/files/{path} | API key | workspace:write | RFC 0059 atomic create/replace (honors If-Match; 409 workspace_conflict on stale etag; 413 workspace_too_large over maxFileBytes; emits workspace.updated) |
DELETE | /v1/host/workspace/files/{path} | API key | workspace:write | RFC 0059 delete a workspace file (capability-gated; emits workspace.updated) |
POST | /v1/trigger-subscriptions | API key | webhooks:manage | RFC 0099 register an external-event (webhook/email/form) trigger subscription bound to a workflow (capability-gated on triggerBridge.ingestion.registrationEndpoint; 501 when unadvertised); returns the subscription + a one-time source binding |
GET | /v1/host/sample/a2a/tasks/{taskId} | API key | runs:read | RFC 0100 host-extension convenience read of the persisted A2ATaskState durable projection (capability-gated on a2a.durableTasks; 501 when unadvertised; the normative read stays the A2A tasks/get JSON-RPC method) |
POST /v1/runs request
{
"workflowId": "string (required)",
"inputs": "object (optional)",
"tenantId": "string (optional, server-defaults from API key)",
"scopeId": "string (optional, opaque correlation)",
"callbackUrl": "string (optional, signed-token HITL callback)",
"configurable": "object (optional, per-run parameter overlay)",
"tags": "string[] (optional)",
"metadata": "object (optional)"
}
Headers:
Authorization: Bearer <key>— REQUIREDIdempotency-Key— RECOMMENDED (seeidempotency.md)X-Dedup: enforce— OPTIONAL; when set, server cross-host claim system rejects duplicate (tenantId, scopeId) pairs with409 Conflict
POST /v1/runs response
{
"runId": "string",
"status": "pending | running | waiting-approval | ...",
"eventsUrl": "string (SSE endpoint)",
"statusUrl": "string"
}
Status codes:
201 Created— run accepted400 Bad Request— malformed body, unknown workflowId, invalid inputs401/403— auth failures (seeauth.md)409 Conflict—X-Dedupcollision; body{ error: "run_already_active", message, details: { activeRunId, activeHost, retryAfter } }; headerRetry-After: <seconds>429 Too Many Requests— rate-limit; headerRetry-After: <seconds>
POST /v1/runs:bulk-cancel request
{
"runIds": ["run-...", "run-..."],
"reason": "string (optional, free-form rationale)"
}
runIds MUST be a non-empty array (1..100 entries) of run identifiers the caller can runs:cancel. The cap on array length is host-defined (REQUIRED upper bound for any one request — RECOMMENDED 100); requests exceeding the cap MUST return 400 validation_error with details.maxRunIds indicating the configured ceiling. Hosts MUST process each cancellation independently; partial failure MUST NOT block successful cancellations of sibling ids.
Response:
{
"results": [
{ "runId": "run-aaa", "status": "cancelling", "ok": true },
{ "runId": "run-bbb", "ok": false, "error": { "code": "not_found", "message": "..." } }
]
}
results[i].runIdechoes the corresponding request entry verbatim. Order MUST match the request'srunIdsarray.results[i].ok: trueindicates the host accepted the cancel intent; the run will transition tocancelling(perruns/{runId}/cancelsingle-cancel semantics) and emitrun.cancelledwhen the cascade completes.results[i].statuscarries the post-acceptance state.results[i].ok: falsecarries a canonicalErrorEnvelope-shaped object undererror. Common codes:not_found,forbidden,run_terminal(already-completed/failed/cancelled),validation_error.
Status codes:
200 OK— the request reached at least one runId; per-id outcomes are inresults. The host MUST return200even when every runId failed individually — the top-level operation succeeded.400 Bad Request—runIdsarray malformed (empty, oversized, non-string entries, or missing).401 / 403— auth failures on the request itself.
Idempotency. Bulk-cancel is naturally idempotent — re-issuing the same bulk request returns the same per-id outcomes (already-cancelled runs return ok: true, status: 'cancelled'). Idempotency-Key is RECOMMENDED to collapse retries.
Auth scope. Single runs:cancel; the same scope that gates /v1/runs/{runId}/cancel. Per-runId authorization MUST still be enforced — a caller without visibility on a runId gets forbidden in that result entry (not a top-level 403).
POST /v1/runs/{runId}:pause request
{
"reason": "string (optional, free-form rationale persisted on run.paused payload)",
"drainPolicy": "immediate | drain-current-node (optional, default 'drain-current-node')"
}
drainPolicy: 'immediate' snapshots the run between events; drainPolicy: 'drain-current-node' lets the executing node reach a terminal (node.completed / node.failed / node.suspended) before the run transitions to paused. Hosts MUST persist the chosen policy on the run.paused event payload.
Response:
{ "runId": "string", "status": "paused", "pausedAt": "ISO8601" }
Status codes:
202 Accepted— pause requested; transition emitsrun.pausedevent when complete404 Not Found— run does not exist or caller can't see it409 Conflict— run is already paused, terminal, or in a state that cannot be paused;details.runStatuscarries the current state
Idempotency: a :pause against a run that is already paused returns 409 (with the existing pause's pausedAt in details) unless the request carries Idempotency-Key matching the original pause, in which case the host returns 202 with the cached response per idempotency.md.
POST /v1/runs/{runId}:resume request
{
"reason": "string (optional)"
}
Resumes a paused run. The next executable event after the run.paused is run.resumed; execution continues from the position recorded at pause.
Response:
{ "runId": "string", "status": "running", "resumedAt": "ISO8601" }
Status codes:
202 Accepted— resume in flight404 Not Found— run does not exist409 Conflict— run is not currently paused;details.runStatuscarries the actual state
Pause/resume is distinct from cancel and from suspend-on-interrupt:
cancelis terminal; the run transitions tocancelledand cannot resume.- HITL
suspendis a workflow-driven wait for a resume event (approval, clarification, etc.); the run is inwaiting-*notpaused. :pauseis operator-driven; the run ispausedand only:resume(or explicit cancel) exits the state.
Replay of a run that was paused mid-execution re-folds run.paused and run.resumed events as no-ops for state-projection purposes; the events are observable in the event log but do not affect the projected state.
GET /v1/runs/{runId}:diff?against={otherRunId} (RFC 0054)
A read-only, deterministic, replay-aware structured diff of two runs — typically a run and its :fork replay. The response body conforms to run-diff-response.schema.json: { a, b, divergedAtSeq, eventDiffs[], stateDiff, truncated? }.
- The caller MUST hold
runs:readon both{runId}andagainst; a caller lacking read on either receives403 forbidden(composing with RFC 0048 cross-workspace isolation). - Both runs SHOULD be terminal. A host MAY diff in-flight prefixes and, when it does, MUST set
truncated: true. - The diff MUST be a pure function of the two runs' event logs: given the same two logs, every conformant host MUST return the same
divergedAtSeqand the same orderedeventDiffs. Sequence alignment is by eventseq; the firstseqwhose event differs by canonical comparison setsdivergedAtSeq. Identical logs MUST yielddivergedAtSeq: nulland an emptyeventDiffs. This aligns with the determinism contract and thereplay.divergedevent inreplay.md. - Canonical comparison — excluded fields (normative). Two events at the same
seqare compared on theirtype+ their canonicalizedpayload(RFC 8785 JCS, perreplay.md). The following fields MUST be excluded from the comparison because they are run-scoped or non-deterministic by construction — including them would make every cross-run diff report total divergence:eventId,runId,causationId,correlationId, the wall-clockts(receipt/emit time), and any transport metadata (traceparent, delivery headers). Equivalently, the comparison key is(seq, type, JCS(payload-minus-excluded)). Payload sub-fields that are themselves run-scoped ids carried for replay (e.g.memoryId,childRunId) are compared as-is — they are part of the observable payload, and a fork that re-mints them is a genuine divergence. - The endpoint is OPTIONAL; a host that does not implement it MUST return
404for the path (and MAY omit it from its OpenAPI). Clients discover support via the endpoint manifest.
HITL (approvals + suspensions)
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
POST | /v1/runs/{runId}/interrupts/{nodeId} | API key | approvals:respond | Resolve a run-scoped interrupt or approval gate |
POST | /v1/interrupts/{token} | Signed token | None | Resolve any HITL interrupt via callback URL |
GET | /v1/interrupts/{token} | Signed token | None | Inspect an interrupt without resolving |
The signed-token surface (/v1/interrupts/{token}) is for asynchronous HITL where the server POSTed a callback URL to an external system at suspension time. Tokens are HMAC-signed by the server and MUST carry an expiry (RFC 0093; the default SHOULD be 30 min, capped at the interrupt's own deadline when one exists). Token intent governs which methods a token authorizes (RFC 0093): a token minted with intent: "resolve" authorizes both GET /v1/interrupts/{token} (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. See interrupt.md §"Signed resolution tokens" for token format and lifecycle (expiry, invalidation, constant-time verification).
Artifacts
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
GET | /v1/runs/{runId}/artifacts/{artifactId} | API key | artifacts:read | Read a run-produced artifact |
Webhooks
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
POST | /v1/webhooks | API key | webhooks:manage | Register a subscription |
DELETE | /v1/webhooks/{webhookId} | API key | webhooks:manage | Unregister |
Audit-log integrity (gated on profile)
Hosts that advertise the openwop-audit-log-integrity profile per auth-profiles.md MUST expose:
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
GET | /v1/audit/verify | API key | audit:read | Re-walk the audit-log hash chain over [fromSeq, toSeq] and return chain-validity verdict + signed checkpoints + anomalies. See auth-profiles.md §openwop-audit-log-integrity §4 and schemas/audit-verify-result.schema.json. |
Prompt library (RFC 0028; gated on capabilities.prompts.*)
Hosts that advertise capabilities.prompts.endpointsSupported: true per prompts.md §"Discovery & distribution" expose the read endpoints. The mutating endpoints additionally require capabilities.prompts.mutableLibrary: true. Hosts without the relevant capability return 501 capability_not_provided. Note: capabilities.prompts.supported: true (without endpointsSupported) gates only the _node-execution_ PromptRef-resolution surface (Phase A); it does NOT imply the REST endpoints are available.
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
GET | /v1/prompts | API key | prompts:read | Paginated list with ?kind, ?tag, ?modelClass, ?source filters + opaque cursor + limit. |
POST | /v1/prompts | API key | prompts:write | Create a user-source PromptTemplate (mutable libraries only). Idempotency-Key supported. Returns 201 with Location. |
GET | /v1/prompts/{templateId} | API key | prompts:read | Fetch a template; optional ?version SemVer pin + optional ?libraryId for cross-pack disambiguation. ETag + If-None-Match revalidation. |
PUT | /v1/prompts/{templateId} | API key | prompts:write | Replace a user-source template; submitted SemVer MUST be strictly greater than stored. Mutable libraries only. |
DELETE | /v1/prompts/{templateId} | API key | prompts:write | Delete a user-source template; 403 on host-built-in or pack-sourced. Mutable libraries only. |
POST | /v1/prompts:render | API key | prompts:read | Render a template against supplied variable bindings; returns composed body + sha256 hash + per-variable hashes. Deterministic-hash invariant per RFC 0027 §F. Does NOT dispatch an LLM call. |
Localized content surface (RFC 0103; gated on capabilities.content.supported)
Hosts that advertise capabilities.content.supported: true (which requires i18n.supported) serve the authored-content surface per localized-content.md. The public delivery path is anonymous-capable and cacheable; locale is negotiated from Accept-Language (no ?locale=). Admin paths are tenant-scoped. Hosts without the advertisement return 501 capability_not_provided.
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
GET | /v1/content/pages/{slug} | None | None | Public delivery — resolve a published page for the negotiated locale (§C merge); sets Content-Language + Vary + Cache-Control. |
GET | /v1/content/pages | API key | content:read | Admin list of the caller-tenant's pages (draft + published). |
POST | /v1/content/pages | API key | content:write | Create a content page. |
PUT | /v1/content/pages/{pageId}/sections/{sectionId} | API key | content:write | Locale-targeted upsert — { locale, data } writes base data (locale==baseLocale) or localizations[locale]. |
GET | /v1/content/settings | API key | content:read | Read the tenant's language settings (baseLocale/supportedLocales/autoTranslateOnPublish). |
PUT | /v1/content/settings | API key | content:write | Update language settings; baseLocale ∉ supportedLocales MUST hold. |
Pack-registry test-mode namespace (RFC 0025; gated on capabilities.packs.testMode)
Hosts that advertise capabilities.packs.testMode.supported: true per node-packs.md §"Test-mode registry namespace" expose a mirror surface against an isolated catalog so the conformance suite can exercise the 19-code publish error catalog without packs:publish scope on the real registry. Hosts without the advertisement return 404 Not Found for every path below. The endpoints mirror the production /v1/packs/* PUT/GET/DELETE/sig surface verbatim — same request bodies, response shapes, status codes, and error code vocabulary.
| Method | Path | Auth | Scope | Purpose |
|---|---|---|---|---|
PUT | /v1/packs-test/{name}/-/{version}.tgz | API key | packs:publish | Publish a pack tarball to the isolated test catalog. Surfaces the documented 19-code error catalog from node-packs.md §"PUT /v1/packs/{name}/-/{version}.tgz" verbatim. Idempotent re-publish returns 200; new publishes return 201; conflicting re-publish returns 409. |
GET | /v1/packs-test/{name}/-/{version}.tgz | API key | packs:read | Fetch a published test-catalog tarball. Mirror of GET /v1/packs/{name}/-/{version}.tgz. |
DELETE | /v1/packs-test/{name}/-/{version} | API key | packs:publish | Unpublish a test-catalog version. Mirror of DELETE /v1/packs/{name}/-/{version} — returns 400 unpublish_window_expired for versions outside the unpublish window. |
GET | /v1/packs-test/{name}/-/{version}.sig | API key | packs:read | Fetch the detached signature blob for a test-catalog version. Mirror of GET /v1/packs/{name}/-/{version}.sig. |
Optional endpoints (transports)
An OpenWOP-compliant server MAY expose additional transports. If exposed, they MUST follow these contracts:
Server-Sent Events (SSE)
The GET /v1/runs/{runId}/events endpoint MUST:
- Set
Content-Type: text/event-stream - Honor
Last-Event-IDrequest header to resume from the next sequence after that ID - Emit a comment line (
:keepalive) at least every 30 seconds to prevent intermediary timeouts - Auto-close the connection when the run reaches a terminal status (
completed,failed,cancelled) - Stream events with
id:,event:,data:fields per the SSE spec
MCP (Model Context Protocol)
If exposed, the server MUST mount MCP at /v1/mcp (platform) or /v1/mcp/{namespace} (namespaced). MCP endpoints follow the MCP spec; this openwop spec does not redefine MCP semantics.
A2A (Agent-to-Agent)
If exposed, the server MUST mount A2A at /v1/a2a (platform) or /v1/a2a/{namespace} (namespaced). The agent card is at /v1/a2a/agent.json.
Headers
| Header | Direction | Purpose |
|---|---|---|
Authorization | Request | Bearer <api-key-or-jwt> |
Idempotency-Key | Request | Per-mutation idempotency token (see idempotency.md) |
X-Dedup | Request | enforce to opt into cross-host run-claim deduplication |
X-Force-Engine-Version | Request (test-keys-only) | Forces the run to emit events at the specified engine version. Used by the conformance suite to verify forward-compat fold-best-effort. Servers MUST reject on production keys with 403 force_engine_version_forbidden. See version-negotiation.md + F5 in conformance/fixtures.md. |
Last-Event-ID | Request (SSE) | Resume from sequence after this ID |
Retry-After | Response | Seconds to wait before retrying (with 409, 429, 503) |
traceparent / tracestate | Both | W3C Trace Context propagation (RECOMMENDED) |
Error response shape
All error responses (REST surface) MUST be JSON:
{
"error": "<machine_readable_code>",
"message": "<human_readable>",
"details": "object (optional, error-specific)"
}
The wire shape is locked at exactly these three top-level fields per schemas/error-envelope.schema.json (additionalProperties: false). Hosts that need to surface contextual data (retry hints, conflict refs, server-side trace IDs, validation field paths) MUST place it under details, never at a new top level.
details.correlationId convention (RECOMMENDED for 5xx)
When a host issues a server-side trace ID for a 5xx response so the API consumer can quote it when filing a bug, the canonical home is details.correlationId:
{
"error": "internal_error",
"message": "An unexpected error occurred.",
"details": {
"correlationId": "01H8Z9..."
}
}
Reasoning:
- The schema declares
additionalProperties: false, so a top-levelcorrelationIdis non-conformant. Strict validators reject it. detailsis the documented contextual-data slot. Existing examples in this spec (retryAfter,activeRunId,capability,requirement) follow the same pattern.- The convention is RECOMMENDED, not REQUIRED. Hosts that don't emit trace IDs are still spec-conformant.
The conformance suite's errors.test.ts (suite version 1.15.0+) asserts:
1. No top-level keys outside {error, message, details} (additionalProperties:false equivalent). 2. When present, details.correlationId MUST be a non-empty string.
Hosts emitting correlationId at the top level (or other extras like hint / requestId / traceId) MUST move them under details.
Common error codes:
unauthenticated,forbidden,key_expired,key_revoked— seeauth.mdvalidation_error— request body/params malformed;detailsenumerates fieldsnot_found— resource doesn't exist or caller can't see it (do not leak existence)run_already_active—X-Dedupcollisionrun_forbidden— RFC 0048. The caller's identity triple ({ tenant, workspace, principal }) is not authorized for the requested run — most commonly aprincipalscoped to oneworkspaceattempting to read a run owned by another (cross-workspace isolation, fail-closed). The host MUST NOT leak the run's existence or contents; HTTP status403(or404to avoid existence disclosure). Distinct fromnot_foundonly when the host chooses to signal the authorization boundary explicitly.workspace_membership_required— RFC 0028 Tier-2 follow-up. The caller's authenticated principal is not a member of the workspace named in a workspace-scoped request to/v1/prompts*(POST/PUT/DELETE bodies carryingworkspaceId, or GET?workspaceId=query). Perprompts.md§"Workspace membership on workspace-scoped reads and writes", hosts MUST verify membership from the authenticated identity BEFORE honoring the request; caller-suppliedworkspaceIdMUST NOT be trusted as authorization. Canonical envelope on HTTP 403 for hosts that surface this distinct authz failure:{ "error": "workspace_membership_required", "message": "<diagnostic, no fixed shape>" }. Hosts MAY refuse with other 4xx/5xx codes (401 if "you can't write to that workspace" is interpreted as authentication-level; 404 to avoid existence disclosure; etc.) — the canonical envelope shape is pinned only when the host chooses 403. Verified by SECURITY invariantsprompt-mutation-workspace-membership-enforced(write path) andprompt-read-workspace-membership-enforced(read path).recursion_limit_exceeded— run terminated due to safety caprun_timeout— RFC 0058. A run exceeded its effectiverunTimeoutMs(min(runTimeoutMs, capabilities.limits.maxRunDurationMs)).details.elapsedMsSHOULD report the observed duration (mirrorssandbox_timeout). Pairs withcap.breached { kind: 'run-duration' }. The run terminatesfailed. Applies only on hosts advertisingcapabilities.limits.maxRunDurationMs.loop_limit_exceeded— RFC 0058. A run exceeded its effectivemaxLoopIterations(min(maxLoopIterations, capabilities.limits.maxLoopIterations)).details.iterationSHOULD report the count reached. Pairs withcap.breached { kind: 'loop-iterations' }. The run terminatesfailed. Applies only on hosts advertisingcapabilities.multiAgent.executionModel.supported(the execution loop counts orchestrator turns, RFC 0037; RFC 0061version: 5adds the observableiterationcounter).schedule_horizon_exceeded— RFC 0052. Ascheduletrigger requested a fire time beyondcapabilities.scheduling.maxFutureHorizon.details.maxFutureHorizonSHOULD echo the advertised cap. Applies only on hosts advertisingcapabilities.scheduling.supported.workspace_conflict— RFC 0059. APUT /v1/host/workspace/files/{path}carried a staleIf-Matchetag — the file changed since the caller last read it. HTTP status409.details.currentVersionSHOULD carry the file's liveversionso the client can re-read and retry. Applies only on hosts advertisingcapabilities.workspace.supported.workspace_too_large— RFC 0059. A workspacePUT'scontentexceededcapabilities.workspace.maxFileBytes. HTTP status413(hosts MAY use400where413is impractical).details.maxFileBytesSHOULD echo the advertised cap;details.requestedBytesMAY identify how much was attempted. Applies only on hosts advertisingcapabilities.workspace.supported.token_budget_exceeded— RFC 0062. A memory-distillation ("dream") run could not be meaningfully distilled within its effectivedistillation.tokenBudget(min(distillation.tokenBudget, capabilities.memory.distillation.maxTokenBudget)). The run MUST fail atomically — no partial archive is written.details.budgetSHOULD echo the budget;details.minimumRequiredSHOULD report the host's best-effort estimate of the tokens needed (counted against the advertisedtokenizerName, ±10%). Applies only on hosts advertisingcapabilities.memory.distillation.supported. (Present in the SDK error vocabulary prior to this RFC; RFC 0062 registers it in the spec error-code list.)rate_limited— too many requestscapability_not_provided— a node'srequiresdeclared a runtime capability the host has not registered. ThemessageMUST name the missing capability id;details.capabilitySHOULD carry the same id machine-readably. The run terminatesfailedand the offending node MUST NOT execute. Seecapabilities.md§"Runtime capabilities".capability_required—POST /v1/runs(or workflow registration) refused a workflow that references a capability-gated reserved typeId (core.conversationGate,core.orchestrator.supervisor,core.dispatch) on a host that does not advertise the gating capability.details.requiredCapabilitySHOULD name the gating capability key (e.g.,"conversationPrimitive");details.offendingTypeIdSHOULD name the typeId that tripped the gate;details.nodeIdSHOULD identify the workflow node. HTTP status MUST be one of400/404/422. Seecapabilities.md§"Unsupported capability — refusal contract".credential_required— a node'srequiresSecrets[]declared a secret but none was resolved. Either the host'sSecretResolverreturned null/undefined for the requested(id, scope)ORRunOptions.configurable.ai.credentialRefwas missing for akind: 'ai-provider'requirement on a BYOK provider.messageSHOULD name the missing secret id;details.requirementSHOULD carry the fullSecretRequirementshape.credential_forbidden— caller passedRunOptions.configurable.ai.credentialReffor a provider NOT inCapabilities.aiProviders.byok. The host doesn't permit BYOK for that provider; the caller must omit the credentialRef and let the host route via platform-managed keys.details.providerSHOULD carry the offending provider id. RFC 0046 (capabilities.credentials) reuses this code for ahost.credentialsreference that resolves but is out of the caller's{ tenant, workspace, principal }scope (fail-closed; the host MUST NOT silently substitute a different credential); in that casedetails.refMAY echo the rejected reference anddetails.capabilitySHOULD carry"credentials".credential_unavailable— a node declaresrequiresSecrets[]but the host doesn't advertiseCapabilities.secrets.supported = true. The host fundamentally can't resolve secrets for this run. The run terminatesfailedand the offending node MUST NOT execute.details.requirementSHOULD carry the SecretRequirement shape;details.capabilitySHOULD carry"secrets"so machine readers can map to the missing capability section. RFC 0046: the same code applies when a node declaresrequiredCredentials[]but the host doesn't advertiseCapabilities.credentials.supported = true(details.capabilitycarries"credentials").credential_not_found— RFC 0046. Ahost.credentialsreference ({ ref, scope }) does not resolve in the host's credential store, OR the old key of atwo-key-overlaprotation is past its grace window. The node MUST fail;details.refMAY echo the unresolved reference. Hosts MAY surface a revoked credential ascredential_not_foundto avoid leaking lifecycle metadata.credential_scope_unsupported— RFC 0046. Ahost.credentialsreference requested ascopenot present incapabilities.credentials.scopes.details.scopeSHOULD carry the requested scope.oauth_provider_unsupported— RFC 0047. A connector node'sauth.provideris not incapabilities.oauth.providers[].id; the host refuses to register the pack.details.providerSHOULD carry the offending provider id.oauth_scope_unsupported— RFC 0047. A connector node requested an OAuth scope not in the provider's advertisedscopesSupported; the host refuses to register the pack.details.provider+details.scopeSHOULD identify the offending request.connector_auth_expired— RFC 0047. A stored OAuth token's refresh failed terminally (revoked/expired refresh token) during node execution. Pairs with theconnector.auth_expiredevent. The node MUST fail;details.provider+details.credentialRefSHOULD identify the connection. No token material inmessageordetails.connector_action_unresolved— RFC 0045. A pack'sconnector.actions[].typeId(or aconnector.triggers[]entry) does not resolve to anodes[].typeIdin the same manifest; the host refuses to register the pack.details.typeIdSHOULD carry the unresolved reference.envelope_truncation_unrecoverable— RFC 0033 §F. Truncation retry budget exhausted while the failure mode remained truncation. Pairs withenvelope.retry.exhausted { finalReason: "truncation" }(RFC 0032 §B.2) andcap.breached { kind: "schema" }. The node MUST fail.details.nodeIdSHOULD identify the failing node;details.totalAttemptsSHOULD report the attempt count. Distinct fromenvelope_invalidwhich covers schema-violation-retry exhaustion — the two paths require fundamentally different retry strategies perai-envelope.md§"Envelope-completion criteria" + RFC 0033 §A.envelope_invalid— RFC 0021 §"Validation outcomes" + RFC 0033 §C/§F. Schema-violation retry budget exhausted (or single emission failed payload validation with retries disabled). Pairs withenvelope.retry.exhausted { finalReason: "schema-violation" }(RFC 0032 §B.2) andcap.breached { kind: "schema" }. Renamed fromenvelope_payload_invalidper the 2026-05-21 RFC adoption-feedback amendment (alignment with the host pattern ofenvelope_<short-failure-mode>names; mirrors theenvelope.invalidhost-internal classification). The node MUST fail.details.nodeIdSHOULD identify the failing node;details.totalAttemptsSHOULD report the attempt count.envelope_refusal— RFC 0033 §F. Provider returned an explicit refusal (OpenAImessage.refusal, Anthropic safety-stop, Gemini safety-block). The host MUST NOT retry per RFC 0032 §B.3 + RFC 0033 §D — retrying refusal with prompt mutation creates a circumvention concern. Pairs withenvelope.refusalevent (RFC 0032 §B.3). Renamed fromenvelope_refused_by_providerper the 2026-05-21 amendment to mirror theenvelope.refusalRunEvent type name.details.provider+details.modelidentify the model that refused;details.safetyCategoryMAY echo the provider's safety category when known. The errormessageMUST NOT include the provider's refusal text (which can echo offending prompt content); that surface lives on theenvelope.refusal.refusalTextevent field, redacted per SECURITY invariantenvelope-refusal-no-prompt-leak.sandbox_memory_exceeded— RFC 0035 §C. Sandbox invocation exceededcapabilities.sandbox.memoryLimitBytes.details.requestedBytesMAY identify how much was attempted;details.limitBytesSHOULD echo the advertised cap. The node MUST fail. Applies only on hosts that advertisecapabilities.sandbox.supported: true.sandbox_timeout— RFC 0035 §C. Sandbox invocation exceededcapabilities.sandbox.wallClockLimitMs.details.elapsedMsSHOULD identify the observed duration. The node MUST fail.sandbox_capability_denied— RFC 0035 §C. Sandbox code called a host capability not incapabilities.sandbox.allowedHostCalls.details.requestedCapabilityMUST be set. The node MUST fail closed; this is the capability-gate-respected invariant in observable form.sandbox_escape_attempt— RFC 0035 §C. Sandbox detected an explicit escape attempt (a syscall from a forbidden list, an out-of-sandbox-root filesystem access, an unauthorized fork/exec).details.escapeKindSHOULD identify which invariant was violated (e.g.,host-fs-escape,host-process-escape,host-env-leak). The node MUST fail closed.replay_memory_snapshot_unavailable— RFC 0039 §B. The host advertisescapabilities.multiAgent.executionModel.version >= 2ANDcapabilities.memory.supported: truebut cannot serve the memory snapshot for the requestedPOST /v1/runs/{runId}:fork fromSeq. Per the MAE-3 contract inspec/v1/multi-agent-execution.md§"Replay carry-forward", forks from past event-log indices MUST return memory state as-of that index, not current state; hosts that haven't persisted a snapshot at the requested index MUST refuse the fork rather than silently substitute current memory.details.fromSeqSHOULD identify the requested index;details.oldestAvailableIdxMAY identify the oldest index for which a snapshot exists so the client can pick a valid fork point.replay_diverged_at_refusal— RFC 0041 §B. The host advertisescapabilities.multiAgent.executionModel.version >= 4ANDcapabilities.multiAgent.executionModel.replayDeterminism.refusalDivergenceEmission: true. During aPOST /v1/runs/{runId}:fork mode: replayinvocation, the replay's LLM call produced a refusal while the original got a valid envelope (or vice-versa). Per the MAE-8 contract inspec/v1/replay.md§"Envelope-refusal recovery in replay (MAE-8 closure)", silent substitution is non-conformant; the host MUST fail the replay with this error code AND emit areplay.divergedAtRefusalevent identifying the diverging node and envelope kinds.details.atSequenceSHOULD identify the event-log index of the divergence;details.nodeIdSHOULD identify the diverging node;details.originalEnvelopeKind+details.replayEnvelopeKind(each"valid"or"refusal") SHOULD identify the direction of divergence.internal_error— unexpected server failure (no implementation details inmessage)rate_limited— request rejected because the caller exceeded a per-tenant, per-route, or global rate limit. Normative envelope: the response MUST set HTTP status429, MUST set theRetry-Afterheader (seconds), AND SHOULD carrydetails.retryAfterMsanddetails.scope. See §"429 Too Many Requestsenvelope" below.
429 Too Many Requests envelope (normative)
When a host returns 429, the response body MUST conform to the canonical ErrorEnvelope with error: "rate_limited" and SHOULD carry the following keys under details:
{
"error": "rate_limited",
"message": "Rate limit exceeded for tenant t-abc on /v1/runs.",
"details": {
"retryAfterMs": 12500,
"scope": "tenant"
}
}
Field semantics:
details.retryAfterMs— milliseconds the caller should wait before retrying. When present, MUST be consistent with theRetry-AfterHTTP header (which carries integer seconds);retryAfterMsprovides sub-second precision. When the host cannot compute a deterministic backoff,retryAfterMsMAY be omitted; theRetry-Afterheader remains REQUIRED.details.scope— closed enum:"tenant" | "route" | "global" | "key". Identifies which limit fired. Clients SHOULD use this to disambiguate retries (a"tenant"-scoped limit affects only one customer;"global"affects everyone;"key"is per-credential).details.limit— OPTIONAL integer ceiling that fired (e.g., requests per minute). Useful for clients that want to display the limit to operators.details.observedRate— OPTIONAL integer observed rate at the time of rejection (same units aslimit).
Hosts MUST NOT introduce additional top-level keys on the envelope (the error-envelope.schema.json declares additionalProperties: false). Vendor-specific data goes under details.
Conformance: rate-limit-envelope.test.ts (suite version 1.16.0+) verifies that any 429 response satisfies this shape.
Legacy deployment aliases
Some deployments may have surfaces that predate openwop and use a slightly different shape. New OpenWOP-compliant deployments SHOULD use the spec'd paths above. Existing deployments MAY continue serving:
/v1/canvas-types/{canvasTypeId}/runs— adds canvas-type scoping in the path. openwop spec moves canvas-type out of the path; servers that want canvas-type filtering SHOULD use a query parameter (?canvasTypeId=...) or carry it on the workflow definition./v1/canvas-types/{canvasTypeId}/manifest— same shape as above.
These canvas-typed routes MAY be served as aliases that map internally to the spec routes. They are not part of the v1 conformance surface.
Open spec gaps
| # | Gap | Owner |
|---|---|---|
| R1 | ✅ Bulk-cancel endpoint landed in v1.0 (this doc, 2026-05-12). | closed |
| R2 | ✅ Explicit administrative pause/resume endpoints — landed in v1.0 (this doc, 2026-05-10). | closed |
| R3 | ✅ Optional gRPC transport profile landed at spec/v1/grpc-transport.md (Phase B, 2026-05-12). REST + SSE remains the REQUIRED wire surface; gRPC is an additional opt-in surface advertised via capabilities.supportedTransports: ["grpc"]. Canonical service definition at api/grpc/openwop.proto. | closed |
| R4 | ✅ Endpoint coverage manifest landed at conformance/coverage.md §"Endpoint Coverage Manifest". Every OpenAPI operationId MUST appear there (enforced by spec-corpus-validity.test.ts). Auto-generation tooling is a future polish; the manual manifest + the gate already close the gap. | closed |
References
auth.md— authentication + scopesidempotency.md—Idempotency-Keycontractcapabilities.md—/.well-known/openwopinterrupt.md— HITL + callback token formatreplay.md— fork/replay endpoint