Concepts

Runners (connect vs serve)

Run your workflow code wherever it already lives: dial out over a WebSocket with connect, or expose an inbound HTTP endpoint with serve.

A runner is a process that hosts an app's workflows and executes its steps. An app can have many runners. A runner reaches Duraton one of two ways; your workflow code is identical either way.

ModeTransportDuraton needs to reach your runner?
connectOutbound WebSocket your runner dialsNo
serveInbound HTTP - Duraton POSTs your /invoke URLYes, at a public URL

Connect mode (outbound WebSocket)

connect is the primary path. Your runner dials Duraton and receives invokes over a persistent WebSocket, so it needs no inbound address and works behind NAT, a firewall, or inside a container with no ingress.

import { connect } from "@duraton/sdk";
import { workflows } from "./workflows";

connect({
  url: process.env.DURATON_URL!,
  apiKey: process.env.DURATON_API_KEY!,
  app: "support",
  runner: "agent-node-1",
  workflows,
});

No HTTP server and no register call: connect handshakes, registers the workflow manifest over the socket, answers invokes, and reconnects if the socket drops. There is no heartbeat to configure - Duraton heartbeats the socket itself and keeps the endpoint fresh from that (see liveness) - and no signing key either, since it authenticates with your API key on the socket upgrade.

Connect uses the runtime's global WebSocket. Bun, Deno, and Cloudflare Workers have it built in; on Node it needs Node 22+ (where WebSocket became a stable global) or a polyfill.

Serve mode (inbound HTTP)

Use serve when the runner is already an HTTP service Duraton can reach - a deployed API, a serverless function. You mount serve() on a route and Duraton POSTs it once per step.

import { serve } from "@duraton/sdk";
import { workflows } from "./workflows";

const invoke = serve({
  workflows,
  url: process.env.DURATON_URL!,
  apiKey: process.env.DURATON_API_KEY!,
  signingKey: process.env.DURATON_SIGNING_KEY!,
  app: "support",
  runnerUrl: "https://runner.example.com/invoke", // where Duraton POSTs; must be publicly reachable
});

Bun.serve({
  port: Number(process.env.PORT ?? 3000),
  fetch: (req) =>
    new URL(req.url).pathname === "/invoke" ? invoke(req) : new Response("ok"),
});

Passing url / app / runnerUrl makes serve() auto-register: it registers on start, retrying until it succeeds, then re-registers on a heartbeat (heartbeatMs, default 30_000) so the endpoint stays live. Omit them and call register() yourself. Serve runs on Node 18+.

Every invoke Duraton POSTs is signed, and serve verifies the signature before parsing the body - that, not the fingerprint below, is what stops a forged invoke. You get the signing key from your project's settings in the console and set it as DURATON_SIGNING_KEY; rotating it is zero-downtime. See invoke signatures and signing key and rotation.

Reported metadata

The SDK sends this handshake metadata on register; Duraton persists it and exposes it on GET /runners. Every field is self-reported by the runner - only what a runner sends is shown, nothing is inferred.

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.
regionThe deployment region, from DURATON_REGION when the runner sets it.
keyFingerprintA one-way SHA-256 prefix of the runner's DURATON_SIGNING_KEY - never the key itself.
keyMatchHow that fingerprint compares to the keys Duraton holds: current, previous (the runner has not picked up a rotation yet) or mismatch. Computed only when both sides are non-empty, so it is often undefined - which means "not compared", not "verified".

keyMatch is a diagnostic for spotting a runner whose signing key has drifted (every invoke to it would fail verification). It is derived from what the runner reported and gates nothing: a mismatch or undefined runner is still registered and still receives invokes.

Liveness

Duraton trusts a runner endpoint only while it keeps checking in, and both transports work the same way: an endpoint Duraton has not seen for 90 seconds ages out of routing, so an anycast run is never sent to a runner that has gone away - a crashed replica, or a stale URL left over from an earlier deploy. Only who does the checking in differs:

ModeWhat keeps the endpoint fresh
connectDuraton's heartbeat on the socket, every 30s. Nothing to configure.
serveThe runner re-registering on its own heartbeat (heartbeatMs, default 30_000).

Either way the endpoint is refreshed about three times per window, so a healthy runner stays live with room to spare.

The console's Apps view shows each runner's last-seen time and a Live or Stale badge; the same two values are on GET /runners as lastSeenAt and live. A connect runner's last-seen advances with every socket heartbeat, so one whose process is gone turns Stale instead of sitting on a Live badge indefinitely. A stale runner is listed, not hidden - so you can see that a replica stopped reporting.

To take a runner out of routing on purpose: a connect runner's close() deregisters it immediately, and a serve runner stops re-registering when you abort the AbortSignal you passed as serve({ ..., signal }), then ages out. See the production guide for the full drain sequence.

An endpoint that is not closed cleanly - either side lost abruptly - stays listed until it ages out, up to the full 90 seconds. An invoke routed to one in that window comes back as a retriable transport error, so Duraton re-dispatches the run to a live runner, or parks it until one appears. The run is not failed, and there is nothing to retry by hand.

Pinning and anycast

Declare a stable runner id and a run can be pinned to that exact instance by setting runner on the event. Omit it on the event and the run is anycast to any of the app's runners.

await duraton.events.send({
  name: "ticket.created",
  app: "support",
  runner: "agent-node-1", // route this run to that one instance; omit for anycast
  data: { ticketId: "T-421" },
});

Addressable {app, runner} routing works over both transports. See the wire protocol reference for the frame shapes.

When no runner is registered

A run whose app has no live, capable runner does not fail on the spot - and POST /events still returns 202, because dispatch is asynchronous. The run parks and retries: the engine re-checks for a runner about once a second and resumes the moment a capable runner (re)registers. The wait is bounded - after 5 minutes with no runner, measured from a durable stamp so a restart cannot reset it, the run fails terminally with a message naming the workflow and app.

This is why the common ordering race self-heals: send an event, then start the runner a moment later, and the queued run simply waits and picks up as soon as the runner is live - you don't have to register before you emit.

The behavior is the same for anycast and pinned runs. A pin to a runner that is briefly absent - a rolling restart of that instance, say - parks and waits for it to come back rather than failing its in-flight runs, up to the same bound. (This is a change from earlier releases, where a pin to a not-currently-registered runner failed fast.)

For deploying runners without stranding in-flight runs, see the production guide.

Which to use

Use connect when...Use serve when...
The runner has no public inbound address (NAT, firewall, a container with no ingress)The runner is already an HTTP service at a reachable URL
You want to pin work to a specific stable instanceStateless replicas sit behind a load balancer
You want a persistent channel with no HTTP request-timeout ceilingYou want no long-lived connection to manage

On this page