Skip to main content
You don’t need this to build workflows - the SDK handles all of it. Read it to understand what your runner is doing.
This is what happens between Duraton and a runner (your code plus an SDK), over the Connect WebSocket.

How Duraton drives a workflow

Duraton never holds your workflow in memory. It makes progress by sending your runner an invoke over its socket, 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. A pass ends in one of two ways. The handler returns, and Duraton marks the run complete; or the handler reaches work it has not done yet, and Duraton records what it discovered, does or schedules that work, and invokes again. A step that is already running - a sleep, a wait, a child run - comes back reported as in progress, and your handler waits on it rather than starting it twice. Steps are matched across passes by their id, which is why step ids must be stable and the handler must be replay-deterministic. Steps covers the rules that follow from that, including how a reused id inside a loop is disambiguated. hashStepId in the SDK (reference) computes the key a given step id is stored under, if you ever need to correlate one yourself.

Endpoints

Logs

A pass carries back the structured lines your handler emitted via ctx.log, on every status (200, 206 and 500 - lines written before a throw still ship), so a log is never lost to the path a pass took. Because the handler body re-runs on every pass, a handler-level log would re-emit each time. Duraton deduplicates it so it persists once, and keys an in-step log on that step’s attempt, so a retried step’s logs stay distinct per attempt. Read them back via GET /runs/{id}/logs.

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. Runners are stateless and the full step memo is resent every invoke, so different passes may safely hit different replicas.
  • Pinned (runner set 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; 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 is keyed by its connection. 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)

Your runner dials Duraton over a WebSocket (GET /connect) and receives invokes on that socket, so it needs no inbound address - it works for an agent on a node behind NAT. In the SDK this is connect({ url, app, runner?, workflows }). The stable runner id is part of the handshake, so a connected runner is pinnable by {app, runner}. A dropped socket is detected by heartbeat and the runner is evicted from routing until it reconnects; the SDK reconnects automatically. See connect().

Status codes

A response body is capped at 1 MiB; a larger one fails the pass rather than truncating silently. When a run fails terminally and its workflow registered onFailure: true, Duraton marks the run failed and invokes the onFailure handler as a separate follow-on run. 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). Each side advertises it and checks the peer’s in the Connect handshake, so a breaking wire change fails loudly instead of misparsing. 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: the socket closes. The SDK sets this for you - you only encounter it if a runner and Duraton are on incompatible releases.