Skip to main content
Ask your AI

walkerOS runtime

runneros runs a prebuilt walkerOS flow artifact. It ships in the npm package @walkeros/runner and is the entrypoint of the walkeros/flow Docker image. It never builds a flow: you build with walkeros bundle, then start the result.

walkeros bundle flow.json -o dist/
runneros start dist/flow.mjs

The runtime has its own name because it is a different tool with a different capability set, not a variant of the CLI. It cannot bundle because the bundler is not installed in it: no esbuild, no package manager, no registry access. Hand it a local flow config and it refuses with BUNDLE must be a prebuilt artifact (.mjs, .js, .cjs, .tar.gz, .tgz), got flow.json.

Build, run, orchestrate​

Getting a flow into production has three stages, and each one is kept to what it needs:

StageHasMust not have
Build (walkeros bundle)Registry access, esbuild, @vercel/nft, a Node toolchainProduction secrets or production traffic
Run (runneros start)The artifact, its runtime secrets, the portA package manager, registry egress, a bundler
Orchestrate (you, your CI, or the walkerOS app)The decision of what runs whereThe ability to execute the artifact

Build values that a web flow bakes into its public bundle are declared in the config under config.bundle.env and resolved at build time, see build-time values. Runtime secrets for a server flow reach the running process through its environment.

The artifact​

walkeros bundle produces two shapes, depending on the flow's platform:

PlatformArtifactRuns where
serverA directory: dist/flow.mjs, dist/package.json and dist/node_modules/ (only the files @vercel/nft traced)runneros start
webA single file: dist/walker.jsThe browser, served by any static host

A server flow always travels as the whole directory. Copying flow.mjs alone drops node_modules/, and a flow with external step packages then fails to resolve its imports. To ship the directory as one file, bundle to an archive with walkeros bundle flow.json -o flow.tar.gz.

Quick start​

Build, then run the directory in the image:

npx @walkeros/cli bundle flow.json -o dist/

docker run --rm -p 8080:8080 \
  -v "$PWD/dist:/app/flow:ro" \
  walkeros/flow:<version>

Or without Docker:

npx --package=@walkeros/runner runneros start dist/flow.mjs

Check it is up:

curl http://localhost:8080/ready

Image tags​

Every walkeros/flow tag is the npm version of the @walkeros/runner it contains: walkeros/flow:<version> runs @walkeros/runner@<version>. Version tags are immutable. latest, <major> and <major>.<minor> move only on stable releases.

Images published before @walkeros/runner existed run an older entrypoint. Pin a version that ships runneros, ideally the same version as the @walkeros/cli that built the artifact, and check it with:

docker run --rm walkeros/flow:<version> runneros --version

The image is published for linux/amd64 only. On an arm64 host, Docker runs it under emulation (--platform linux/amd64).

Canonical Dockerfile​

Build and run in two stages. The build stage has npm and the registry; the runtime stage has neither:

ARG WALKEROS_VERSION

FROM node:24-alpine AS builder
ARG WALKEROS_VERSION
WORKDIR /build
RUN npm init -y && npm install --save-dev @walkeros/cli@${WALKEROS_VERSION}
COPY flow.json ./
RUN npx walkeros bundle flow.json -o dist/

FROM walkeros/flow:${WALKEROS_VERSION}
COPY --from=builder /build/dist/ /app/flow/
docker build --build-arg WALKEROS_VERSION=<version> -t my-flow .
docker run --rm -p 8080:8080 my-flow
  • COPY --from=builder /build/dist/ /app/flow/ copies the whole directory, so the traced node_modules/ comes along.
  • The image already sets BUNDLE=/app/flow/flow.mjs, PORT=8080 and CMD ["runneros", "start"], so the Dockerfile needs no command and no BUNDLE.
  • One WALKEROS_VERSION pins both the CLI that builds and the runtime that runs.

What runneros start accepts​

runneros start [artifact] takes the artifact as an argument, or from BUNDLE when there is none, or flow.mjs in the working directory when there is neither.

InputExampleNotes
Local file/app/flow/flow.mjs (the image default).mjs, .js or .cjs. Imported in place, nothing is written
Local archive/app/flow.tar.gz.tar.gz or .tgz holding flow.mjs at its root. Extracted next to the default write path
URLhttps://cdn.example.com/flow.mjsA presigned URL works. Gzip content is extracted as an archive, anything else is written as flow.mjs
Stdincat dist/flow.mjs | docker run -i ... walkeros/flow:<version>Read only when the given path does not exist. An empty stdin is ignored

A fetched, extracted or piped artifact is written to /app/flow/ when that directory exists (in the image it does), otherwise to /tmp/. A local path with any other extension, a .json flow config above all, is refused before anything loads. Content from a URL or stdin is checked only by what it is written as: a flow config delivered that way is written as flow.mjs and then fails to import with a syntax error. Either way nothing is bundled.

Options​

FlagEnvironment fallbackDescription
[artifact]BUNDLEArtifact path or URL
-p, --port <number>PORTPort to listen on (default 8080)
--flow-id <id>WALKEROS_FLOW_IDFlow ID in the walkerOS app. Enables the heartbeat and secret injection
--project <id>WALKEROS_PROJECT_IDProject ID, required with --flow-id
--env-file <path>noneLoad a dotenv file first. Existing variables win; a group- or other-readable file is refused
--jsonnoneJSON output
-v, --verbosenoneVerbose output
-s, --silentnoneSuppress output

Environment variables​

VariableDefaultDescription
BUNDLE/app/flow/flow.mjs in the imageArtifact path or URL
PORT8080Port for the health server and the flow's HTTP handler
WALKEROS_FLOW_IDnoneFlow ID in the walkerOS app (same as --flow-id)
WALKEROS_PROJECT_IDnoneProject ID (same as --project)
WALKEROS_DEPLOYMENT_IDnoneDeployment ID, sent with every heartbeat and used for telemetry
WALKEROS_DEPLOY_TOKENnoneRunner token. Takes precedence over WALKEROS_TOKEN
WALKEROS_TOKENnoneRunner token (wos_run_...) or automation token (wos_pat_...)
WALKEROS_APP_URLhttps://app.walkeros.ioApp API base URL
WALKEROS_HEARTBEAT_INTERVAL60Seconds between heartbeats. Values below 10, or not a number, are clamped to 10
WALKEROS_CACHE_DIR$XDG_CACHE_HOME/walkeros, else ~/.cache/walkerosWhere the runtime persists recent errors between restarts. CACHE_DIR is read as a fallback
WALKEROS_OBSERVE_LEVELunset (standard)Baseline telemetry level: off, standard or trace. See Observe
WALKEROS_OBSERVER_URL, WALKEROS_INGEST_TOKENnoneObserver connection. With WALKEROS_DEPLOYMENT_ID, all three enable telemetry
NODE_OPTIONS--max-old-space-size=384 in the imageHeap cap sized for a 512 MB container. Raise it with the container's memory

Credentials come from the environment only. The runtime reads no config file and no CLI login, so a token in ~/.walkeros/config.json does not reach it: export WALKEROS_DEPLOY_TOKEN (or WALKEROS_TOKEN) instead.

Connecting to the walkerOS app​

A flow runs on its own with no account. Setting a flow ID, a project ID and a token connects it to the app:

docker run --rm -p 8080:8080 \
  -v "$PWD/dist:/app/flow:ro" \
  -e WALKEROS_TOKEN="wos_run_xxx" \
  -e WALKEROS_PROJECT_ID="proj_xxx" \
  -e WALKEROS_FLOW_ID="flow_xxx" \
  -e WALKEROS_DEPLOYMENT_ID="dep_xxx" \
  walkeros/flow:<version>

The self-hosted tab of a flow in the app prints this command with the values filled in, including a runner token bound to that flow. A flow ID without a token or without a project ID stops the runtime at boot with an error naming the missing value.

When connected, the runtime:

  • Fetches secrets once at boot and injects them into the environment before the flow loads, so $env.NAME references in a server flow resolve to them. From then on every log line masks the fetched values (6 characters or longer) as ***: the runtime's own lines, the flow's lines, the recent errors and log lines a heartbeat carries, and the --json error output.
  • Sends a heartbeat to POST /api/projects/:projectId/runners/heartbeat every WALKEROS_HEARTBEAT_INTERVAL seconds with its instance ID, flow and deployment IDs, version, uptime, event counters, and recent errors and log lines (secrets are redacted before they leave the process). A failed heartbeat is logged and never stops the flow.

The artifact itself never changes while the runtime runs. To ship a new version, bundle again and redeploy.

Values from --env-file, or already set in the container's environment, are not known to the runtime as secrets. The pattern rules still catch them where they have a recognizable shape (known token prefixes, private keys, credential-named fields, long high-entropy strings), but they are not masked by exact value. Prefer app-managed secrets for anything that must never reach a log.

Log lines print a path under the system temp directory as $TMPDIR/.... On Linux TMPDIR is often unset, so read such a path as the system temp directory (usually /tmp).

Secret fetch failures​

ResponseBehavior
401 or 403Fatal: the runtime exits. The token is missing, revoked or not bound to this flow
404 (no secrets configured)Warning, the runtime continues
Any other errorWarning, the runtime continues without secrets

Timeouts, network errors, 429 and 5xx responses are retried a few times with bounded, jittered backoff before the behavior above applies, so a brief blip while the container starts does not fail the boot. The same applies to fetching an artifact from a URL.

Health checks​

The runtime serves two endpoints on PORT, independent of the flow's sources:

EndpointStatusBody
GET /healthAlways 200{"status":"ok"}
GET /ready200 once the flow's collector is constructed{"status":"ready"}
GET /ready503 while starting{"status":"not_ready"}
GET /ready503 when the flow failed to start{"status":"failed","reason":"flow failed to start, see the runtime log"}
GET /ready503 after a sustained loop of uncaught errors{"status":"degraded","reason":"flow degraded, see the runtime log"}

Use /health for liveness and /ready for readiness. The image's HEALTHCHECK polls /ready every 30 seconds after a 30-second start period. Every other request goes to the flow's HTTP handler (for example the Express source's routes); before a flow is loaded, those answer 503.

Serving files​

The runtime has no static file mode. A server flow serves files the same way it handles events: the include field copies a folder next to the artifact, the fs store reads from it, and @walkeros/server-transformer-file answers matching requests:

{
  "version": 4,
  "include": ["./public"],
  "flows": {
    "default": {
      "config": { "platform": "server" },
      "stores": {
        "files": {
          "package": "@walkeros/server-store-fs",
          "config": { "settings": { "basePath": "./public" }, "file": true }
        }
      },
      "sources": {
        "http": {
          "package": "@walkeros/server-source-express",
          "config": {
            "settings": { "paths": ["/collect", "/static/*path"] },
            "ingest": {
              "map": {
                "method": { "key": "method" },
                "path": { "key": "path" }
              }
            }
          },
          "next": [
            {
              "match": { "key": "ingest.method", "operator": "eq", "value": "GET" },
              "next": "file"
            }
          ]
        }
      },
      "transformers": {
        "file": {
          "package": "@walkeros/server-transformer-file",
          "config": { "settings": { "prefix": "/static" } },
          "env": { "store": "$store.files" }
        }
      }
    }
  }
}

The source only fills ingest.method and ingest.path when config.ingest maps them, so without that block the match never passes and the file transformer never runs. paths must cover the served files: the express source registers only /collect by default, and /static/*path is Express 5 wildcard syntax. file: true makes the fs store return the files byte-exact.

walkeros bundle copies public/ into dist/public/, and the canonical Dockerfile's COPY of the whole dist/ directory carries it into the image. Never include the output directory itself (include: ["./dist"] with output dist/ is refused as a circular copy), and never point the fs store at a directory that holds credentials. include applies to server builds only: a web build copies nothing and logs that it ignored include.

File paths at runtime​

Before loading a flow, the runtime changes its working directory to the artifact's directory. Relative paths in flow settings resolve against the artifact, not your project root: with the artifact at /app/flow/flow.mjs, "filePath": "./data.json" reads /app/flow/data.json. Use include to put such files next to the artifact.

Constraints​

  • Filesystem. An archive, URL or stdin input writes to /app/flow/, so those inputs need that directory writable. A read-only root filesystem, the usual ECS and Kubernetes hardening, works only with a local .mjs artifact already in place (for example the canonical Dockerfile, or a read-only mount). A connected runtime persists recent errors to its cache directory (default ~/.cache/walkeros), which also needs to be writable; the image's /app/cache is writable by the runtime user, so on a read-only root filesystem set WALKEROS_CACHE_DIR=/app/cache and mount a volume there. Without a writable cache directory, recent errors are not persisted across restarts, and the flow still runs.
  • Architecture. The image is linux/amd64 only.
  • Signals. The image runs under tini and shuts the flow down gracefully on SIGTERM.
  • In-memory default store. A state operation that names no store uses the collector's in-memory __cache store, which lives inside one process. Anything relying on it across events (state, deduplication, a rate limiter on the default store) is correct only on a single instance. Managed deployments scale up to five instances; point such steps at a store shared by every instance.
  • User. The image runs as walker (UID 1001). node_modules/ in the image is read-only for that user; only /app/flow and /app/cache are writable.

Kubernetes​

apiVersion: apps/v1
kind: Deployment
metadata:
  name: walkeros-flow
spec:
  replicas: 1
  selector:
    matchLabels:
      app: walkeros-flow
  template:
    metadata:
      labels:
        app: walkeros-flow
    spec:
      containers:
        - name: flow
          image: registry.example.com/my-flow:1.0.0 # built from the canonical Dockerfile
          ports:
            - containerPort: 8080
          env:
            - name: WALKEROS_CACHE_DIR
              value: /app/cache
          livenessProbe:
            httpGet: { path: /health, port: 8080 }
            periodSeconds: 30
          readinessProbe:
            httpGet: { path: /ready, port: 8080 }
            periodSeconds: 10
          resources:
            requests: { memory: 256Mi, cpu: 250m }
            limits: { memory: 512Mi, cpu: 500m }

Keep replicas: 1 for a flow that relies on the in-memory default store (see Constraints).

Troubleshooting​

BUNDLE must be a prebuilt artifact: the runtime was given a flow config. Build it first with walkeros bundle and start the output.

Flow artifact not found: /app/flow/flow.mjs: nothing was copied or mounted at the artifact path. Copy the whole dist/ directory to /app/flow/.

Cannot find package at startup: only flow.mjs was copied. A server artifact is the directory, including node_modules/.

--flow-id requires --project or WALKEROS_PROJECT_ID, or Remote flow requires authentication: a flow ID was set without its project ID or token. Set both, or unset the flow ID to run unconnected.

Exits with a 401 or 403 on secrets: the token is invalid, revoked or bound to a different flow. Mint a new one in the app.

Next steps​

  • CLI: build, validate and simulate flows
  • Deploy: the build-then-run path end to end
  • Observe: watch a running flow's records
  • Bundled mode: the flow.json format
💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)