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/flowRunner vs Docker runtime
| Runner (this page) | Docker runtime | |
|---|---|---|
| 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
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
No signup, no token, no API:
docker run -v ./flow.json:/app/flow.json \
-e BUNDLE=/app/flow.json \
-p 8080:8080 \
walkeros/flowMode 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/flowMode 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/flowEnvironment 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
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
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
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:
- 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. - 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
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.
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
When FLOW_ID is set, the runner polls for config changes using ETags:
- Every
POLL_INTERVALseconds, checks for a new config version 304 Not Modified: no action- 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
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/flowHealth 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
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)
Trace can be turned on and off for a running deployment without rebuilding or redeploying. The mechanism is a poll, not the heartbeat:
- The deployment carries a trace window timestamp (
traceUntil). - The observer exposes
GET /trace/:deploymentId(authenticated with the ingest token), returning{ "traceUntil": "<ISO timestamp>" | null }. - Both the server runtime (
@walkeros/cli run) and the deployed browser bundle poll that endpoint about every 15 seconds and feed the returned value intoresolveTelemetryOptions({ traceUntil })in@walkeros/core.
The resolver applies the value per emit:
- A
traceUntilthat parses to a future ISO timestamp forceslevel: tracewith full inbound and outbound payloads andsample: 1, overriding the flow'sobserveblock. - A
nullor past value reverts to the flow'sobservebaseline. - 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
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). |
Correlation crosses the wire via the W3C traceparent header:
- Emit. The web
apidestination addstraceparent: 00-<traceId>-<eventId>-01to each non-batched send when the event carries both ids. Atraceparentyou configure yourself always wins (matched case-insensitively). Batched sends carry notraceparent, 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
traceparentand threads its trace and parent span into the ingest context, so the receiving flow's records share the caller'straceIdand link back viaparentEventId. An absent or malformed header is ignored. The AWS and GCP handlers do not yet adopt inbound request identity.
Journey status
Records sharing a traceId assemble into a single journey, and 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) overlaps its window, so a journey can be complete and lossy, or partial and lossy, at once.
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
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
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
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_INTERVALseconds (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:flowPin 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
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
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
- Docker runtime: Pre-built bundle deployment
- CLI: Build and test flows locally
- Flow configuration: Flow.Json format reference