Concepts

Runners (serve vs connect)

How your code connects to the engine - inbound HTTP serve mode, or an outbound WebSocket for runners behind NAT.

A runner is a process that hosts an app's workflows and executes its steps. An app can have many runners. A runner reaches the engine in one of two ways - the choice is invisible to your workflow code, which is identical either way.

Serve mode (inbound HTTP)

You stand up an HTTP server; the engine POSTs your /invoke URL once per step.

import { serve } from "@durablex/sdk";

const invoke = serve({
  workflows,
  engineUrl: process.env.DURABLEX_ENGINE_URL!,
  app: "demo",
  url: "http://localhost:6773/invoke",
});
Bun.serve({ port: 6773, fetch: (req) => new URL(req.url).pathname === "/invoke" ? invoke(req) : new Response("ok") });

Passing engineUrl/app/url makes serve() auto-register: it retries until the engine is up, then re-registers on a heartbeat (default 30s) so the endpoint stays live. Omit them and call register() yourself if you'd rather drive it explicitly. Simple and stateless - but the engine must be able to reach your runner's URL.

Liveness

A serve endpoint is trusted only while it keeps checking in: the engine ages out one that stops heartbeating (a crashed replica, or a stale URL left by an earlier run) so anycast never routes to a dead runner, and a live runner's heartbeat also evicts those stale siblings. Connect runners need no heartbeat for this - the engine drops their endpoint the moment the socket closes. Pass an AbortSignal as serve({ ..., signal }) to stop the heartbeat on shutdown.

The console's Apps view lists each app's connected runners with the metadata each one reports on register, marking a serve endpoint stale once it stops heartbeating (a connect runner stays live until its socket closes). The run inspector also shows the Runner that executed a run, so you can trace a run back to the instance that ran it.

Reported metadata

The SDK sends this handshake metadata automatically; the engine persists it and exposes it on GET /runners. Every field is best-effort - only what a runner reports is shown, nothing is mocked.

FieldWhat it is
frameworkThe serve adapter the runner is mounted on (hono, express, next, fastify, bun, elysia, node), or connect for the WebSocket transport.
runtimeThe JS runtime: node, bun, or deno.
sdkName + versionThe SDK package and its version (forward-looking for future non-TS SDKs).
regionThe deployment region, from DURABLEX_REGION if set.
keyFingerprintA one-way SHA-256 prefix of the runner's invoke signing key - never the key. The engine compares it to its own and reports keyMatches, so a runner whose DURABLEX_SIGNING_KEY differs from the engine's (every invoke would 401) is visible at a glance.

Connect mode (outbound WebSocket)

Your runner dials the engine and receives invokes over a persistent WebSocket. It needs no inbound address, which is the model for an agent running on a node behind NAT or a firewall.

import { connect } from "@durablex/sdk";

connect({ engineUrl: process.env.DURABLEX_ENGINE_URL!, app: "demo", runner: "agent-node-1", workflows });

No HTTP server, no register call - connect handles the handshake, registers the workflow manifest over the socket, answers invokes, and reconnects automatically if the socket drops.

Connect mode uses the runtime's global WebSocket. Bun, Deno, and Cloudflare Workers have it built in; on Node it requires Node 22+ (where WebSocket became a stable global) or a polyfill. Serve mode has no such requirement - it runs on Node 18+.

Which to use

Use serve when...Use connect when...
The runner is a normal service with a reachable URLThe runner is behind NAT/firewall (an agent on a node)
Stateless replicas behind a load balancerYou want to pin work to a specific stable instance
Simplest possible setupYou want a persistent channel (no inbound exposure, no HTTP-timeout limits)

Pinning works with both

Declare a stable runner id and a run can be pinned to that exact instance by triggering with the same id. Set runner on the event to route the run to that one runner:

await fetch(`${engineUrl}/events`, {
  method: "POST",
  body: JSON.stringify({ name: "order.created", app: "demo", runner: "agent-node-1", data: { orderId: "A1" } }),
});

Omit the runner on the event and the run is anycast to any of the app's runners. Pinning to a runner that isn't currently registered fails the run fast (no bounded wait in v0). This addressable {app, runner} routing - over either transport - is Durablex's headline difference from Inngest, which is anycast-only. See the wire protocol reference for the details.

On this page