Skip to main content
connect dials Duraton over a WebSocket and receives invokes on that socket, so the runner needs no inbound URL, no framework, and no separate registration - it works from behind NAT, from a laptop, or from a container with no ingress. A connect runner authenticates with your API key on the WebSocket upgrade.

ConnectOptions

AnyWorkflowDefinition[]
required
The workflows this runner serves. Their manifest is registered over the socket on every (re)connection.
string
default:"DURATON_URL, then https://run.duraton.dev"
Your Duraton base URL; the WebSocket URL is derived from it.
string
default:"DURATON_APP, then \"default\""
The app this runner belongs to; its workflow names are registered under that app.
string
A stable runner id. Declare it to receive pinned runs; omit it to join the app’s anycast pool.
string
default:"DURATON_API_KEY"
The project API key, sent as a Bearer token on the WebSocket upgrade.
ProviderResolver
default:"the built-in registry (getProvider)"
Swaps how a step.ai call turns a provider name into an adapter. See AI steps - Providers.
CostSource
default:"unset; cost stays absent"
Prices each step.ai call from its metering axes, so maxCost caps bite. See AI steps - Cost.
AICache
default:"createMemoryCache() - a process-local LRU"
The store backing opt-in inference caching. Inert until a call passes cache. See AI steps - Inference cache.
Middleware
default:"unset; no hooks run"
Hooks that run before and after your handler: onInvoke enriches per-run log context, onResult transforms the result or error before it leaves the process.
The SDK ships two ready-made middlewares:

ConnectHandle

connect returns synchronously - it does not await the socket - so the handle is available before the first invoke arrives.
() => void
required
Closes the socket and stops reconnecting. A closed handle never redials.

Pinned and anycast runners

Declaring a runner id makes the runner addressable: runs pinned to that id are delivered to it, and ctx.step.runWorkflow({ runner }) can target it. Omitting runner puts the process in the app’s anycast pool, where any replica may take any run - the right shape for stateless replicas you scale horizontally.
Pinning is what lets a run land back on the machine that holds the state it needs (a local model, a mounted volume, an open session). See runners.

Reconnection

The socket redials on close with exponential backoff, starting at 500ms and doubling to a ceiling of 30s; a successful open resets the backoff to 500ms. Each reconnection re-sends the workflow manifest, so a runner that comes back is immediately eligible for runs again - you do not re-register.
An invoke that was in flight when the socket dropped is not answered on that socket; its run retries under the workflow’s retry policy, landing on whichever runner is connected then.

Liveness

A dropped socket that fires close is easy - the reconnect above handles it. The harder failure is a half-open (zombie) socket: the TCP connection is silently dead (a proxy evicted an idle connection, a NAT mapping expired, the network black-holed), yet readyState stays OPEN and no close ever fires. Left alone, the runner looks connected while invokes go nowhere. The runner defeats this with an application-level heartbeat:
  • It sends a ping frame on an interval, capped at the server’s advertised heartbeat so the cadence never drifts slower than the interval Duraton expects to hear from you on.
  • Any inbound frame - a pong, an invoke, a result - clears a pong watchdog, since any frame proves the socket still delivers server to client.
  • If the watchdog fires (no frame arrived within the pong timeout of a ping), the runner tears the socket down immediately rather than waiting on a close that a black-holed socket may never send, then reconnects. So a zombie surfaces within roughly ping + pong timeout (~35s by default), not after the OS TCP timeout minutes later.
These four knobs are configurable per call and via environment variables, with the env var names and defaults below.
number
default:"DURATON_CONNECT_PING_MS, then 25000"
App-level ping cadence, capped at the server heartbeat. A half-open socket surfaces within pingIntervalMs + pongTimeoutMs.
number
default:"DURATON_CONNECT_PONG_TIMEOUT_MS, then 10000"
How long a ping waits for any inbound frame before the socket is treated as dead.
number
default:"DURATON_CONNECT_RECONNECT_INITIAL_MS, then 500"
The initial backoff before the first redial after a drop.
number
default:"DURATON_CONNECT_RECONNECT_MAX_MS, then 30000"
The ceiling the doubling backoff is capped at.
Duraton heartbeats the socket from its side too, and refreshes the runner’s endpoint each time. That is what keeps the runner’s last-seen time and its Live badge current in the console, and what turns it Stale if the process goes away without closing cleanly - see runner liveness.

Runtime

connect uses the runtime’s global WebSocket when one exists. Bun, Deno, Cloudflare Workers, and Node 22+ ship it, so on those there is no extra dependency:
On Node 18-21 there is no global WebSocket. The SDK detects this and falls back to the optional ws package, imported lazily so it stays out of the graph everywhere else - install it and connect uses it automatically:
Streaming AI steps (step.ai.generate({ stream: true })) use the live channel this socket provides. See AI steps.