Every command below was run against the v2 reference host from openwop/openwop-examples, and every response shown is real output, trimmed.
Start a host
You need Node 20 or later, git, curl and jq.
git clone https://github.com/openwop/openwop-examples.git
cd openwop-examples/examples/hosts/v2-reference
npm install --legacy-peer-deps
npm start
--legacy-peer-deps is required: the host pins the conformance suite and its contract package as exact peers, which npm's default resolver refuses. The host listens on http://127.0.0.1:3838, keeps its state in one SQLite file, and accepts a single development key.
In a second terminal, set the two variables the rest of this guide uses. The OpenWOP CLI reads the same names.
export OPENWOP_BASE_URL=http://127.0.0.1:3838
export OPENWOP_API_KEY=openwop-v2-dev-key
The reference host is a single process built to show the protocol, not to run production traffic. To use your own host instead, change these two values.
Discover what the host supports
Every host serves one discovery document at /.well-known/openwop. The OpenWOP-Version header selects which representation you get.
curl -s "$OPENWOP_BASE_URL/.well-known/openwop" -H "OpenWOP-Version: 2" \
| jq '{protocolVersions, preferredVersion, implementation, lanes: [.auth.lanes[].lane]}'
{
"protocolVersions": ["1.11", "2.0"],
"preferredVersion": "1.11",
"implementation": {
"name": "openwop-host-v2-reference",
"version": "2.0.0-rc.1",
"vendor": "openwop (reference example)"
},
"lanes": ["api-key", "session", "saml", "scim", "workload"]
}
protocolVersions lists every major the host serves; 2.0 is there, so v2 requests will work. preferredVersion is 1.11 because this host also serves the previous major, which is why you send the header. The lanes are the ways the host accepts credentials.
Everything else at the root is either metadata or a capability record. To list the capability families this host advertises:
curl -s "$OPENWOP_BASE_URL/.well-known/openwop" -H "OpenWOP-Version: 2" \
| jq -c '[to_entries[] | select(.value | type == "object" and has("status")) | .key]'
["limits","eventLog","interrupt","runList","replay","webhooks","idempotency","compensation","feedback","heartbeat","a2a","mcp","toolCatalog","workflowChainPacks","packs","sandbox","workspace","auth","conversationPrimitive","oauth","agents","supportedEnvelopes","schemaVersions","envelopeStrictness"]
Each record carries status, since, witness and the family's own facets. The replay record, for example, names the fork modes the host accepts:
curl -s "$OPENWOP_BASE_URL/.well-known/openwop" -H "OpenWOP-Version: 2" | jq '.replay'
{
"status": "experimental",
"since": "2.0",
"until": "2.1",
"witness": "witnessable-gated",
"modes": ["replay", "branch"],
"retention": { "days": 30 },
"effectSeamsManifest": "/host/effect-seams"
}
Check a family's record before you use the feature. If the key is absent, the host does not offer it.
Two negotiation rules are worth seeing once. Without the header, you get the host's preferred major:
curl -si "$OPENWOP_BASE_URL/.well-known/openwop" | grep -i '^openwop-version'
OpenWOP-Version: 1.11
A major the host does not serve is refused with 406, and the error lists what it does serve:
curl -s "$OPENWOP_BASE_URL/.well-known/openwop" -H "OpenWOP-Version: 3" | jq .
{
"error": "protocol_version_unsupported",
"message": "major 3 is not served by this host",
"details": { "protocolVersions": ["1.11", "2.0"] }
}
The discovery document also carries a standard ETag; send it back as If-None-Match and an unchanged document answers 304.
Read: capabilities for the record shape and the closed root, and versioning for the negotiation rules.
Authenticate
Protocol operations take a bearer credential. How you obtain one depends on the lane: the reference host's api-key lane accepts the development key above, and a production host issues its own. Without a credential, the request is refused:
curl -si -X POST "$OPENWOP_BASE_URL/runs" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"workflowId":"conformance-noop"}'
HTTP/1.1 401 Unauthorized
OpenWOP-Version: 2.0
WWW-Authenticate: Bearer
Content-Type: application/json; charset=utf-8
{"error":"unauthenticated","message":"Authorization: Bearer <credential> is required"}
The host resolves every credential to a Subject, and that Subject owns the runs you create. Scopes include runs:create, runs:read, runs:cancel, approvals:respond, artifacts:read and the rest.
Read: identity for lanes, Subjects, revocation and the id grammars.
Create a run and read its snapshot
POST /runs starts a workflow. conformance-noop is a one-node fixture the reference host ships; it completes almost at once.
curl -s -X POST "$OPENWOP_BASE_URL/runs" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"workflowId":"conformance-noop","inputs":{}}' | tee run.json | jq .
{
"runId": "openwop-reference-tenant/hiCD9cCLBhv0yOQ4Y1PNNU8v",
"status": "pending",
"eventsUrl": "http://127.0.0.1:3838/runs/openwop-reference-tenant~2FhiCD9cCLBhv0yOQ4Y1PNNU8v/events",
"statusUrl": "http://127.0.0.1:3838/runs/openwop-reference-tenant~2FhiCD9cCLBhv0yOQ4Y1PNNU8v"
}
The 201 body is { runId, status, eventsUrl, statusUrl? }. Save the run id in its path-safe form; jq's @uri turns the / into %2F:
RUN_ID=$(jq -r '.runId | @uri' run.json)
Then read the snapshot:
curl -s "$OPENWOP_BASE_URL/runs/$RUN_ID" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" | jq .
{
"runId": "openwop-reference-tenant/hiCD9cCLBhv0yOQ4Y1PNNU8v",
"workflowId": "conformance-noop",
"status": "completed",
"owner": {
"tenant": "openwop-reference-tenant",
"subject": {
"issuer": "urn:openwop-host-v2-reference:api-key",
"subjectId": "default",
"tenant": "openwop-reference-tenant",
"lane": "api-key",
"kind": "user"
}
},
"eventLogSchemaVersion": 3,
"engineVersion": 1,
"compensationStatus": "none",
"variables": {},
"startedAt": "2026-09-27T05:45:07.133Z",
"completedAt": "2026-09-27T05:45:07.137Z"
}
owner.subject records who created the run. eventLogSchemaVersion: 3 marks a log written under v2.
Idempotency-Key is optional but recommended. Keys must match ^[A-Za-z0-9._~-]{22,128}$; a UUID fits, and a shorter key is refused with 400 idempotency_key_invalid. Sending the same create twice with one key returns the original run rather than starting a second one, and the replayed response says so:
HTTP/1.1 201 Created
{"runId":"openwop-reference-tenant/_gemiiE3se3ihi12wQhNIDlj", ...}
HTTP/1.1 201 Created
OpenWOP-Idempotent-Replay: true
{"runId":"openwop-reference-tenant/_gemiiE3se3ihi12wQhNIDlj", ...}
The create body is closed: an unknown top-level key is 400 validation_error, and a workflow that needs a capability the host does not advertise is 422 capability_required.
Read: runs for the run surface, the snapshot fields, cancel, pause and fork; idempotency for the key grammar and replay rules.
Receive events
A run is its append-only event log. Every event has a sequence that starts at 0 and only increases. There are three ways to read it.
Server-sent events
Stream the run over SSE. -N stops curl buffering the stream:
curl -sN "$OPENWOP_BASE_URL/runs/$RUN_ID/events?streamMode=updates" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2"
id: 0
event: run.started
data: {"eventId":"a2yifnU3eMxIwiv4aFqSUDgW","runId":"openwop-reference-tenant/hiCD9cCLBhv0yOQ4Y1PNNU8v","type":"run.started","payload":{"workflowId":"conformance-noop","inputs":{},"transport":"rest",...},"sequence":0,...}
id: 1
event: node.started
data: {...,"type":"node.started","payload":{"nodeId":"noop","typeId":"core.noop","attempt":0},"sequence":1,...}
id: 2
event: node.completed
data: {...,"type":"node.completed","payload":{"nodeId":"noop","outputs":{},"durationMs":0},"sequence":2,...}
id: 3
event: run.completed
data: {...,"type":"run.completed","payload":{"outputs":{},"durationMs":4},"sequence":3,...}
Each frame's id: is the event's sequence, event: is its type, and data: is the full event. The host closes the stream after the run's terminal event.
streamMode selects which events you receive:
| Mode | You get |
|---|---|
updates | Run and node transitions, suspensions and interrupt events. The default, and the one mode every host must implement. |
values | A full state.snapshot after each transition. Cannot be combined with other modes. |
messages | Token chunks from streaming AI nodes, for chat interfaces. |
debug | Every event in the log, including vendor events. |
Combine the others with commas, such as updates,messages. A mode the host does not serve is 400 unsupported_stream_mode, with details.supported listing the modes it does serve. Add bufferMs (0 to 5000) to receive events in event: batch frames.
To resume after a disconnect, send the last id: you processed as Last-Event-ID. The host sends only later events:
curl -sN "$OPENWOP_BASE_URL/runs/$RUN_ID/events" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Last-Event-ID: 2"
id: 3
event: run.completed
data: {...,"type":"run.completed","payload":{"outputs":{},"durationMs":4},"sequence":3,...}
Polling
Where SSE is not practical, long-poll with afterSequence. The response carries events with a higher sequence; omit the parameter to start from the first event.
curl -s "$OPENWOP_BASE_URL/runs/$RUN_ID/events/poll?afterSequence=1" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
| jq '{lastSequence, status, isTerminal, events: [.events[] | {sequence, type}]}'
{
"lastSequence": 3,
"status": "completed",
"isTerminal": true,
"events": [
{ "sequence": 2, "type": "node.completed" },
{ "sequence": 3, "type": "run.completed" }
]
}
Feed lastSequence back as the next afterSequence, and stop when isTerminal is true. timeout (1 to 60 seconds, default 30) sets how long the host waits for new events.
Webhooks
A host that advertises the webhooks family pushes matching events to your HTTPS endpoint, signed, with retries and a dead-letter sink. Register a subscription:
curl -s -X POST "$OPENWOP_BASE_URL/webhooks" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{
"url": "https://my-app.example/openwop-webhook",
"events": ["run.completed", "run.failed", "interrupt.requested"],
"secret": "replace-with-a-long-random-secret"
}' | tee webhook.json | jq .
{
"webhookId": "openwop-reference-tenant/ShVWniIstF0B_Io2x2c2vTDO"
}
The response is only { webhookId }: nothing returns a secret to you, so generate one yourself and send it in secret. The URL must be https:// and publicly routable. The host refuses loopback, private and cloud-metadata addresses:
{
"error": "webhook_url_rejected",
"message": "url names a loopback, private, link-local or metadata host (webhooks.md §Egress)",
"details": { "url": "https://localhost:9000/hook" }
}
Unregister with the escaped id. The host answers 204 and makes no further delivery attempts for that subscription, including scheduled retries:
curl -si -X DELETE "$OPENWOP_BASE_URL/webhooks/$(jq -r '.webhookId | @uri' webhook.json)" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" | head -1
HTTP/1.1 204 No Content
Each delivery is a POST whose body is { runId, workspaceId?, event }, where event is the run event exactly as the stream carries it. Five headers come with it: OpenWOP-Webhook-Id, OpenWOP-Event-Type, OpenWOP-Timestamp, OpenWOP-Signature (sha256=<hex>) and OpenWOP-Signature-Algorithm (v1). Verify every delivery before you act on it:
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the exact bytes received. headers: lower-cased header names.
function verify(rawBody, headers, secret) {
if (headers['openwop-signature-algorithm'] !== 'v1') return false;
const ts = headers['openwop-timestamp'] ?? '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // ±5 minutes
const sig = (headers['openwop-signature'] ?? '').replace(/^sha256=/, '');
const expected = createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex');
return sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
}
Delivery is at least once, so the same event can arrive more than once. Deduplicate on (OpenWOP-Webhook-Id, runId, sequence). The TypeScript SDK ships the same check as verifyWebhookSignature in @openwop/openwop/webhooks.
To see a delivery end to end you need a receiver the host can reach over public HTTPS, such as one behind a tunnel. The reference host's egress guard refuses a receiver on your own machine, as the spec requires. The function above was checked against a real delivery from the reference host with that guard relaxed for local testing; do not relax it on a host you expose.
Read: events for the envelope, stream modes, SSE frames and the poll cursor; webhooks for signing, durability and egress rules.
Answer an interrupt
A run that needs a human decision, an answer or an external signal suspends on an interrupt. conformance-approval suspends on an approval gate named gate:
curl -s -X POST "$OPENWOP_BASE_URL/runs" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"workflowId":"conformance-approval"}' > approval.json
APPROVAL_ID=$(jq -r '.runId | @uri' approval.json)
curl -s "$OPENWOP_BASE_URL/runs/$APPROVAL_ID/events/poll" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
| jq '{status, requested: (.events[] | select(.type == "interrupt.requested") | {nodeId, payload})}'
{
"status": "waiting-approval",
"requested": {
"nodeId": "gate",
"payload": {
"kind": "approval",
"key": "openwop-reference-tenant/-FNm3JHnG_ZbKH2YNEwOamhF:gate:0",
"data": {
"artifactId": "gate",
"artifactType": "conformance-artifact",
"title": "Conformance approval",
"actions": ["accept", "reject"],
"description": "Conformance suite — please accept to complete the run."
}
}
}
}
The snapshot's currentNodeId also names the waiting node. Resolve it through the run-scoped surface, choosing one of the offered actions:
curl -s -X POST "$OPENWOP_BASE_URL/runs/$APPROVAL_ID/interrupts/gate" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"resumeValue":{"action":"accept","decidedAt":"2026-09-27T12:00:00Z","feedback":"Looks good"}}' | jq .
{
"runId": "openwop-reference-tenant/-FNm3JHnG_ZbKH2YNEwOamhF",
"nodeId": "gate",
"status": "running"
}
The run records interrupt.resolved, then node.resumed, node.completed and run.completed. An action outside actions is 400 validation_error. A second resolve of the same interrupt fails, which is how two approvers racing each other are kept apart:
{"error":"interrupt_already_resolved","message":"the interrupt was already resolved, or its run is terminal"}
HTTP 409
Every interrupt kind uses this one resolve contract: approval, clarification, external-event, custom, the conversation.* kinds, low-confidence and credential. Approvals can also restrict who may decide (approversList) and require a quorum.
A host should also offer a signed-token surface, GET and POST /interrupts/{token}, for approvers who do not hold a protocol credential. How the token reaches them, through a callbackUrl or an email link for example, is up to the host, so it is not shown here.
Read: interrupt for the payload of each kind, the resolve surfaces, tokens and approver enforcement.
Pause, resume and cancel
Pause, resume, cancel and bulk cancel are core operations in v2, not optional extras. Start a run that waits for a minute:
curl -s -X POST "$OPENWOP_BASE_URL/runs" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"workflowId":"conformance-delay","inputs":{"delayMs":60000}}' > slow.json
SLOW_ID=$(jq -r '.runId | @uri' slow.json)
Pause it. drainPolicy is drain-current-node (the default) or immediate:
curl -s -X POST "$OPENWOP_BASE_URL/runs/$SLOW_ID:pause" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"drainPolicy":"immediate"}' | jq -c .
{"runId":"openwop-reference-tenant/98w3lsHw-qcPAzKzD5V2UbAO","status":"paused"}
Resume it with :resume, and cancel it with /cancel:
curl -s -X POST "$OPENWOP_BASE_URL/runs/$SLOW_ID:resume" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{}' | jq -c .
curl -s -X POST "$OPENWOP_BASE_URL/runs/$SLOW_ID/cancel" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"reason":"done testing"}' | jq -c .
{"runId":"openwop-reference-tenant/98w3lsHw-qcPAzKzD5V2UbAO","status":"running","resumedAt":"2026-09-27T05:45:10.488Z"}
{"runId":"openwop-reference-tenant/98w3lsHw-qcPAzKzD5V2UbAO","status":"cancelling"}
cancelling means the cancel was accepted; the run emits run.cancelled when it finishes. Operations that do not fit the run's state are 409: pausing a paused run is run_state_conflict, and cancelling a finished run is run_terminal:
{"error":"run_terminal","message":"a cancelled run cannot be cancelled","details":{"runStatus":"cancelled"}}
POST /runs:bulk-cancel takes { runIds, reason? } (up to 100 ids) and answers one result per id, in order.
Fork a run
Forking creates a new run from any point in an existing run's log. A host that advertises replay serves it.
Replay re-executes the workflow against the current code, holding the source's earlier events as fixed history. Use it to check that today's code reproduces what happened:
curl -s -X POST "$OPENWOP_BASE_URL/runs/$RUN_ID:fork" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"mode":"replay"}' | jq .
{
"runId": "openwop-reference-tenant/48SVcRqaPNqiJrSvtFTp4vfn",
"sourceRunId": "openwop-reference-tenant/hiCD9cCLBhv0yOQ4Y1PNNU8v",
"fromSeq": 0,
"mode": "replay",
"status": "pending",
"eventsUrl": "http://127.0.0.1:3838/runs/openwop-reference-tenant~2F48SVcRqaPNqiJrSvtFTp4vfn/events"
}
fromSeq defaults to 0 for a replay. A replay must not repeat external side effects: a node that calls out resolves to the outcome the source run recorded, and fails closed if there is none. GET /host/effect-seams lists the seams the host guards:
{"seam":"http.fetch","guarded":true,"branchReFires":false}
{"seam":"webhook.fanout","guarded":true,"branchReFires":true}
Branch starts a new, independent run from the state at fromSeq, optionally with different run options. Branch the approval run from just before its decision (sequence 2 is the interrupt.requested event), and take the other path:
curl -s -X POST "$OPENWOP_BASE_URL/runs/$APPROVAL_ID:fork" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"mode":"branch","fromSeq":2,"runOptionsOverlay":{"tags":["branch-reject"]}}' \
| tee branch.json | jq .
{
"runId": "openwop-reference-tenant/4YYn25dH-SCQdLlfzBKpT4aN",
"sourceRunId": "openwop-reference-tenant/-FNm3JHnG_ZbKH2YNEwOamhF",
"fromSeq": 2,
"mode": "branch",
"status": "running",
"eventsUrl": "http://127.0.0.1:3838/runs/openwop-reference-tenant~2F4YYn25dH-SCQdLlfzBKpT4aN/events"
}
The branch waits at the gate again. Reject it this time:
BRANCH_ID=$(jq -r '.runId | @uri' branch.json)
curl -s -X POST "$OPENWOP_BASE_URL/runs/$BRANCH_ID/interrupts/gate" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{"resumeValue":{"action":"reject","decidedAt":"2026-09-27T12:05:00Z"}}' | jq -c .
{"runId":"openwop-reference-tenant/4YYn25dH-SCQdLlfzBKpT4aN","nodeId":"gate","status":"failed"}
The source run still reads completed; the branch reads failed and carries the branch-reject tag. runOptionsOverlay is for branches only; a replay with an overlay is 400. A fromSeq that is not in the source log is 422 fork_point_invalid. The child run's owner is copied from the source.
Read: replay for determinism, side-effect suppression and divergence events.
Configure a run
Run options travel in the create body as configurable, tags and metadata. In v2 configurable is a closed, nested object with a required version: 1, not a flat map of dotted keys:
curl -s -X POST "$OPENWOP_BASE_URL/runs" \
-H "Authorization: Bearer $OPENWOP_API_KEY" \
-H "OpenWOP-Version: 2" \
-H "Content-Type: application/json" \
-d '{
"workflowId": "conformance-noop",
"configurable": {
"version": 1,
"run": { "recursionLimit": 500, "runTimeoutMs": 120000 }
},
"tags": ["quickstart", "q3-launch"],
"metadata": { "requestedBy": "you@example.com" }
}'
The snapshot returns them unchanged, and they cannot change after the run is created:
{
"status": "completed",
"configurable": {
"version": 1,
"run": { "recursionLimit": 500, "runTimeoutMs": 120000 }
},
"tags": ["quickstart", "q3-launch"],
"metadata": { "requestedBy": "you@example.com" }
}
The sections are run (recursionLimit, runTimeoutMs, maxLoopIterations, escalationThreshold), ai, distillation, budget and extensions. An unknown key is refused:
{"error":"validation_error","message":"unknown configurable key ai.provider (closed schema)","details":{"path":"configurable.ai.provider"}}
Bring your own key
Model selection and BYOK go in the ai section. credentialRef is an opaque reference the host issued for a key it holds; key material never appears on the wire.
"configurable": {
"version": 1,
"ai": { "provider": "anthropic", "model": "claude-sonnet-5", "credentialRef": "secret_a3b9c2" }
}
This needs a host that advertises the aiProviders family. provider must be listed in aiProviders.providers, and credentialRef must refer to a provider listed in aiProviders.byok, or the host answers 403 credential_forbidden. The reference host advertises no AI provider, so it refuses the request above:
{"error":"validation_error","message":"ai.provider MUST be in aiProviders.providers — this host advertises no AI provider","details":{"path":"configurable.ai.provider"}}
Read: runs §Run options for every key and its limits.
Write a node pack
A node pack is a versioned, signed bundle of node types that workflows reference by typeId. A minimal v2 manifest:
{
"name": "community.your-group.salesforce-tools",
"version": "1.0.0",
"kind": "node",
"engines": { "openwop": ">=2.0 <3.0.0" },
"nodes": [
{
"typeId": "community.your-group.salesforce.upsert",
"version": "1.0.0",
"category": "integration",
"role": "side-effect",
"capabilities": ["side-effectful"],
"configSchemaRef": "schemas/upsert.config.json",
"inputSchemaRef": "schemas/upsert.input.json",
"outputSchemaRef": "schemas/upsert.output.json"
}
],
"runtime": {
"language": "javascript",
"entry": "dist/index.js",
"format": "esm",
"requires": ["net.outbound"]
}
}
This manifest validates against the v2 node-pack-manifest schema. Three rules to know:
engines.openwopneeds an explicit major ceiling. It must match>=X <Y.0.0. A v2 host reads a range with no ceiling as<2.0.0and refuses to install it withpack_engine_unsupported.peerDependencieskeys are capability family keys, such as"aiProviders": "required", from the v2 declaration file. An unknown key ispack_peer_dependency_undefined.- Registry paths are resolved, not constructed. The registry is versioned by tree, and a client resolves every path through the registry's
endpointsmap:
curl -s https://packs.openwop.dev/.well-known/openwop-registry.json | jq '.endpoints.v2'
{
"registryIndex": "/v2/index.json",
"packMetadata": "/v2/packs/{name}/index.json",
"versionManifest": "/v2/packs/{name}/-/{version}.json",
"versionTarball": "/v2/packs/{name}/-/{version}.tgz",
"versionSignature": "/v2/packs/{name}/-/{version}.sig",
"versionSbom": "/v2/packs/{name}/-/{version}.sbom.json",
"registrySbom": "/v2/sbom.json"
}
To run a pack you need a host that advertises the packs family and executes pack code in a sandbox. The reference host validates manifests but runs no third-party pack code.
Read: packs for manifests, the registry tree and signing, and node-pack runtimes for the runtime languages.
Check a host with the conformance suite
@openwop/openwop-conformance is a black-box suite you run against a host. Install it together with its contract package at the same version; the suite refuses to start on a mismatch. Use npm 11 or later.
npm install --legacy-peer-deps @openwop/openwop-conformance@2.45.14 @openwop/spec-artifacts@2.45.14
Run the v2 scenarios. Pass --target-major 2: without it the suite follows the host's preferredVersion, which may be an older major.
npx openwop-conformance \
--base-url "$OPENWOP_BASE_URL" \
--api-key "$OPENWOP_API_KEY" \
--target-major 2
--filter <pattern> narrows the run. A filtered run against the reference host ends with a summary like this one (from suite 2.43.0; the counts depend on the suite version and the filter):
Test Files 17 passed | 106 skipped (123)
Tests 27 passed | 472 skipped (499)
[openwop-conformance] RFC 0148 §A dispositions — 44 requirement(s) recorded
executed-pass 35 · blocked 6 · inapplicable 3
A test that passes is not the same as a requirement that was checked. inapplicable means the requirement does not apply to your host; blocked means it was not measured. On a local run the webhook rows are blocked because the host's egress guard, correctly, refuses the suite's loopback receiver. A certification bundle with any blocked row does not certify.
Hosts are measured against profiles, which are derived from the families you advertise rather than declared. openwop-discovery-core needs only the discovery document. openwop-core-standard needs the interrupt, replay, webhooks, idempotency and eventLog families and their 13 floor scenarios, and it is what an unqualified "OpenWOP conformant" claim means. To produce a signed bundle, add --certify bundle.json with --host-build, --signing-key and --signing-key-id.
Read: conformance for bundles and profiles, and the conformance leaderboard for published results.
Use an SDK
The 2.x SDKs speak v2 only. They send OpenWOP-Version: 2.0 on every request.
| Language | Package | Install |
|---|---|---|
| TypeScript | @openwop/openwop 2.x | npm install @openwop/openwop@2 |
| Python | openwop-client 2.x | pip install "openwop-client>=2,<3" |
| Go | github.com/openwop/openwop-sdks/go/v2 | go get github.com/openwop/openwop-sdks/go/v2 |
The same run as above, in TypeScript:
import { OpenwopClient } from '@openwop/openwop';
const client = new OpenwopClient({
baseUrl: 'http://127.0.0.1:3838',
apiKey: 'openwop-v2-dev-key',
});
const caps = await client.discovery.capabilities();
console.log(caps.preferredVersion, caps.protocolVersions);
const { runId } = await client.runs.create({ workflowId: 'conformance-noop' });
for await (const event of client.runs.events(runId)) {
console.log(event.sequence, event.type);
}
1.11 [ '1.11', '2.0' ]
0 run.started
1 node.started
2 node.completed
3 run.completed
The SDK escapes tenant-bound ids for you. For polling, client.runs.pollEvents(runId, { afterSequence }) returns the same { runId, events, lastSequence, status, isTerminal } shape as the raw call. Each SDK's README in openwop/openwop-sdks lists its methods, and sdk/PARITY.md there maps every v2 operation to its method in all three languages.
Use the CLI
The openwop CLI reads the same two environment variables and talks to the v2 run surface:
npm install -g @openwop/cli
openwop runs create conformance-noop --wait --json
openwop runs list --limit 2
runId workflowId status createdAt
------------------------------------------------- ---------------- --------- ---------
openwop-reference-tenant/dZKPp6swc3nVOXx5qs0ZNMfQ conformance-noop completed
openwop-reference-tenant/btonV5DfkWaudv_uCfTjAjky conformance-noop completed
The CLI page covers every command group.