Workflow SDKs

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:

ActionMethodWhat it does
discoverGETReturn the deployment's workflows and capabilities. Consumed by the sync endpoint.
executePOSTAdvance one run: replay history, run the next step, stream back opcodes. Signed.
previewPOSTRun a workflow with empty history to show what a fresh run would emit.
codeGETReturn a stub source descriptor for a workflow (SDKs can't reflect over your source).
health-checkGETLiveness — 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.

POST execute, signed Replay history, run next step 206 opcodes, continuation POST execute, signed 200 run_complete, terminal record step, schedule wait or child Ductor server Your bridge

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:

OpcodeMeaning
stepA generic step, not yet classified as run or errored.
step.runA step completed successfully; data carries its output.
step.errorA retriable step failure.
step.failedA terminal step failure. Terminal.
step.plannedA step planned but deferred to a later invocation — parallel branch interleaving.
sleepPause for a duration or until an absolute time.
wait_for_eventPark until a matching event arrives.
wait_for_signalPark until a named signal is delivered.
invoke_functionStart a child workflow run.
run_completeThe 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 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: 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).

request body
{
  "tenant_id": "acme",
  "environment": "prod",
  "bridge_url": "https://my-app.example/api/ductor",
  "signing_key_ref": "bridge/acme/prod",
  "published_by": "[email protected]"
}
response
{
  "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:

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

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.

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

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.