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.
| Mode | Transport | Duraton needs to reach your runner? |
|---|---|---|
connect | Outbound WebSocket your runner dials | No |
serve | Inbound HTTP - Duraton POSTs your /invoke URL | Yes, 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,
});import asyncio
from duraton import Runner
from workflows import workflows
async def main() -> None:
# url / api_key fall back to DURATON_URL / DURATON_API_KEY.
await Runner(workflows, app="shop", runner="agent-node-1").run()
asyncio.run(main())import (
"log"
"duraton.dev/sdk-go/duraton"
)
runner, err := duraton.Connect(duraton.ConnectOptions{
App: "shop",
Runner: "agent-node-1",
Workflows: workflows,
// URL / APIKey fall back to DURATON_URL / DURATON_API_KEY.
})
if err != nil {
log.Fatal(err)
}
defer runner.Close()
runner.Wait()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.
| Field | What it is |
|---|---|
framework | The serve adapter the runner is mounted on (hono, express, next, fastify, bun, elysia, node), or connect for the WebSocket transport. |
runtime | The JS runtime: node, bun, or deno. |
sdkName + version | The SDK package and its version. |
region | The deployment region, from DURATON_REGION when the runner sets it. |
keyFingerprint | A one-way SHA-256 prefix of the runner's DURATON_SIGNING_KEY - never the key itself. |
keyMatch | How 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:
| Mode | What keeps the endpoint fresh |
|---|---|
connect | Duraton's heartbeat on the socket, every 30s. Nothing to configure. |
serve | The 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 instance | Stateless replicas sit behind a load balancer |
| You want a persistent channel with no HTTP request-timeout ceiling | You want no long-lived connection to manage |
Webhooks
Let a third party start a run, and let a finished run tell the outside world - verified inbound POSTs, signed outbound ones, one durable delivery log.
Workspaces & projects
Keep teams and environments apart: a workspace holds members, roles, and one pooled allowance; a project is the isolation boundary for runs, events, and keys.