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 reconnecting

Prop

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 replicas

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.

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 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. 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-21

Streaming 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.

On this page