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

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

A connect runner authenticates with your API key on the WebSocket upgrade.

```ts theme={null}
import { connect, workflow } from "@duraton/sdk";

const ticketCreated = workflow<{ ticketId: string }>({
  name: "ticket.created",
  handler: async (ctx) => ctx.step.run("refund", () => issueRefund(ctx.event.data.ticketId)),
});

const handle = connect({
  apiKey: process.env.DURATON_API_KEY,
  app: "support-app",
  workflows: [ticketCreated],
});

process.on("SIGTERM", () => handle.close());
```

## ConnectOptions

<ResponseField name="workflows" type="AnyWorkflowDefinition[]" required>
  The workflows this runner serves. Their manifest is registered over the socket on every (re)connection.
</ResponseField>

<ResponseField name="url" type="string" default="DURATON_URL, then https://run.duraton.dev">
  Your Duraton base URL; the WebSocket URL is derived from it.
</ResponseField>

<ResponseField name="app" type="string" default="DURATON_APP, then &#x22;default&#x22;">
  The app this runner belongs to; its workflow names are registered under that app.
</ResponseField>

<ResponseField name="runner" type="string">
  A stable runner id. Declare it to receive pinned runs; omit it to join the app's anycast pool.
</ResponseField>

<ResponseField name="apiKey" type="string" default="DURATON_API_KEY">
  The project API key, sent as a Bearer token on the WebSocket upgrade.
</ResponseField>

<ResponseField name="resolveProvider" type="ProviderResolver" default="the built-in registry (getProvider)">
  Swaps how a step.ai call turns a provider name into an adapter. See AI steps - Providers.
</ResponseField>

<ResponseField name="resolveCost" type="CostSource" default="unset; cost stays absent">
  Prices each step.ai call from its metering axes, so maxCost caps bite. See AI steps - Cost.
</ResponseField>

<ResponseField name="cache" type="AICache" default="createMemoryCache() - a process-local LRU">
  The store backing opt-in inference caching. Inert until a call passes cache. See AI steps - Inference cache.
</ResponseField>

<ResponseField name="middleware" type="Middleware" default="unset; no hooks run">
  Hooks that run before and after your handler: onInvoke enriches per-run log context, onResult transforms the result or error before it leaves the process.
</ResponseField>

The SDK ships two ready-made middlewares:

| Helper               | Hook       | What it does                                                                                                             |
| -------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ |
| `sanitizeErrors()`   | `onResult` | Replaces a thrown error with one that keeps the message but drops the stack, so internal frames never leave the process. |
| `bindLogContext(fn)` | `onInvoke` | Attaches `fn(info)` to every log line the run emits. A per-call field on `ctx.log` wins over a binding of the same key.  |

## ConnectHandle

`connect` returns synchronously - it does not await the socket - so the handle is available before the
first invoke arrives.

```ts theme={null}
const handle = connect({ app: "support-app", workflows: [ticketCreated] });
handle.close(); // closes the socket and stops reconnecting
```

<ResponseField name="close" type="() => void" required>
  Closes the socket and stops reconnecting. A closed handle never redials.
</ResponseField>

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

```ts theme={null}
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](/core/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.

```ts theme={null}
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](/core/retries), 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, with the env var names and
defaults below.

<ResponseField name="pingIntervalMs" type="number" default="DURATON_CONNECT_PING_MS, then 25000">
  App-level ping cadence, capped at the server heartbeat. A half-open socket surfaces within pingIntervalMs + pongTimeoutMs.
</ResponseField>

<ResponseField name="pongTimeoutMs" type="number" default="DURATON_CONNECT_PONG_TIMEOUT_MS, then 10000">
  How long a ping waits for any inbound frame before the socket is treated as dead.
</ResponseField>

<ResponseField name="reconnectInitialMs" type="number" default="DURATON_CONNECT_RECONNECT_INITIAL_MS, then 500">
  The initial backoff before the first redial after a drop.
</ResponseField>

<ResponseField name="reconnectMaxMs" type="number" default="DURATON_CONNECT_RECONNECT_MAX_MS, then 30000">
  The ceiling the doubling backoff is capped at.
</ResponseField>

```ts theme={null}
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](/core/runners#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:

```ts theme={null}
// 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`](https://www.npmjs.com/package/ws) package, imported lazily so it stays out of the graph
everywhere else - install it and `connect` uses it automatically:

```sh theme={null}
npm install ws   # only on Node 18-21
```

<Note>
  Streaming AI steps (`step.ai.generate({ stream: true })`) use the live channel this socket provides.
  See [AI steps](/reference/sdk/ai-steps).
</Note>
