Bridge Protocol
The signed HTTP protocol between Ductor and your SDK — the five actions, the opcode wire format, HMAC signing, and the sync, checkpoint, and inline-run server endpoints.
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
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. 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.
discover advertises the deployment's capabilities, which the server keys off to
decide how it may drive the run:
{
"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
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. 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. 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.
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.
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 noX-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: the timestamp must be within ±5 minutes of now, or the request is rejected as expired.
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.
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.
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
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).
{
"tenant_id": "acme",
"environment": "prod",
"bridge_url": "https://my-app.example/api/ductor",
"signing_key_ref": "bridge/acme/prod",
"published_by": "[email protected]"
}{
"created": 2,
"updated": 1,
"unchanged": 5,
"archived": 0,
"errors": [],
"completed_at": "2026-07-21T12:00:00Z"
}POST /v1/bridge/checkpoint/{runID}/steps
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:
{
"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
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.
{
"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
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.