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

# Protocol reference

> How Duraton talks to a runner - the endpoints, the execution model, routing, status codes, and version negotiation.

<Note>
  You don't need this to build workflows - the [SDK](/core/workflows) handles all of it. Read it to
  understand what your runner is doing.
</Note>

This is what happens between **Duraton** and a **runner** (your code plus an SDK), over the Connect
WebSocket.

## How Duraton drives a workflow

Duraton never holds your workflow in memory. It makes progress by sending your runner an
invoke over its socket, once per step, sending along the results of every step that has already
completed. Your handler runs from the top each time: completed steps return their saved result, and
the first unfinished step does real work and reports back. Duraton saves that result and calls
again, until the handler returns.

This is why a runner is stateless and a run survives a Duraton restart: all progress lives in
Duraton's store and is replayed to the runner on each call.

A pass ends in one of two ways. The handler returns, and Duraton marks the run complete; or the
handler reaches work it has not done yet, and Duraton records what it discovered, does or schedules
that work, and invokes again. A step that is already running - a sleep, a wait, a child run - comes
back reported as in progress, and your handler waits on it rather than starting it twice.

Steps are matched across passes by their id, which is why **step ids must be stable and the handler
must be replay-deterministic**. [Steps](/core/steps) covers the rules that follow from that,
including how a reused id inside a loop is disambiguated. `hashStepId` in the SDK
([reference](/reference/sdk/steps)) computes the key a given step id is stored under, if you ever
need to correlate one yourself.

## Endpoints

| Method + path                                         | On      | Purpose                                                                                                                                                                                                                                                             |
| ----------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /events`                                        | Duraton | Ingest an event: resume any `waitForEvent` waiters and fan out to every workflow whose triggers match. Request and response are documented at [Events](/reference/api/events).                                                                                      |
| `GET /runs`, `GET /runs/{id}`, `GET /runs/{id}/steps` | Duraton | Inspect [runs and steps](/reference/api/runs).                                                                                                                                                                                                                      |
| `GET /workflows`                                      | Duraton | List [registered workflow definitions](/reference/api/workflows).                                                                                                                                                                                                   |
| `GET /runners`                                        | Duraton | List [registered runners](/reference/api/runners). Filter `?app=`.                                                                                                                                                                                                  |
| `GET /events`, `GET /events/{id}`                     | Duraton | The [event log](/reference/api/events): each ingested event with what it triggered or woke.                                                                                                                                                                         |
| `GET /events/stream`                                  | Duraton | Live tail of ingested events as Server-Sent Events.                                                                                                                                                                                                                 |
| `GET /connect`                                        | Duraton | WebSocket upgrade for the Connect transport. The runner dials this, registers its app and workflows (the shape [`GET /workflows`](/reference/api/workflows) reads back), and receives invokes over the socket. Each pass answers `200` (done) or `206` (more work). |

## Logs

A pass carries back the structured lines your handler emitted via `ctx.log`, on every status
(`200`, `206` and `500` - lines written before a throw still ship), so a log is never lost to the
path a pass took.

Because the handler body re-runs on every pass, a handler-level log would re-emit each time. Duraton
deduplicates it so it persists once, and keys an in-step log on that step's attempt, so a retried
step's logs stay distinct per attempt. Read them back via
[`GET /runs/{id}/logs`](/reference/api/runs#run-logs).

## Routing (`{app, runner?}`)

A run is owned by an app and executed by one of that app's registered runners (an app may have many).
The `runner` id is the routing handle:

* **Anycast** (no `runner`): each invoke goes to any one registered runner of the app. Runners are
  stateless and the full step memo is resent every invoke, so different passes may safely hit
  different replicas.
* **Pinned** (`runner` set on the event): routed only to that runner id, invoke after invoke.

Routing is the only thing the pin changes - the no-runner behaviour is identical for both. If no
capable runner is registered when a run needs one (none at all for an anycast run, or that specific
id for a pinned run), the run **parks and retries** rather than failing on the first miss, so the
event-before-register race and a pinned runner's rolling restart both self-heal. The wait is bounded
at **5 minutes**; if it elapses with still no capable runner, the run **fails terminally** with a
reason naming the missing workflow (and the runner id, for a pin). An orphaned run - its app scaled
to zero, or its workflow served by no runner - therefore still reaches a terminal state instead of
parking forever.

A runner that omits an id is keyed by its connection. A child workflow inherits its parent's
pin only in the same app. `ctx.runner` carries the pin to your handler (empty for anycast).

## Connect transport (WebSocket)

Your runner dials Duraton over a WebSocket (`GET /connect`) and receives invokes on that socket, so it
needs **no inbound address** - it works for an agent on a node behind NAT. In the SDK this is
`connect({ url, app, runner?, workflows })`.

The stable `runner` id is part of the handshake, so a **connected runner is pinnable** by
`{app, runner}`. A dropped socket is detected by heartbeat and the runner is evicted from routing until
it reconnects; the SDK reconnects automatically. See [`connect()`](/reference/sdk/connect).

## Status codes

| Code  | Meaning                                                                                                                                   |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `200` | The handler returned. The run is complete.                                                                                                |
| `206` | The pass discovered more work. Duraton records it and invokes again.                                                                      |
| `4xx` | The runner rejected the request: an unknown workflow, an incompatible protocol version. Not retried as a step.                            |
| `5xx` | The runner failed to answer. Duraton retries the invoke on the transport budget, counted separately from the workflow's own step retries. |

A response body is capped at **1 MiB**; a larger one fails the pass rather than truncating silently.

When a run fails terminally and its workflow registered `onFailure: true`, Duraton marks the run
`failed` and invokes the onFailure handler as a separate follow-on run. The failed run is retained:
queryable via `GET /runs?status=failed` and redrivable via `POST /runs/{id}/replay`. See
[Retries](/core/retries).

## Protocol version

Duraton and runner share a single integer wire version (currently `1`). Each side advertises it and
checks the peer's in the Connect handshake, so a breaking wire change fails loudly instead of
misparsing.

The rule is **lenient on absence, strict on a present mismatch**: a peer that sends no version is
assumed compatible, so the field is additive and never breaks an older peer, but a version that is
present and differs is rejected: the socket closes. The SDK sets
this for you - you only encounter it if a runner and Duraton are on incompatible releases.
