# Quickstart

> Source: https://openwop.dev/quickstart/ · OpenWOP, the open-source protocol for multi-agent workflow orchestration.

Run the v2 reference host on your machine, then use plain `curl` to discover what it supports, start a run, stream its events, answer an approval, pause and cancel a run, and fork one.

Every command below was run against the v2 reference host from [`openwop/openwop-examples`](https://github.com/openwop/openwop-examples/tree/main/examples/hosts/v2-reference), and every response shown is real output, trimmed.

## Four rules

Every command below follows these four rules.

- **Paths are unversioned.** A v2 operation lives at `/runs`, not `/v2/runs`. There is no version in the path ([versioning §1.2](https://openwop.dev/spec/v2/core/versioning.html)).
- **Send `OpenWOP-Version: 2` on every request.** A host may prefer an older major, and a request with no header gets that one. Every response carries `OpenWOP-Version` naming the contract that produced it ([versioning §1.3–1.4](https://openwop.dev/spec/v2/core/versioning.html)).
- **Ids are tenant-bound.** A `runId` looks like `<tenantId>/<opaque>`. In a URL path the `/` must be escaped: send `%2F`, or the `~2F` form the host itself emits in links ([identity §5](https://openwop.dev/spec/v2/core/identity.html)).
- **Presence is the claim.** The v2 discovery document has no `supported: false`. A host that supports a feature advertises a record for it; a host that does not, omits it ([capabilities §2](https://openwop.dev/spec/v2/core/capabilities.html)).

## Start a host

You need Node 20 or later, `git`, `curl` and `jq`.

```bash
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.

```bash
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.

```bash
curl -s "$OPENWOP_BASE_URL/.well-known/openwop" -H "OpenWOP-Version: 2" \
  | jq '{protocolVersions, preferredVersion, implementation, lanes: [.auth.lanes[].lane]}'
```

```json
{
  "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:

```bash
curl -s "$OPENWOP_BASE_URL/.well-known/openwop" -H "OpenWOP-Version: 2" \
  | jq -c '[to_entries[] | select(.value | type == "object" and has("status")) | .key]'
```

```json
["limits","eventLog","interrupt","runList","replay","webhooks","idempotency","compensation","feedback","heartbeat","a2a","mcp","toolCatalog","workflowChainPacks","packs","sandbox","workspace","auth","conversationPrimitive","oauth","agents"]
```

Each record carries `status`, `since`, `witness` and the family's own facets. The `replay` record, for example, names the fork modes the host accepts:

```bash
curl -s "$OPENWOP_BASE_URL/.well-known/openwop" -H "OpenWOP-Version: 2" | jq '.replay'
```

```json
{
  "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:

```bash
curl -si "$OPENWOP_BASE_URL/.well-known/openwop" | grep -i '^openwop-version'
```

```text
OpenWOP-Version: 1.11
```

A major the host does not serve is refused with `406`, and the error lists what it does serve:

```bash
curl -s "$OPENWOP_BASE_URL/.well-known/openwop" -H "OpenWOP-Version: 3" | jq .
```

```json
{
  "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](https://openwop.dev/spec/v2/core/capabilities.html) for the record shape and the closed root, and [versioning](https://openwop.dev/spec/v2/core/versioning.html) 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:

```bash
curl -si -X POST "$OPENWOP_BASE_URL/runs" \
  -H "OpenWOP-Version: 2" \
  -H "Content-Type: application/json" \
  -d '{"workflowId":"conformance-noop"}'
```

```text
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](https://openwop.dev/spec/v2/core/identity.html) 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.

```bash
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 .
```

```json
{
  "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`:

```bash
RUN_ID=$(jq -r '.runId | @uri' run.json)
```

Then read the snapshot:

```bash
curl -s "$OPENWOP_BASE_URL/runs/$RUN_ID" \
  -H "Authorization: Bearer $OPENWOP_API_KEY" \
  -H "OpenWOP-Version: 2" | jq .
```

```json
{
  "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:

```text
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](https://openwop.dev/spec/v2/core/runs.html) for the run surface, the snapshot fields, cancel, pause and fork; [idempotency](https://openwop.dev/spec/v2/core/idempotency.html) 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:

```bash
curl -sN "$OPENWOP_BASE_URL/runs/$RUN_ID/events?streamMode=updates" \
  -H "Authorization: Bearer $OPENWOP_API_KEY" \
  -H "OpenWOP-Version: 2"
```

```text
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:

```bash
curl -sN "$OPENWOP_BASE_URL/runs/$RUN_ID/events" \
  -H "Authorization: Bearer $OPENWOP_API_KEY" \
  -H "OpenWOP-Version: 2" \
  -H "Last-Event-ID: 2"
```

```text
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.

```bash
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}]}'
```

```json
{
  "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:

```bash
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 .
```

```json
{
  "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:

```json
{
  "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:

```bash
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
```

```text
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:

```js
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](https://openwop.dev/spec/v2/core/events.html) for the envelope, stream modes, SSE frames and the poll cursor; [webhooks](https://openwop.dev/spec/v2/core/webhooks.html) 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`:

```bash
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})}'
```

```json
{
  "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`:

```bash
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 .
```

```json
{
  "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:

```text
{"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](https://openwop.dev/spec/v2/core/interrupt.html) 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:

```bash
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`:

```bash
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 .
```

```json
{"runId":"openwop-reference-tenant/98w3lsHw-qcPAzKzD5V2UbAO","status":"paused"}
```

Resume it with `:resume`, and cancel it with `/cancel`:

```bash
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 .
```

```json
{"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`:

```json
{"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:

```bash
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 .
```

```json
{
  "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:

```json
{"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:

```bash
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 .
```

```json
{
  "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:

```bash
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 .
```

```json
{"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](https://openwop.dev/spec/v2/core/replay.html) 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:

```bash
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:

```json
{
  "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:

```json
{"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.

```json
"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:

```json
{"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](https://openwop.dev/spec/v2/core/runs.html) 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:

```json
{
  "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.openwop` needs an explicit major ceiling.** It must match `>=X <Y.0.0`. A v2 host reads a range with no ceiling as `<2.0.0` and refuses to install it with `pack_engine_unsupported`.
- **`peerDependencies` keys are capability family keys**, such as `"aiProviders": "required"`, from the v2 declaration file. An unknown key is `pack_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 `endpoints` map:

```bash
curl -s https://packs.openwop.dev/.well-known/openwop-registry.json | jq '.endpoints.v2'
```

```json
{
  "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](https://openwop.dev/spec/v2/core/packs.html) for manifests, the registry tree and signing, and [node-pack runtimes](https://openwop.dev/spec/v2/core/node-pack-runtimes.html) 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.

```bash
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.

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

```text
 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](https://openwop.dev/spec/v2/core/conformance.html) for bundles and profiles, and the [conformance leaderboard](https://openwop.dev/conformance/) 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:

```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);
}
```

```text
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`](https://github.com/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:

```bash
npm install -g @openwop/cli
openwop runs create conformance-noop --wait --json
openwop runs list --limit 2
```

```text
runId                                              workflowId        status     createdAt
-------------------------------------------------  ----------------  ---------  ---------
openwop-reference-tenant/dZKPp6swc3nVOXx5qs0ZNMfQ  conformance-noop  completed
openwop-reference-tenant/btonV5DfkWaudv_uCfTjAjky  conformance-noop  completed
```

The [CLI page](https://openwop.dev/cli/) covers every command group.

## Try the reference application

[`openwop/openwop-app`](https://github.com/openwop/openwop-app) is a full host with a React front end, and it runs at [app.openwop.dev](https://app.openwop.dev/). It advertises families the example host does not, such as `aiProviders`, so it can run model-backed workflows. The [install page](https://openwop.dev/install/) shows how to run it yourself.

## Next steps

- [The v2 specification](https://openwop.dev/spec/v2/) — the core documents, in reading order.
- [Error codes](https://openwop.dev/errors/) — every v2 error code with its HTTP status.
- [Host implementers](https://openwop.dev/for/host-implementers/) — build a host and get it measured.
- [The CLI](https://openwop.dev/cli/) — drive any host from your terminal.
