> ## Documentation Index
> Fetch the complete documentation index at: https://docs.duraton.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Runners

> Run your workflow code wherever it already lives: the runner dials out to Duraton over a WebSocket, so it needs no inbound address.

A **runner** is a process that hosts an app's workflows and executes its steps. An app can have many
runners. Each one reaches Duraton with [`connect`](/reference/sdk/connect).

## Connect (outbound WebSocket)

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.

```ts theme={null}
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](#liveness)). 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.

### Reported metadata

The SDK sends this handshake metadata when it connects; Duraton persists it and exposes it on
[`GET /runners`](/reference/api/runners). Every field is self-reported by the runner - only what a runner
sends is shown, nothing is inferred.

| Field                 | What it is                                                            |
| --------------------- | --------------------------------------------------------------------- |
| `framework`           | `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. |

## Liveness

Duraton trusts a runner endpoint only while it keeps checking in: 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 an endpoint left over from an earlier deploy. Duraton heartbeats the socket every
`30s`, so a healthy runner is refreshed about three times per window. Nothing to configure.

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`](/reference/api/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, call the connect runner's `close()`: it deregisters
immediately. See the [production
guide](/core/production#graceful-shutdown-and-draining) for the full drain sequence.

<Note>
  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](#when-no-runner-is-registered) until one appears. The run is not failed, and there is nothing to
  retry by hand.
</Note>

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

```ts theme={null}
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" },
});
```

See the [protocol
reference](/reference/wire-protocol#routing-app-runner) for how a pin is resolved.

### 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](/core/production).
