> Part of the walkerOS documentation. Project overview and full index: <https://www.walkeros.io/llms.txt>

# walkerOS Runner

The walkerOS runner is the `walkeros/flow` Docker image in **self-bundling mode**. Point it at a flow config (local file or API) and it bundles, runs, and keeps itself up to date.

```
docker run -v ./flow.json:/app/flow.json -e BUNDLE=/app/flow.json -p 8080:8080 walkeros/flow
```

## Runner vs Docker runtime[​](#runner-vs-docker-runtime "Direct link to Runner vs Docker runtime")

|                     | [Runner](https://www.walkeros.io/docs/apps/runner.md) (this page) | [Docker runtime](https://www.walkeros.io/docs/apps/docker.md) |
| ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------- |
| **Input**           | Flow config (JSON)                                                | Pre-built bundle (.mjs)                                       |
| **Bundling**        | Self-bundles internally                                           | None; expects pre-built                                       |
| **API integration** | Heartbeat, config polling, hot-swap                               | None                                                          |
| **Use case**        | Self-hosted production, managed deploy                            | Simple deploy, CI/CD pipelines                                |

Use the **runner** when you want API visibility, remote config, or hot-swap. Use the **Docker runtime** when you pre-build bundles in CI and want minimal images.

## Deployment modes[​](#deployment-modes "Direct link to Deployment modes")

Same image, same config format. Each mode adds one env var:

| Mode                 | What you set                      | What happens                                                     |
| -------------------- | --------------------------------- | ---------------------------------------------------------------- |
| **A. Local**         | `BUNDLE` only                     | Bundles config, runs. No API.                                    |
| **B. Registered**    | + `WALKEROS_TOKEN` + `PROJECT_ID` | Same as A, plus heartbeat. Visible in dashboard.                 |
| **C. Remote config** | + `FLOW_ID`                       | Fetches config from API. Polls for updates. Hot-swaps on change. |
| **D. Managed**       | (set by walkerOS)                 | Same as C, on walkerOS infrastructure.                           |

### Mode A: fully local[​](#mode-a-fully-local "Direct link to Mode A: fully local")

No signup, no token, no API:

```
docker run -v ./flow.json:/app/flow.json \
  -e BUNDLE=/app/flow.json \
  -p 8080:8080 \
  walkeros/flow
```

### Mode B: local config + dashboard[​](#mode-b-local-config--dashboard "Direct link to Mode B: local config + dashboard")

Adds heartbeat registration. The runner appears in your project dashboard:

```
docker run -v ./flow.json:/app/flow.json \
  -e BUNDLE=/app/flow.json \
  -e WALKEROS_TOKEN=sk-walkeros-xxx \
  -e PROJECT_ID=proj_xxx \
  -p 8080:8080 \
  walkeros/flow
```

### Mode C: remote config with hot-swap[​](#mode-c-remote-config-with-hot-swap "Direct link to Mode C: remote config with hot-swap")

Config is fetched from the API. Polls for updates, hot-swaps on new versions:

```
docker run \
  -e WALKEROS_TOKEN=sk-walkeros-xxx \
  -e PROJECT_ID=proj_xxx \
  -e FLOW_ID=flow_xxx \
  -v runner-cache:/app/cache \
  -p 8080:8080 \
  walkeros/flow
```

## Environment variables[​](#environment-variables "Direct link to Environment variables")

| Variable             | Default                   | Description                                                   |
| -------------------- | ------------------------- | ------------------------------------------------------------- |
| `MODE`               | `collect`                 | `collect` (HTTP event server) or `serve` (static file server) |
| `PORT`               | `8080`                    | Server port                                                   |
| `BUNDLE`             | `/app/flow/bundle.mjs`    | Local config/bundle path or URL                               |
| `WALKEROS_TOKEN`     | none                      | API token for registration and config fetch                   |
| `PROJECT_ID`         | none                      | Project ID (required with `WALKEROS_TOKEN`)                   |
| `FLOW_ID`            | none                      | Flow ID for remote config (requires token + project)          |
| `FLOW_NAME`          | none                      | Flow name for multi-flow configs (selects which flow to run)  |
| `CACHE_DIR`          | `/app/cache`              | Directory for last-known-good bundle cache                    |
| `POLL_INTERVAL`      | `30`                      | Seconds between config polls (mode C/D)                       |
| `HEARTBEAT_INTERVAL` | `60`                      | Seconds between heartbeats (mode B/C/D)                       |
| `WALKEROS_APP_URL`   | `https://app.walkeros.io` | API base URL                                                  |

### Validation rules[​](#validation-rules "Direct link to Validation rules")

Invalid combinations fail fast with actionable error messages:

| Vars set                                    | Result                                                                              |
| ------------------------------------------- | ----------------------------------------------------------------------------------- |
| Nothing or `BUNDLE` only                    | Mode A: local bundle, no API                                                        |
| `WALKEROS_TOKEN` + `PROJECT_ID`             | Mode B: local config + heartbeat                                                    |
| `WALKEROS_TOKEN` + `PROJECT_ID` + `FLOW_ID` | Mode C/D: remote config + polling                                                   |
| `FLOW_ID` without token                     | Error: `FLOW_ID requires WALKEROS_TOKEN and PROJECT_ID`                             |
| `WALKEROS_TOKEN` without `PROJECT_ID`       | Error: `WALKEROS_TOKEN requires PROJECT_ID`                                         |
| `BUNDLE` + `FLOW_ID`                        | Starts with local bundle, polls for remote. Hot-swaps when remote version is newer. |

## Pipeline[​](#pipeline "Direct link to Pipeline")

After config is resolved, all modes converge into the same pipeline:

```
1. Validate env ─────────── all modes ──────────

2. Fetch secrets ──────────── if flow_id set ───

3. Resolve config           local | API fetch

                               ↓

                    ┌──────────────────┐

                    │ config (Flow.Json)   │

                    └──────────────────┘

                               ↓

4. Bundle ───────────────── esbuild ────────────

5. Cache ────────────── write last-known-good ──

6. Run ──────────────────── all modes ──────────

7. Heartbeat ────────────── if token set ───────

8. Poll + hot-swap ──────── if flow_id set ─────
```

## Secrets[​](#secrets "Direct link to Secrets")

When `FLOW_ID` is set, the runner fetches secrets from the walkerOS cloud API and injects them into `process.env` before the flow starts. This lets you use `$env.SECRET_NAME` references in your flow config without manually setting environment variables on the container.

Secrets are managed per-flow in the walkerOS dashboard. The runner handles them automatically:

1. **At startup**: secrets are fetched and injected into the environment before config resolution and bundling. The `$env.*` references in your flow config resolve to the fetched values.
2. **On hot-swap**: when a new config version is detected, secrets are re-fetched before re-bundling. This ensures updated secrets take effect alongside the new config.

### Error handling[​](#error-handling "Direct link to Error handling")

The runner treats secret fetch failures differently depending on the cause:

| Error                           | Behavior                                                                                                                    |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **401/403 (auth failure)**      | Fatal: the runner exits. An invalid token means config fetch will also fail.                                                |
| **404 (no secrets configured)** | Warning: the runner continues. The flow may not use `$env.*` references.                                                    |
| **500 (server error)**          | Warning: the runner continues without secrets.                                                                              |
| **Failure during hot-swap**     | The swap is skipped entirely. The runner keeps running the current flow with existing secrets and retries on the next poll. |

Transient fetch failures (request timeouts, network errors, and 5xx responses) are retried a few times with bounded, jittered backoff before the behavior above applies. This covers the secret, config, and bundle fetches at startup, so a brief blip while the container starts does not fail the run. The secret and bundle fetches are also each bounded by a timeout, so an unresponsive API cannot stall startup indefinitely.

note

Secrets only apply in mode C/D (remote config). In local modes, set environment variables directly on the container with `-e` or in your Docker Compose file.

## Polling and hot-swap[​](#polling-and-hot-swap "Direct link to Polling and hot-swap")

When `FLOW_ID` is set, the runner polls for config changes using ETags:

1. Every `POLL_INTERVAL` seconds, checks for a new config version
2. `304 Not Modified`: no action
3. New version detected: re-fetches secrets, downloads config, bundles locally, atomically swaps the running flow, updates cache, reports version via heartbeat

If the new bundle fails to load, the current flow stays active (safe swap). If secret refresh fails during a poll, the entire swap is skipped and the runner retries on the next poll cycle.

## Caching and resilience[​](#caching-and-resilience "Direct link to Caching and resilience")

The runner writes the last working bundle to `CACHE_DIR` (`/app/cache`). This provides cold-start resilience:

* API unreachable at startup: falls back to cached bundle
* Bundling fails: uses cached bundle instead
* Mount as a Docker volume to persist across restarts

```
# Volume mount for cache persistence
docker run \
  -e WALKEROS_TOKEN=sk-walkeros-xxx \
  -e PROJECT_ID=proj_xxx \
  -e FLOW_ID=flow_xxx \
  -v runner-cache:/app/cache \
  walkeros/flow
```

## Health checks[​](#health-checks "Direct link to Health checks")

The runner provides its own health server on the configured `PORT`, independent of flow sources. Health endpoints are always available, even during flow hot-swaps or when no flow handler is loaded.

| Endpoint      | Description                                                                    |
| ------------- | ------------------------------------------------------------------------------ |
| `GET /health` | Liveness check: always returns `200`                                           |
| `GET /ready`  | Readiness check: returns `200` when a flow handler is mounted, `503` otherwise |

```
curl http://localhost:8080/health
```

```
{ "status": "ok" }
```

All other requests are delegated to the flow's HTTP handler (e.g., Express source routes).

The Docker image includes a built-in `HEALTHCHECK` that polls `/health` every 30 seconds with a 30-second start period.

## Observability and runtime tracing[​](#observability-and-runtime-tracing "Direct link to Observability and runtime tracing")

A flow's baseline observability is set by the `flow.<name>.config.observe` block:

| Level                | What the runtime emits                                                                                                                                                                                                       |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `off`                | No baseline records are emitted. In a managed deployment the observer and trace poll stay wired, so runtime trace can still elevate to `trace`; a self-hosted bundle built with no telemetry option makes no fetches at all. |
| `standard` (default) | Structural FlowState records, without inbound or outbound event payloads.                                                                                                                                                    |
| `trace`              | Full payloads on every hop (inbound and outbound), sample rate `1`.                                                                                                                                                          |

When `observe` is omitted, managed deployments default to `standard`.

### Activating trace at runtime (no redeploy)[​](#activating-trace-at-runtime-no-redeploy "Direct link to Activating trace at runtime (no redeploy)")

Trace can be turned on and off for a running deployment without rebuilding or redeploying. The mechanism is a poll, not the heartbeat:

1. The deployment carries a trace window timestamp (`traceUntil`).
2. The observer exposes `GET /trace/:deploymentId` (authenticated with the ingest token), returning `{ "traceUntil": "<ISO timestamp>" | null }`.
3. Both the server runtime (`@walkeros/cli run`) and the deployed browser bundle poll that endpoint about every 15 seconds and feed the returned value into `resolveTelemetryOptions({ traceUntil })` in `@walkeros/core`.

The resolver applies the value per emit:

* A `traceUntil` that parses to a **future** ISO timestamp forces `level: trace` with full inbound and outbound payloads and `sample: 1`, overriding the flow's `observe` block.
* A `null` or **past** value reverts to the flow's `observe` baseline.
* The window self-expires once the timestamp passes, so trace stops on its own with no further action.

Only a `200` with a parseable body changes the active trace state. A network error, a non-200, or an unparseable response leaves the current window untouched, so a transient observer outage never silently disables (or enables) tracing.

### Journey correlation across flows[​](#journey-correlation-across-flows "Direct link to Journey correlation across flows")

Every FlowState record carries correlation fields so a single observer can stitch one request across web and server flows. These fields survive every level, including `standard`; they are never gated behind `trace`.

| Field           | Meaning                                                                                                                                                                                  |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `traceId`       | W3C 32-hex trace id of the originating run. Resolved as `event.source.trace`, falling back to an inbound header's trace, then the run-scoped collector trace. Never synthesized per hop. |
| `eventId`       | W3C span id of the walker event at this hop (`event.id`).                                                                                                                                |
| `parentEventId` | The upstream runtime's `event.id`, present when this flow was entered across a `$flow` crossing (from an inbound `traceparent`).                                                         |
| `sourceId`      | The originating source id.                                                                                                                                                               |
| `seq`           | Per-poster monotonic counter, stamped on transport (see [Loss-visible transport](#loss-visible-transport)).                                                                              |

Correlation crosses the wire via the W3C `traceparent` header:

* **Emit.** The web `api` destination adds `traceparent: 00-<traceId>-<eventId>-01` to each non-batched send when the event carries both ids. A `traceparent` you configure yourself always wins (matched case-insensitively). Batched sends carry no `traceparent`, since one batch may aggregate distinct upstream traces.
* **Adopt.** Any server source with a request header bag (for example an Express or fetch handler) reads a valid inbound `traceparent` and threads its trace and parent span into the ingest context, so the receiving flow's records share the caller's `traceId` and link back via `parentEventId`. An absent or malformed header is ignored. The AWS and GCP handlers do not yet adopt inbound request identity.

### Journey status[​](#journey-status "Direct link to Journey status")

The records of one event assemble into a single journey, identified by that event's id. A `traceId` is run-scoped rather than per event, so it is carried as a shared handle that links every journey of one page load or container run. Each journey carries a status derived from the hops observed so far:

| Status     | Meaning                                                                                                  |
| ---------- | -------------------------------------------------------------------------------------------------------- |
| `pending`  | The journey is still settling: a record arrived recently, or an expected hop has no terminal record yet. |
| `complete` | Every expected hop reached a terminal state (`out`, `error`, or `skip`).                                 |
| `partial`  | The journey has settled, but an expected hop never produced a terminal record.                           |

"Expected" comes from the flow's step topology when one is known (only steps downstream of a processed hop are expected); without a topology, a journey is `complete` once every observed hop is terminal. A separate `lossy` flag is orthogonal to status: a journey is `lossy` when a `seq` gap (see [Loss-visible transport](#loss-visible-transport)) overlaps its window, so a journey can be `complete` and `lossy`, or `partial` and `lossy`, at once.

### Vendor-call capture[​](#vendor-call-capture "Direct link to Vendor-call capture")

At `trace` level a destination can record the outgoing vendor calls it makes, so an observer sees not just the mapped event but the exact call that left for the vendor. A destination opts in by declaring its observable callables as dot-paths (the `gtag` destination declares `call:window.gtag`); the runtime then wraps those callables and records each `{ fn, args, ts }` onto the record's outbound payload, with arguments sanitized to JSON-safe markers.

Capture works whenever the destination reaches its callable through the env. Server and simulate runs carry the callable in the env directly. On live web a destination resolves a real global (for example `window.gtag`) through `@walkeros/web-core`'s `getEnv`; the runtime wraps the declared callables at that resolution point, so calls are recorded without the destination or the page changing. The batched delivery path is not captured: batched sends flush against the unwrapped env, so a batching destination shows its per-event input and output but not its vendor calls. Recorded calls are payload-class: they appear only when the outbound payload does (`trace`, or an explicit `includeOut`).

### Loss-visible transport[​](#loss-visible-transport "Direct link to Loss-visible transport")

The batched poster stamps each record with a monotonic `seq` before sending. A dropped or rejected POST is never re-sequenced, so a gap in `seq` at the observer makes lost telemetry visible instead of silent. The poster also splits a batch by real UTF-8 byte size (`maxBodyBytes`, default 60000) rather than letting an oversized body fail with a 413.

Docker Compose examples

### Mode A: local config[​](#mode-a-local-config "Direct link to Mode A: local config")

```
services:
  runner:
    image: walkeros/flow:latest
    volumes:
      - ./flow.json:/app/flow.json
    environment:
      BUNDLE: /app/flow.json
      PORT: "8080"
    ports:
      - "8080:8080"
```

### Mode C: remote config with cache[​](#mode-c-remote-config-with-cache "Direct link to Mode C: remote config with cache")

```
services:
  runner:
    image: walkeros/flow:latest
    volumes:
      - runner-cache:/app/cache
    environment:
      WALKEROS_TOKEN: ${WALKEROS_TOKEN}
      PROJECT_ID: ${PROJECT_ID}
      FLOW_ID: ${FLOW_ID}
      PORT: "8080"
    ports:
      - "8080:8080"
    restart: unless-stopped

volumes:
  runner-cache:
```

Heartbeat details

When `WALKEROS_TOKEN` and `PROJECT_ID` are set, the runner sends periodic heartbeats:

* **Endpoint**: `POST /api/projects/:projectId/runners/heartbeat`
* **Interval**: Every `HEARTBEAT_INTERVAL` seconds (default 60)
* **Payload**: instance ID, flow ID, config version, CLI version, uptime, mode, event counters (delta since last successful heartbeat), plus recent runtime errors and recent log output (redacted at the runner before sending)
* **Fire-and-forget**: Failures are logged, never crash the runner. Counter deltas accumulate on failure and are included in the next successful heartbeat.

The recent errors and logs let the dashboard surface a deployed flow's runtime errors and log output without any external log tooling. They are captured in bounded in-memory buffers and secrets are redacted before they leave the runner.

Runners appear in the project dashboard after their first heartbeat.

Building a custom image

```
cd packages/cli
npm run docker:build:flow
```

Pin a specific CLI version:

```
docker build --build-arg CLI_VERSION=1.3.0 -t walkeros/flow:1.3.0 -f Dockerfile .
```

## File paths at runtime[​](#file-paths-at-runtime "Direct link to File paths at runtime")

When the runner loads a bundle, it sets the working directory (`process.cwd()`) to the **bundle's directory**. All file paths in your flow config settings resolve relative to the bundle, not your project root.

For example, if your bundle is at `dist/bundle.mjs` and a transformer reads a file:

```
{

  "settings": {

    "filePath": "./data.json"

  }

}
```

At runtime, this resolves to `dist/data.json`, not `./data.json` from the project root. Use the `include` field in your flow config to copy files alongside the bundle so they're accessible at runtime.

## Troubleshooting[​](#troubleshooting "Direct link to Troubleshooting")

**Runner won't start**: Check the error message. Invalid env var combinations produce specific errors like `FLOW_ID requires WALKEROS_TOKEN and PROJECT_ID`.

**Runner exits with "Secrets fetch failed (auth)"**: The API token is invalid or expired. Generate a new token in the walkerOS dashboard. Auth failures (401/403) are fatal because config fetch would also fail.

**"Warning: Could not fetch secrets" but runner continues**: A non-auth error (404, 500) occurred. The runner continues without secrets. If your flow uses `$env.*` references, those values will be missing or fall back to their defaults.

**API fetch fails at startup**: The runner falls back to the cached bundle. If no cache exists, it exits. Mount `CACHE_DIR` as a volume for resilience.

**Hot-swap not working**: Verify `FLOW_ID` is set (polling only runs in mode C/D). Check logs for `Config unchanged` or `Poll error`. Confirm the flow was updated in the walkerOS UI.

**Hot-swap skipped with "Failed to refresh secrets"**: A transient secrets fetch error during polling skips the swap. The runner keeps the current flow and retries on the next poll. Check API connectivity and token validity.

**Bundle fails after config fetch**: The runner continues with the current flow and logs the error. The cached bundle is not overwritten.

## Next steps[​](#next-steps "Direct link to Next steps")

* **[Docker runtime](https://www.walkeros.io/docs/apps/docker.md)**: Pre-built bundle deployment
* **[CLI](https://www.walkeros.io/docs/apps/cli.md)**: Build and test flows locally
* **[Flow configuration](https://www.walkeros.io/docs/getting-started/modes/bundled.md)**: Flow\.Json format reference
