Wire protocol
Build or port an SDK against the exact HTTP contract between Duraton and a runner - messages, step id hashing, routing, the connect transport, and signing.
You don't need this to build workflows - the SDK handles all of it. This reference is for people building or porting an SDK, or integrating with Duraton at the protocol level.
This is the contract between Duraton and a runner (your code plus an SDK), over HTTP or the Connect WebSocket. It covers every step operation a handler can reach: running a step, sleeping, waiting for an event, invoking a child workflow, emitting an event, sending a webhook, parking on an approval, offloading a model call, and recording a score.
How Duraton drives a workflow
Duraton never holds your workflow in memory. It makes progress by calling your runner's
/invoke endpoint, once per step, sending along the results of every step that has already
completed. Your handler runs from the top each time: completed steps return their saved result, and
the first unfinished step does real work and reports back. Duraton saves that result and calls
again, until the handler returns.
This is why a runner is stateless and a run survives a Duraton restart: all progress lives in Duraton's store and is replayed to the runner on each call.
Endpoints
| Method + path | On | Purpose |
|---|---|---|
POST /register | Duraton | Runner announces its app, optional stable runner id, workflows, and invoke URL. |
POST /events | Duraton | Ingest an event: resume any waitForEvent waiters and fan out to every workflow whose triggers match (optionally pinned to a runner). Returns 202 {runId?, woke, triggered}. |
GET /runs, GET /runs/{id}, GET /runs/{id}/steps | Duraton | Inspect runs and steps. |
GET /workflows | Duraton | List registered workflow definitions (name, app, retry policy, scheduled + schedules[] for cron workflows, and the advisory steps manifest when declared). |
GET /runners | Duraton | List registered runners (id, app, url, runtime/version, last-seen, live). Filter ?app=. Backs the console's connected-runners view. |
GET /events, GET /events/{id} | Duraton | The event log: each ingested event with what it triggered/woke. Filter ?app=&name=&limit=, newest first. |
GET /events/stream | Duraton | Live tail of ingested events as Server-Sent Events (text/event-stream). |
POST /invoke | runner | Duraton drives one pass. Returns 200 (done) or 206 (more work). |
GET /connect | Duraton | WebSocket upgrade for the Connect transport. A runner with no inbound URL dials this, registers, and receives invokes over the socket. |
The runner's invoke URL is whatever it advertises in /register - unless it connects over the Connect
transport (see below), in which case it has no inbound URL at all.
Messages
POST /register (runner → Duraton)
{
"app": "support-app",
"runner": "node-7",
"url": "https://orders.example.com/api/duraton/invoke",
"runtime": "bun",
"language": "typescript",
"version": "0.1.0",
"protocolVersion": 1,
"workflows": [
{
"name": "ticket.created",
"retry": { "maxAttempts": 3 },
"concurrency": { "limit": 5, "key": "accountId" }
}
]
}| Field | Type | Description |
|---|---|---|
app | string | App the runner serves. |
runner | string (optional) | Stable runner id; omitted → Duraton keys the endpoint by url. |
url | string | The runner's /invoke endpoint. |
runtime / language / version | string (optional) | Handshake metadata describing the runner (e.g. bun / typescript / the SDK version), surfaced in the console's connected-runners view. The SDK sends them automatically. |
protocolVersion | number (optional) | The wire version the runner speaks (see Protocol version). Omitted → assumed compatible. |
workflows | object[] | Each entry has a name plus optional triggers, retry, onFailure (true if the workflow has an onFailure handler), flow-control fields (see Flow Control), and an optional steps manifest. |
workflows[].triggers | object[] (optional) | What starts the workflow: event triggers { event, if? } (event may end in a * wildcard; if is a CEL filter) and cron triggers { cron }. Omitted → an implicit event trigger on the workflow name. See Triggers. |
workflows[].steps | object[] (optional) | Advisory step manifest in declaration ticket: each entry is { name, description?, hidden? }. Rendering metadata only - it never constrains execution and is diffed against the actually-executed steps (discovery wins). See Defining workflows. |
POST /events (caller → Duraton)
{ "name": "ticket.created", "app": "support-app", "runner": "node-7", "dedupeId": "evt-A1", "data": { "ticketId": "T-421" } }| Field | Type | Description |
|---|---|---|
name | string | Event name; matched against every workflow's event triggers (exact or * wildcard) and resumes awaiting waitForEvent steps. |
app | string | Target app. |
runner | string (optional) | Present → pin the run to that runner; absent → anycast. |
dedupeId | string (optional) | A repeat of the same id (per app) within 24h is dropped entirely - no waiters woken, no fan-out, no new log row - so an at-least-once caller can safely retry. The response is 202 { "deduped": true }. |
data | JSON | Event payload. Any valid JSON (object, array, or scalar); may be omitted. |
name is required (non-blank) and length-bounded, as are app, runner, targetApp, and dedupeId;
data, when present, must be valid JSON. A request that violates any of these is rejected with 400.
The 202 response reports what the event did:
{ "runId": "01H...", "woke": 0, "skipped": false, "dropped": false, "debounced": false, "batched": false, "deduped": false }| Field | Type | Description |
|---|---|---|
runId | string | Set if the event triggered a run; mirrors the first entry of triggered, for the common single-match case. Read triggered when an event fans out to several workflows. |
woke | number | waitForEvent runs resumed (broadcast). |
skipped | boolean | A singleton skip policy dropped the trigger. |
dropped | boolean | A rateLimit policy shed the trigger. |
debounced | boolean | Coalesced into a debounce buffer. |
batched | boolean | Buffered into a batch. |
deduped | boolean | The event was a duplicate dedupeId, or a workflow's idempotency key was already seen in its window. |
triggered | object[] | One entry per workflow the event matched (event triggers can fan out); each has workflow, runId?, and the gate booleans. |
POST /invoke (Duraton → runner)
{
"event": { "name": "ticket.created", "data": { "ticketId": "T-421" } },
"steps": { "9f2b8c...": { "data": { "refundId": "re_A1" } } },
"ctx": { "runId": "01H...", "workflow": "fulfillment", "attempt": 1, "app": "support-app", "runner": "node-7" }
}| Field | Type | Description |
|---|---|---|
event | object | The triggering event (name, data); name is informational and may differ from the workflow. |
steps | object | Memo map: hashed step id → its saved state. |
ctx.runId | string | The run being replayed. |
ctx.workflow | string | The dispatch key: the registered workflow name the runner routes to (distinct from event.name). |
ctx.attempt | number | Run-level attempt counter. |
ctx.app | string | The run's app. |
ctx.runner | string | The run's pin; "" for an anycast run. |
ctx.onFailure | boolean (optional) | Set on an onFailure invocation; the runner dispatches to the workflow's onFailure handler. |
ctx.error | StepError (optional) | The terminal error, present only when ctx.onFailure is set. |
ctx.traceparent | string (optional) | W3C trace context of Duraton's invoke span; the runner extracts it to nest its spans in the run's distributed trace. Rides the body (not a header) so it propagates the same over HTTP and the Connect WebSocket. |
Each steps entry carries one of:
| Field | Type | Description |
|---|---|---|
data | JSON | A completed step's result. |
error | StepError | A completed step that threw. |
pending | boolean | Step already started (a parked sleep / wait / child); the runner blocks on it without re-running or re-emitting. |
The runner replies 200 { "data": <result>, "logs": [LogLine, ...] } when the run completes, or
206 { "opcodes": [Opcode, ...], "logs": [LogLine, ...] } listing the steps discovered this pass. The
logs array carries any ctx.log lines captured during the pass (see Logs); it is [] when
none were emitted.
Opcode
{ "op": "StepRun", "id": "9f2b8c...", "name": "triage", "data": { "refundId": "re_A1" } }| Field | Type | Used by | Description |
|---|---|---|---|
op | enum | all | StepRun | Sleep | SleepUntil | WaitForEvent | RunWorkflow | Emit | Webhook | Approval | Infer | Score. An opcode Duraton does not recognize fails the step (a runner newer than Duraton), rather than re-invoking forever. |
id | string | all | Hashed step id; Duraton stores the result under this key. |
name | string | all | Human-readable step id (for the console). |
data | JSON | StepRun, Emit, Webhook, Approval, Infer, Score | Step result / event payload / webhook body / proposed tool args / model request / the score. |
input | JSON (optional) | StepRun | The step's declared input (the step.run(id, input, fn) form). The structural opcodes carry their input in their own fields. |
error | StepError | StepRun | Step failure. |
retriable | boolean (optional) | StepRun | false fails the run now, skipping remaining attempts (NonRetriableError). |
retryAfterMs | number (optional) | StepRun | Overrides the policy backoff for this retry (RetryAfterError). |
sleepMs | number | Sleep | Duration in ms. |
sleepUntilMs | number | SleepUntil | Absolute wake time (UTC epoch ms). |
eventName | string | WaitForEvent, Emit | Awaited / emitted event name. |
timeoutMs | number | WaitForEvent, Approval | Wait timeout in ms; on an Approval it is the escalation deadline, not an auto-decision. |
eventIf | string (optional) | WaitForEvent | CEL predicate on the event payload; the run resumes only on an event whose name matches and whose payload satisfies it. Same dialect as trigger filters. |
dedupeId | string (optional) | Emit | Drops a repeat of the same emitted event (per app) within the dedupe window, the same idempotency key POST /events accepts. |
targetApp | string (optional) | Emit | Narrow the emit to one app's triggers; omitted, it broadcasts project-wide. |
childName | string | RunWorkflow | Child workflow to invoke. |
childData | JSON | RunWorkflow | Input passed to the child. |
childApp / childRunner | string (optional) | RunWorkflow | Address the child at a specific app (and runner in it). Omitted childApp resolves the bare name same-app-preferred, with a cross-app fallback. |
webhookUrl | string | Webhook | Destination URL for a ctx.webhook.send; Duraton enqueues a durable outbound delivery to it carrying data (a custom send has no endpoint secret, so it is delivered unsigned). |
tool | string | Approval | The proposed action awaiting sign-off; its arguments ride data. |
risk / policy / summary / context / escalatesTo | string (optional) | Approval | Annotations for the decider, read back by the approvals API. |
ai | JSON (optional) | StepRun, Infer | The journal block a step.ai.* call reports alongside its output. Duraton stores it opaquely - it never parses or recomputes it. |
usage | Usage (optional) | StepRun, Infer | The metering axes of a model call. This, not the ai journal, is what Duraton meters. |
StepError is { "message": string, "stack"?: string }.
Usage is { "model"?: string, "tokensIn"?: number, "tokensOut"?: number, "cost"?: number, "latencyMs"?: number, "cacheHit"?: boolean }.
Every field is optional and a missing one means not reported, not zero: Duraton holds no price list, so
cost is present only when the runner priced the call, and cacheHit only when the call engaged the
inference cache.
Two opcodes are not side effects the runner performs. Infer is an offloaded model call: the runner
emits the request in data and is then free - Duraton makes the call and completes the step. Score
carries a { name, value, ... } score in data: Duraton writes the score row and memoizes the step, so
a replay never double-writes it.
Logs
A pass's response also carries the structured logs the handler emitted via ctx.log. The same array
shape rides every status (200, 206, and 500 - logs up to a throw still ship), so a log is never
lost to the path a pass took:
{ "level": "info", "message": "triaging ticket", "fields": { "priority": "high" }, "scope": "triage", "index": 0, "tsMs": 1718900000000 }| Field | Type | Description |
|---|---|---|
level | enum | debug | info | warn | error. |
message | string | The log message. |
fields | object (optional) | Structured fields. Sensitive keys are redacted by Duraton before storage. |
scope | string | The enclosing step name, or @root for a handler-level log. |
index | number | A per-scope counter Duraton uses (with scope + attempt) to give each line a replay-stable dedupe id. |
tsMs | number | Runner wall clock (advisory). |
Because the handler body re-runs on every pass, a top-level (@root) log re-emits each pass; the
Duraton deduplicates it by (runId, attempt, scope, index) so it persists once. An in-step log only
runs on the pass where its step executes, and its dedupe is keyed on the step's attempt, so a retried
step's logs stay distinct per attempt. Read them back via
GET /runs/{id}/logs.
Sleep.sleepMs is a duration, not an absolute time. The runner has no clock authority; it says
"sleep 10s" and Duraton resolves the wake time when it persists the sleep, which keeps the
directive idempotent across passes. SleepUntil.sleepUntilMs is the absolute counterpart (UTC epoch
ms) for step.sleepUntil: a fixed wall-clock target Duraton stores verbatim as the deadline and
parks against its own clock until it arrives. A target already in the past wakes on the next cycle.
A Webhook opcode (ctx.webhook.send) is a durable side effect, not a step result: Duraton
enqueues a signed outbound delivery to webhookUrl carrying data, then records the step. Like Emit,
it is at-least-once on the wire but made exactly-once by the deterministic step id, so a replayed pass
never re-sends. See the webhooks guide.
A single pass can return several opcodes: a handler that runs steps with Promise.all discovers the
whole batch at once, and the 206 array carries them all (sequential steps are just the one-opcode
case). Duraton persists every opcode, parks at the earliest deadline across the batch, and
re-invokes as each branch is ready; the handler proceeds once all are memoized. A terminal failure in
one branch fails the run and cancels its in-flight siblings. A step Duraton has already started but
not finished comes back as "pending": true in the memo - the runner must not re-run or re-emit it,
so a parked branch's timer survives re-invokes its siblings trigger.
Step id hashing
hashedStepId = lowercase_hex( SHA-256( utf8(stepId) ) )The runner hashes the human-readable step id (e.g. "triage") to produce the steps map key and the
Opcode.id; Duraton treats it as an opaque key. Implementations in different languages must
produce byte-identical output. A step id reused within one run is disambiguated runner-side before
hashing by a positional suffix ("x", then "x:1", "x:2", ...), so each occurrence gets a distinct
key; see the full spec for the exact scheme.
Routing ({app, runner?})
A run is owned by an app and executed by one of that app's registered runners (an app may have many).
The runner id is the routing handle:
- Anycast (no
runner): each invoke goes to any one registered runner of the app (random pick). Runners are stateless and the full step memo is resent every invoke, so different passes may safely hit different replicas. - Pinned (
runnerset on the event): routed only to that runner id, invoke after invoke.
Routing is the only thing the pin changes - the no-runner behaviour is identical for both. If no capable runner is registered when a run needs one (none at all for an anycast run, or that specific id for a pinned run), the run parks and retries rather than failing on the first miss, so the event-before-register race and a pinned runner's rolling restart both self-heal. The wait is bounded at 5 minutes, measured from a durable stamp so a restart cannot reset the clock; if it elapses with still no capable runner, the run fails terminally with a reason naming the missing workflow (and the runner id, for a pin). An orphaned run - its app scaled to zero, or its workflow served by no runner - therefore still reaches a terminal state instead of parking forever.
A runner that omits an id in /register is keyed by its URL. A child workflow inherits its parent's
pin only in the same app. ctx.runner carries the pin to your handler (empty for anycast).
Connect transport (WebSocket)
Two transports drive a runner, and the choice is invisible to your workflow code:
- Serve (default): you stand up an HTTP server and Duraton POSTs your
/invokeURL. Simple, but the runner must be inbound-reachable. - Connect: your runner dials Duraton over a WebSocket (
GET /connect) and receives invokes on that socket, so it needs no inbound address - the model for an agent on a node behind NAT. In the SDK this isconnect({ url, app, runner?, workflows })instead ofserve(...)+register(...).
The execution model is identical (same step-memoization replay); only the connection direction differs.
Connect uses duraton's own protocol (subprotocol duraton.connect.v0, a generic
{type, id, payload} envelope, message types hello / runner.register / invoke / invoke.result),
and the stable runner id is part of the handshake, so a connected runner is pinnable by
{app, runner} exactly like an HTTP one. The hello and runner.register frames also carry the wire
version (see Protocol version). A dropped socket is detected by heartbeat and the
runner is evicted from routing until it reconnects (the SDK reconnects automatically).
Status codes
| Code | Meaning |
|---|---|
200 | Handler returned. Body { data, logs } carries the final result. The run succeeds. |
206 | Handler emitted new opcodes and isn't finished. Body is { opcodes, logs }. |
4xx | Non-retriable error (bad request, unknown workflow). Duraton fails the run. |
5xx | Retriable transport error. Duraton retries the invoke with backoff on a fixed transient budget, then fails the run. |
A step failure is different from a transport error: the runner reports it as a 206 whose opcode
carries error, and Duraton retries that step per the workflow's retry policy. Per-step retries
and the transient transport budget are counted separately.
Duraton caps the invoke response at 1 MiB (the same wire-message limit the connect transport applies to a result frame), so a runaway runner can't exhaust its memory with an unbounded result; a response over the cap fails the invoke. Large step state is offloaded by API, not carried inline.
When a run fails terminally and its workflow registered onFailure: true, Duraton marks the run
failed and spawns a separate follow-on run invoked with ctx.onFailure + ctx.error to run the
onFailure handler. The failed run is retained: queryable via GET /runs?status=failed
and redrivable via POST /runs/{id}/replay. See Retries.
Protocol version
Duraton and runner share a single integer wire version (currently 1; v1 wrapped the invoke
response in an object on every status so logs ride alongside the result - v0 returned a bare
{data} on 200 and a bare opcode array on 206). Each side advertises it and checks the peer's at
every boundary, so a breaking wire change fails loudly instead of misparsing:
| Boundary | Carried as |
|---|---|
| HTTP invoke (Duraton → runner) | the X-Duraton-Protocol header |
| HTTP register (runner → Duraton) | RegisterRequest.protocolVersion |
| Connect handshake | protocolVersion on the hello and runner.register frames |
The rule is lenient on absence, strict on a present mismatch: a peer that sends no version is
assumed compatible, so the field is additive and never breaks an older peer, but a version that is
present and differs is rejected (400 on the HTTP paths; the socket closes on Connect). The SDK sets
this for you - you only encounter it if a runner and Duraton are on incompatible releases.
Signing
Duraton signs every invoke it sends your runner with HMAC-SHA256, in the X-Duraton-Signature
header:
X-Duraton-Signature: t=1750000000&s=9f2b8c...| Part | Meaning |
|---|---|
t | The Unix timestamp (seconds) the request was signed at. |
s | Lowercase hex HMAC-SHA256(secret, "<t>.<rawBody>") - the timestamp, a literal ., then the exact request bytes. |
To verify: recompute s over the raw body you received (before JSON parsing, byte for byte) with your
runner's signing key, compare in constant time, and reject a t more than 5 minutes from now -
that skew window is what stops a captured request from being replayed indefinitely.
The runner's key is signingKey on serve() / connect(), or the DURATON_SIGNING_KEY environment
variable. serve() verifies each invoke for you and requires the key: it throws on startup when
none is set, since the signature check is the only thing standing between the invoke URL and a forged
request. Set one on every runner you expose.
Each runner reports a keyFingerprint (a one-way SHA-256 prefix, never the key) on register, so
GET /runners can flag a runner whose key does not match Duraton's - see
keyMatch.