Octov0.4.2
Runtime

Running Flows Locally

octo run in depth: hot reload, concurrency tuning, and invocation.

The octo CLI runs a config of flows on your machine — the same engine the platform deploys to a cluster, backed by the standalone services module (in-process queues, in-memory KV, no external infrastructure). This page covers octo run, octo invoke, and octo eval in depth, plus the per-flow concurrency knobs.

For installing the CLI, see Installation.

octo run

run starts every connector and flow in a config and processes messages until you interrupt it. It is the default subcommand, so octo --config x.yaml works too.

octo run --config my-flow.yaml          # a single config file
octo run --config ./integrations        # a directory: loads every .yaml/.yml in it
octo run --config my-flow.yaml --watch  # reload on change
FlagMeaning
--config <path>Path to the runtime config — a file or a directory (required).
--watchReload the config whenever it changes.

Flags accept one or two dashes (--config or -config).

When the config path is a directory, the runtime loads every .yaml/.yml file in it as one combined config. The config directory also becomes the resource root: declared resources (env files, templates) resolve relative to it.

Once every connector and flow has started, the CLI prints a ready banner. Stop the runtime with Ctrl-C (SIGINT) or SIGTERM; shutdown is graceful and unwinds in strict reverse order — sources stop first, channels close, in-flight messages drain, then connectors release their resources.

Run it from Docker

The runtime also ships as a public image — juancavallotti/octo-runtime on Docker Hub, multi-arch (linux/amd64 and linux/arm64), published on every release. It carries the octo binary and nothing else: no editor, no orchestrator, no cluster. Its default command is octo run --config /etc/octo/integrations, so mounting a config directory there is all it takes:

# Every .yaml/.yml in the mounted directory is loaded as one config.
docker run -p 8080:8080 -v "$PWD:/etc/octo/integrations" juancavallotti/octo-runtime

The image runs the same standalone services module as a local binary (in-process queues, in-memory KV), so anything that runs under octo run on your machine runs here unchanged. A few practicalities:

  • Ports — nothing is published for you. Map whatever port your http connector binds (8080 unless you set port/HTTP_PORT).
  • Environment — pass env: values with -e, or mount a .env file into the config directory; both resolve exactly as they do locally. LOG_LEVEL works the same way.
  • Resources — the mounted config directory is the resource root, so env files and templates declared as resources resolve relative to it.
  • Read-only mounts are fine — the runtime only reads its config. Use :ro if you want to enforce that.
  • It runs as nonroot on a distroless base: no shell in the image, so docker exec gets you nothing to run.

Override the command to use the other subcommands — for example, a one-shot invoke against a mounted config:

docker run --rm -v "$PWD:/etc/octo/integrations" juancavallotti/octo-runtime \
  invoke --config /etc/octo/integrations --flow enrich-order --data '{"id": 42}'

This is the same Dockerfile the cluster's octo-runtime image is built from, but the public image is the default build (standalone services), while the cluster image is built with -tags k8s for leader election, the orchestrator KV, and NATS queues. See Docker images.

Log level

The runtime logs through Go's slog to stderr. Set the LOG_LEVEL environment variable to debug, info, warn, or error (case-insensitive; default info). An invalid value logs a warning and falls back to info.

LOG_LEVEL=debug octo run --config my-flow.yaml

Hot reload with --watch

With --watch, the CLI watches the config path and rebuilds the runtime when it changes:

  • File changes are debounced (200 ms), so a burst of editor writes coalesces into one reload. Atomic-rename saves — editors that replace the file rather than writing in place — are detected too.
  • Watching a directory also notices added and removed config files.
  • The .env files consulted during load, and any declared env or template resources, feed the same reload channel — editing them restarts the runtime just like a config change.
  • A config that fails to load, build, or start is not fatal: the CLI logs the error and waits for the next change, so fixing the file recovers without restarting the process.
  • Runtime services (connections, leases) are built once and reused across reloads; each generation only borrows them, so they are not churned on every save.

octo invoke

invoke loads a config, calls one flow by name with a request body, prints the result message as JSON, and exits. Sources are not started — no HTTP server binds, no cron fires — so it is safe to invoke against a config whose sources would otherwise conflict with a running instance.

octo invoke --config my-flow.yaml --flow enrich-order --data '{"id": 42}'
FlagMeaningDefault
--config <path>Path to the runtime config (file or directory).required
--flow <name>Name of the flow to invoke.required
--data <json>JSON request body.reads stdin when omitted
--timeout <dur>Max time to wait for the flow.30s

When --data is omitted and stdin is piped (not an interactive terminal), the body is read from stdin — it must be valid JSON:

cat order.json | octo invoke --config my-flow.yaml --flow enrich-order

The flow's final message prints to stdout as JSON — {event_id, variables, body}, the variables it built up along with the payload, so pipe it through jq '.body' when the body is all you want. A flow that drops the message prints nothing (the drop is logged); a flow that fails exits non-zero with the error.

Any flow is invokable by name, but flows declared without a source: are made for it: they get an implicit source and exist purely to be called — by invoke, or by other flows via flow-ref.

octo eval

eval compiles and evaluates one CEL expression against an ad-hoc message — no config, no runtime services. Use it to test an expression before pasting it into a flow.

octo eval --expr 'body.total * 1.21' --data '{"total": 100}'
# {"ok":true,"result":121}

octo eval --expr '"hi " + vars.name' --vars '{"name": "Ada"}'
# {"ok":true,"result":"hi Ada"}
FlagMeaning
--expr <cel>The CEL expression (required).
--data <json>JSON object bound to body (reads stdin when omitted).
--vars <json>JSON object bound to vars.
--env <json>JSON object bound to env.

The result is a JSON envelope: {"ok": true, "result": ...} on success, {"ok": false, "result": null, "error": "..."} on a compile or evaluation failure. Both cases exit 0 — a bad expression is a normal result, not a CLI failure — so scripts can parse the envelope instead of inspecting exit codes. result is always emitted, so a legitimate false/0/null result is not confused with its absence.

Because no config is loaded, templateResource() is unavailable in eval; expressions over body, vars, env, eventID, correlationID, and now all work.

Two more subcommands round out the CLI: octo schema prints the editor capability schema as JSON (--out <path> writes it to a file), and octo version prints the version and build date. See the CLI reference.

Concurrency tuning

A root flow declares three knobs, all optional:

flows:
  - name: ingest-orders
    workers: 8      # per-flow worker pool size (default 8; set 1 for FIFO)
    buffer: 128     # source -> worker channel depth (default 64)
    pool: 16        # shared pool for concurrent composites (default 8)
    source: { ... }
    process: [ ... ]
  • workers — the number of goroutines running this flow. All workers read the same channel the source emits on; each takes a message and runs it through the whole block chain. With the default of 8, messages may complete out of order; set workers: 1 for strict FIFO processing within a flow.
  • buffer — the depth of the bounded channel between the source and the workers. This is the flow's backpressure valve: when workers fall behind, the channel fills and the source blocks. A larger buffer absorbs bursts; a smaller one propagates backpressure sooner.
  • pool — the size of the flow's shared worker pool, used by composites that parallelize (a fork's branches run on it). The pool has a bounded task queue; if a flow's fan-out submits more work than it can accept (for example, deeply nested forks), the pool is exhausted and the runtime panics rather than risk a silent deadlock — size pool for your flow's fan-out.

Sub-flows nested inside composite blocks must not declare workers, buffer, or pool — they run within the parent's budget. See The Processing Pipeline for how the two pools interact.

A practical dev loop

From a clone of the repository, the Taskfile wraps the common loop:

# Run a sample config from samples/
task runtime:run:sample -- heartbeat.yaml
task runtime:run:sample -- http-orders.yaml

# Run the CLI with arbitrary args
task runtime:run -- run --config ../../samples/hello-world.yaml

# Build a local binary into bin/octo
task runtime:build
# Edit-and-reload loop: keep one terminal running
LOG_LEVEL=debug octo run --config ./my-flow.yaml --watch

# In another terminal, exercise flows without starting sources
octo invoke --config ./my-flow.yaml --flow my-flow --data '{}'

# Iterate on expressions in isolation
octo eval --expr 'has(body.items) ? size(body.items) : 0' --data '{"items":[1,2]}'

The combination of --watch for the running config, invoke for source-less testing, and eval for expression iteration covers most development without redeploying anything.

On this page