# Bridge Protocol (/docs/sdks/bridge-protocol)



The bridge is **plain, signed HTTP** — not Connect RPC, not gRPC. Ductor POSTs to
the single endpoint your serve adapter mounts, dispatching on an `action` query
parameter, and your handler replies with JSON. The wire shapes are frozen by the
cross-SDK contract tests, so this page is the authoritative description of what
crosses the wire in either direction.

## Actions [#actions]

The handler routes on the `action` query parameter
(`sdks/go/ductor/handler.go`). There are five:

| Action         | Method | What it does                                                                              |
| -------------- | ------ | ----------------------------------------------------------------------------------------- |
| `discover`     | GET    | Return the deployment's workflows and capabilities. Consumed by the sync endpoint.        |
| `execute`      | POST   | Advance one run: replay history, run the next step, stream back opcodes. &#x2A;*Signed.** |
| `preview`      | POST   | Run a workflow with empty history to show what a fresh run would emit.                    |
| `code`         | GET    | Return a stub source descriptor for a workflow (SDKs can't reflect over your source).     |
| `health-check` | GET    | Liveness — returns `{ok, sdk, version}`.                                                  |

`execute` is the hot path. It requires `workflow_id`, `step_id`, and `run_id`
query parameters and a signed JSON body (`ExecuteRequestBody`) carrying the
trigger `event`, `subscriber_data`, the recorded `step_history`, and any
`controls`. The handler looks up the workflow, verifies the signature (if a
signing key is configured), runs the body against the supplied history, and
returns the resulting opcode stream. A terminal stream returns HTTP `200`; a
continuation returns `206 Partial Content`.

```mermaid
sequenceDiagram
  participant S as Ductor server
  participant B as Your bridge
  S->>B: POST execute, signed
  B->>B: Replay history, run next step
  B-->>S: 206 opcodes, continuation
  Note over S: record step, schedule wait or child
  S->>B: POST execute, signed
  B-->>S: 200 run_complete, terminal
```

`discover` advertises the deployment's capabilities, which the server keys off to
decide how it may drive the run:

```json
{
  "parallel_opcodes": true,
  "signed_requests": true
}
```

`signed_requests` is set whenever a signing key is configured; `parallel_opcodes`
declares that the SDK can interleave parallel branches via `step.planned`.

## Opcodes [#opcodes]

`execute` returns a stream of **generator opcodes**. Each is a JSON envelope
(`GeneratorOpcode` in `sdks/go/ductor/opcode.go`) with an `op` discriminator, a
deterministic `id`, a `name`, and op-specific `opts`/`data`/`error` fields. The
`op` strings mirror `domain/bridge/opcode.go` on the server and are locked by
contract test:

| Opcode            | Meaning                                                                           |
| ----------------- | --------------------------------------------------------------------------------- |
| `step`            | A generic step, not yet classified as run or errored.                             |
| `step.run`        | A step completed successfully; `data` carries its output.                         |
| `step.error`      | A retriable step failure.                                                         |
| `step.failed`     | A terminal step failure. &#x2A;*Terminal.**                                       |
| `step.planned`    | A step planned but deferred to a later invocation — parallel branch interleaving. |
| `sleep`           | Pause for a duration or until an absolute time.                                   |
| `wait_for_event`  | Park until a matching event arrives.                                              |
| `wait_for_signal` | Park until a named signal is delivered.                                           |
| `invoke_function` | Start a child workflow run.                                                       |
| `run_complete`    | The workflow finished. &#x2A;*Terminal.**                                         |

A stream is terminal when it contains `run_complete` (clean finish) or
`step.failed` (unrecoverable failure) — `Opcode.IsTerminal()` encodes exactly
that. Everything else is a continuation: the engine records the yielded step,
schedules the wait or child, and calls `execute` again when there is more to do.

<Callout type="info" title="The envelope is a locked contract">
  `GeneratorOpcode` carries `op`, `id`, `name`, and optional `display_name`,
  `opts`, `data`, `error`, and `userland`. Its JSON shape is asserted
  byte-for-byte against the server's envelope and the other SDKs. Do not
  hand-roll opcodes — emit them through the step primitives so the shape stays
  correct.
</Callout>

## HMAC signing [#hmac-signing]

Every signed request carries an HMAC-SHA-256 signature so the bridge can trust
that a call really came from Ductor (and your handler can trust the reverse). The
scheme is Ductor decision **D-01-HMAC** and is implemented identically in all
three SDKs and in the server's `application/bridge.Sign` (`sdks/go/ductor/auth.go`).

* **Header:** `Ductor-Signature` — note there is **no `X-` prefix**, per RFC 6648.
* **Format:** `t=<unix>,v1=<base64rawurl>`.
* **Signing input:** the string `<unix>.<body>` — the Unix timestamp, a literal
  dot, then the raw request body.
* **Signature:** base64 raw-url encoding (no padding) of
  `HMAC_SHA256(secret, "<unix>.<body>")`.
* **Tolerance:*&#x2A; the timestamp must be within **±5 minutes** of now, or the
  request is rejected as expired.

```text
signing input:  1721563200.{"event":{"order_id":"A-42"},...}
                └── t ────┘ └────────────── raw body ──────────────┘

header value:   Ductor-Signature: t=1721563200,v1=Base64RawURL(HMAC-SHA256(...))
```

An **empty signing key disables verification** — the handler skips the check
entirely. That is a development-only convenience; a production deployment must
configure a key. Because the construction is byte-compatible across every SDK and
the server, a signature produced by the TypeScript signer verifies under the Go
verifier and vice versa.

<Callout type="warn" title="The header format is strict and load-bearing">
  `t=<unix>,v1=<sig>` must stay byte-identical across SDKs — the round-trip
  contract tests fail on any drift. Do not reformat, reorder, or rename the
  fields, and do not add an `X-` prefix. If you re-implement the signer, match
  `auth.go` exactly.
</Callout>

## Server endpoints [#server-endpoints]

The three primitives above are how the engine talks *to* your bridge. The
following endpoints are how your deployment (and the engine's own bridge worker)
talk *to* the server. All require a valid inbound HMAC signature — the handlers
refuse to construct without a verifier.

### POST /v1/bridge/sync [#post-v1bridgesync]

Registers or updates the workflows an SDK deployment exposes. The server fetches
your `discover` output from `bridge_url` and reconciles it against what it already
has (`transport/bridge/handler.go`).

```json title="request body"
{
  "tenant_id": "acme",
  "environment": "prod",
  "bridge_url": "https://my-app.example/api/ductor",
  "signing_key_ref": "bridge/acme/prod",
  "published_by": "alex@acme.example"
}
```

```json title="response"
{
  "created": 2,
  "updated": 1,
  "unchanged": 5,
  "archived": 0,
  "errors": [],
  "completed_at": "2026-07-21T12:00:00Z"
}
```

### POST `/v1/bridge/checkpoint/{runID}/steps` [#post-v1bridgecheckpointrunidsteps]

Accepts a durable batch of completed steps for a run, so an SDK can
checkpoint work without a round-trip per step
(`transport/bridge/checkpoint_handler.go`). The batch body carries `tenant_id`
and a `steps` array; the response reports how many landed:

```json title="response"
{
  "checkpoint_id": "01J...ULID",
  "accepted": 3,
  "duplicates": 0
}
```

Body is capped at **4 MiB**. Replays are idempotent: steps dedupe on
`(run_id, op_id)` at the database layer, so a re-sent batch returns `200` with
`duplicates` equal to the number of steps it re-sent and `accepted` of zero.

### POST /v1/inline-run [#post-v1inline-run]

Starts a run whose steps the SDK has already computed inline, so a
short workflow can complete in a single request rather than a series of `execute`
callbacks (`transport/bridge/inline_run_handler.go`). The body carries `run_id`,
`tenant_id`, `idempotency_key`, `definition_id`, the `event`, and an
`inline_steps` array.

```json title="response"
{
  "run_id": "01J...",
  "status": "succeeded",
  "poll_url": ""
}
```

Body is capped at **2 MiB**. If the run does not finish within the synchronous
window the handler returns `202 Accepted` with `status: "running"` and a
`poll_url` of `/v1/runs/{runID}/status`. Parallel-mode payloads are rejected —
inline-run is for linear step sequences.

## Gotchas [#gotchas]

<Callout type="warn" title="The Go Connect adapter uses plain HTTP">
  Despite the name, `serve.Connect` is **not** a Connect-RPC bridge — the SDK
  protocol is plain HTTP, and the adapter simply forwards to the `net/http`
  handler under a Connect-conventional path prefix. See
  [Languages](/docs/sdks/languages).
</Callout>

## Related [#related]

<Cards>
  <Card title="Languages" href="/docs/sdks/languages">
    Per-language serve adapters and their real status, including the pass-through
    caveats above.
  </Card>

  <Card title="Workflow primitives" href="/docs/sdks/workflow-primitives">
    The step helpers that produce these opcodes and how replay memoizes them.
  </Card>
</Cards>
