connect
Run your workflow code from behind NAT, a laptop, or a container with no ingress - connect dials out over a WebSocket, so there is no inbound URL to expose.
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. It is the default way to run a runner; reach for
serve only when the runner is already a publicly reachable HTTP service.
A connect runner needs no signing key: it authenticates with your API key on the WebSocket
upgrade, so the invoke signing key applies only to serve runners.
import { connect, defineWorkflow } from "@duraton/sdk";
const ticketCreated = defineWorkflow<{ ticketId: string }>({
name: "ticket.created",
handler: async (ctx) => ctx.step.run("refund", () => issueRefund(ctx.event.data.ticketId)),
});
const handle = connect({
url: process.env.DURATON_URL, // your Duraton base URL, shown in the console
apiKey: process.env.DURATON_API_KEY,
app: "support-app",
workflows: [ticketCreated],
});
process.on("SIGTERM", () => handle.close());ConnectOptions
Prop
Type
The middleware option is shared with serve: the same onInvoke /
onResult hooks and the built-in sanitizeErrors() / bindLogContext() helpers work identically
over a connect socket.
ConnectHandle
connect returns synchronously - it does not await the socket - so the handle is available before the
first invoke arrives.
const handle = connect({ app: "support-app", workflows: [ticketCreated] });
handle.close(); // closes the socket and stops reconnectingProp
Type
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.
connect({ app: "agents", runner: "agent-node-1", workflows }); // receives runs pinned to agent-node-1
connect({ app: "support-app", workflows }); // one of N interchangeable replicasPinning 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.
const handle = connect({ app: "support-app", workflows: [ticketCreated] });
// Duraton restarts, the socket drops: the runner redials at 500ms, 1s, 2s, 4s ... capped at 30s.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
pingframe 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
closethat a black-holed socket may never send, then reconnects. So a zombie surfaces within roughlyping + pong timeout(~35s by default), not after the OS TCP timeout minutes later.
These four knobs are configurable per call and via environment variables. They apply to all three
SDKs - the TypeScript options below, the Python Runner(...) keyword arguments (ping_interval_ms,
...), and the Go ConnectOptions fields (PingInterval, ...) - with the same env var names and
defaults.
Prop
Type
connect({
app: "support-app",
workflows: [ticketCreated],
pingIntervalMs: 15_000, // probe more aggressively behind a short-idle proxy
pongTimeoutMs: 5_000,
});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:
// Bun, Deno, Cloudflare Workers, Node 22+: no extra dependency.
connect({ app: "support-app", workflows: [ticketCreated] });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:
npm install ws # only on Node 18-21Streaming AI steps (step.ai.generate({ stream: true })) need the live channel this socket provides;
over an HTTP serve runner the same call falls back to a plain generate. See
AI steps.
Steps
Everything a handler can do durably: the step API - run, sleep, sleepUntil, waitForEvent, runWorkflow, emit, and approval - plus the handler context.
Serving runners
Host workflows in an HTTP service you already deploy: serve, register, the framework adapters, invoke signature verification, and the SDK error types.