CLI
The octo command: run, invoke, eval, schema, and version.
The octo binary is the runtime's command-line interface. It starts
integrations, calls flows directly, evaluates CEL expressions, and emits the
capability catalogue the editor and documentation tooling consume.
octo — run and invoke octo integration flows
Usage:
octo [run] --config <path> [--watch] Start connectors and flows (default)
octo invoke --config <path> --flow <name> [--data <json>] [--vars <json>]
Call one flow and print its result
octo eval --expr <cel> [--data <json>] Evaluate a CEL expression and print the result
octo schema [--out <path>] Print the editor capability schema as JSON
octo version Print the version and build date
octo --help Show this helpFlags accept one or two dashes: --config and -config are equivalent.
Running octo with no arguments prints the help page above.
Subcommands
| Subcommand | Purpose |
|---|---|
run | Start the configured connectors and flows until interrupted (the default subcommand). |
invoke | Load a config, call one flow by name with a JSON body, and print its result. Use --break-at to stop at a block and inspect the message there. |
eval | Evaluate a CEL expression against an ad-hoc message, without a config. |
schema | Emit the capability catalogue (blocks and connectors) as JSON. capabilities is an alias. |
version | Print the release version and build date. |
octo run
Starts every connector and flow declared in the config and keeps them running
until the process receives SIGINT (Ctrl-C) or SIGTERM. run is the default
subcommand, so a leading flag dispatches to it: octo --config app.yaml is the
same as octo run --config app.yaml.
octo run --config samples/hello-world.yaml| Flag | Type | Default | Description |
|---|---|---|---|
--config | path | (required) | Path to the runtime config — a YAML file or a directory. |
--watch | bool | false | Reload the runtime when the config changes. |
When --config points to a directory, every .yaml/.yml file directly in it
(sorted by name, subdirectories ignored) is loaded and merged into one config.
The config's directory also becomes the resource root that relative resource
ids (templates, env files) resolve against.
Files ending in _test.yaml are skipped. orders_test.yaml is a test suite
for the flows in orders.yaml, the way orders_test.go tests orders.go — it
lives beside them, and the runtime walks past it just as the Go compiler walks
past a _test.go.
Once every connector and flow has started, the runtime prints a ready banner to stdout:
time=... level=INFO msg="starting runtime" version=0.4.2 connectors=1 flows=1🚀 octo v0.4.2 is up and ready to roll!Watch mode
With --watch, the runtime watches the config path (and the .env files it
consults) and rebuilds on every change: the current generation is stopped,
the config is reloaded, and a new generation starts. Failures are not fatal in
watch mode — a config that fails to load, build, or start logs an 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.
octo invoke
Loads a config, starts the flows in invoke mode (sources are not started — no ports are bound, no schedules fire), calls the named flow once with the given body, prints the result message as JSON on stdout, and exits.
octo invoke --config samples/hello-invoke.yaml --flow greet --data '{"name": "Ada"}'{"event_id":"5c12a0e3f47b4d19a6c8f0b21d3e4a97","body":{"greeting":"hello, Ada!"}}The result is the whole message — event_id, variables, body — and not the
body alone, because the variables a flow builds up with set-variable, enrich
or foreach are as much its result as the payload is. variables is omitted
when the flow set none, as in the greeting above. To pipe just the payload
onward, ask for it:
octo invoke --config orders.yaml --flow orders --data '{"orderId": 42}' | jq '.body'| Flag | Type | Default | Description |
|---|---|---|---|
--config | path | (required) | Path to the runtime config (file or directory). |
--flow | string | (required) | Name of the flow to invoke. |
--data | JSON | stdin | JSON request body. When omitted, piped stdin is read instead. |
--content-type | MIME | (none) | Treat --data as a raw body of this type instead of JSON. See Raw bodies. |
--vars | JSON | (none) | JSON object seeding the message variables. |
--timeout | duration | 30s | Maximum time to wait for the flow (Go duration, e.g. 10s, 2m). |
--break-at | address | (none) | Run until this block, print the message it produced, and stop. See Breakpoints. |
--spies | addresses | (none) | Comma-separated blocks to record every message that crosses. See Spies. |
--mocks | JSON | (none) | Blocks to answer for instead of running. See Mocks. |
--run-debug-config | path | (none) | A file holding the whole run. See Debug config. |
--envelope | bool | false | Always print the outcome envelope, reporting a flow failure in it rather than exiting non-zero. |
--envelope-out | string | — | Write the envelope to this file instead of stdout (implies --envelope). Use it when the flow itself prints. |
The body can also arrive on stdin — --data and a pipe are interchangeable:
echo '{"name": "Grace"}' | octo invoke --config samples/hello-invoke.yaml --flow greet{"event_id":"9f1c3b0e7a5d4e2b8c6f0a1d2e3f4b5c","body":{"greeting":"hello, Grace!"}}Stdin is only read when it is piped or redirected, never from an interactive
terminal, and it must be valid JSON. When the flow drops the message (a filter
rejected it, or a block returned nothing), invoke logs
flow dropped the message and prints no result. Any flow is callable this
way, including flows without a source: (see
Flow File).
Raw bodies
--data is JSON by default, so a flow fed by a
raw-content source — XML, CSV, form-encoded, anything
non-JSON — could not be invoked with a realistic payload. --content-type opts
the body out of JSON: the bytes of --data become the message body verbatim,
tagged with the MIME type you give, exactly as an HTTP source configured with
rawBody: true would receive them.
octo invoke --config orders.yaml --flow intake \
--content-type application/xml \
--data '<order><id>42</id></order>'Inside the flow the body takes the raw-content shape — body.contentType and
body.rawData — rather than a parsed object; see
Raw Content for what the flow then does with it. Without
--content-type, nothing changes: the body is parsed as JSON as before.
Seeding variables
A message is a body and a set of variables, and --data only fills the
body. Because invoke starts no sources, nothing populates those variables for
you — the HTTP source is what normally copies request headers into them (see
State and Data).
So a flow that reads vars needs them supplied explicitly, which is what
--vars is for:
octo invoke --config orders.yaml --flow orders \
--data '{"orderId": 42}' \
--vars '{"x-api-key": "dev-key"}'The flow now sees vars["x-api-key"] exactly as it would behind a real request.
Without --vars, an expression reading a variable that was never set fails with
a missing-key error — which is usually the reason an invoked flow behaves
differently from the same flow under its source.
Breakpoints
--break-at answers a question a plain invoke cannot: what did the message
look like at this point in the flow? The runtime wraps the block you address,
runs the flow until it reaches that block, records the message the block
produced, and stops — the rest of the chain never runs.
octo invoke --config samples/builtins-demo.yaml --flow demo \
--data '{"firedAt": "2026-07-10T12:00:00Z"}' \
--break-at 'demo.set-threshold'{"reached":true,"block":"demo.set-threshold","message":{
"event_id":"5c12…","variables":{"threshold":100},
"body":{"firedAt":"2026-07-10T12:00:00Z","orders":[{"id":1,"amount":50},{"id":2,"amount":250}]}}}The snapshot is the message after the addressed block ran — what the next block would have received. It carries the body and every variable set so far.
Breakpoints are a debugging aid for invoke only. They are not a block you can
write in a flow, they never appear in the editor palette, and they are refused
outside invoke mode, so they can never halt live traffic.
The debug envelope
A run that was asked to observe itself — with --break-at, --spies, or both
— prints one JSON envelope instead of the result message, wrapping what it saw:
| Field | Meaning |
|---|---|
reached | Whether execution ever got to the block. Present only under --break-at. |
block | The address you asked for, echoed back. |
message | The message the block produced. Present only when reached is true. |
result | The flow's own result message. Present when it was not stopped at a breakpoint. |
dropped | The flow filtered the message out and returned nothing. |
spies | What each spied block saw. See Spies. |
error | The flow's failure, when it failed before reaching the block. |
A --mocks-only run prints its result message, like any other invoke — mocking
changes what the flow does but produces no observations of its own, so there is
no envelope to wrap them in. Ask for it outright with
--envelope.
The outcome envelope
--envelope prints the same envelope for any run, even one that observed
nothing. Use it when a program — rather than a person — reads the output.
A plain invoke reports its three outcomes three different ways: a flow that
completed prints its message, one that dropped the message prints nothing at
all, and one that failed prints nothing and exits 1 with the error in a log
line. Reading stdout, you cannot tell a drop from a result, and a failure has to
be scraped out of the logs. --envelope says all three in one shape:
octo invoke --config samples/builtins-demo.yaml --flow demo --data '{"firedAt":"x"}' --envelope{"result":{"event_id":"6708a609…","body":{"firedAt":"x","orders":[…]}}}{"dropped":true}
{"error":"block \"seed-orders\": upstream exploded"}Under --envelope, a flow that fails exits 0 — the flow failing is the
answer to the question you asked, not a failure of the CLI. A run that could not
start (an unresolvable block address, a broken config) is a bad request and
still exits non-zero, because there is no outcome to report.
--envelope-out <path> — when the flow itself prints
stdout is not the CLI's alone. A log block writes through
a logger connector, whose output defaults to stdout — so a flow that logs
anything interleaves its own JSON with the envelope, and a program reading stdout
gets two documents where it expected one. It cannot be quieted from outside,
either: the connector takes its level and destination from the config, not the
environment.
--envelope-out writes the envelope to a file instead, on a channel the flow
cannot reach. stdout stays entirely the flow's.
octo invoke --config samples/multi-transform.yaml --flow order \
--data '{"orderId":"A-1","qty":3,"price":10.0}' --envelope-out outcome.json
# stdout is the flow's own log line:
{"time":"…","level":"INFO","msg":"order A-1 subtotal=30 total=33"}
# the answer is in the file:
cat outcome.json
{"result":{"event_id":"…","body":{"orderId":"A-1","subtotal":30,"total":33}}}This is what dolphin, the test runner, reads — which is why it can test a flow
that logs.
A block that is never reached is a normal result, not an error. If the flow
takes the other branch of an if, matches a different switch case, or drops
the message first, you get reached: false and exit code 0:
{"reached":false,"block":"demo.any-orders[else].log"}That is usually the most valuable answer the flag gives you — it tells you the branch you were debugging never ran at all.
An address that does not resolve is a different thing entirely: it is a
mistake in your command, so invoke exits non-zero and prints no envelope.
A typo can never be mistaken for reached: false. The error lists what is
actually there:
no block "nosuchblock" in that chain (blocks: seed-orders, set-threshold, any-orders, each-order, drop-threshold)
has no branch "nope" (branches: then, else)Addressing a block
An address starts with the flow name and walks down to the block, one segment per step:
<flow>.<block>
<flow>.<composite>[<branch>].<block>
<flow>.<composite>[<branch>].<composite>[<branch>].<block>A bracket names the branch to descend into. Every segment but the last names a composite and the branch to follow; the last segment names the block you want to break on.
Naming a block
Within a chain, a block is matched by its name, else its type, else
its ref — in that order. So an unnamed block is still addressable, as long
as it is the only one of its kind in that chain:
process:
- type: set-payload
name: seed-orders # -> demo.seed-orders
- type: log # -> demo.log (only one `log` in this chain)
- ref: shared-http # -> demo.shared-httpIf two blocks in the same chain would match, the address is rejected rather
than guessed at — give the one you want a name:.
Naming a branch
Every composite exposes its branches by name:
| Composite | Branch names |
|---|---|
if | then, else |
foreach, enrich, cache-scope | body |
handle-errors, ai-retry | process, error |
validate, jwt-validate (filter blocks) | onReject |
fork | each branch's name — or its index (0, 1, …) |
switch | each case's name — or its index — plus default |
ai-router | each route's name, plus default |
ai-agent, mcp-router | each tool's name, plus default |
A bracket on the flow itself selects its error chain: orders[error].notify.
Examples
Given this flow:
flows:
- name: orders
process:
- type: set-payload
name: seed
- type: if
name: check-header
condition: "size(body.orders) > 0"
then:
process:
- type: rest
name: charge
else:
process:
- type: log
- type: foreach
name: each-order
items: "body.orders"
as: order
body:
process:
- type: switch
name: classify
cases:
- when: "vars.order.amount >= 100"
process:
- type: log
default:
process:
- type: log
- type: fork
name: fanout
branches:
- name: audit
process:
- type: log
name: log-it
- name: notify
process:
- type: rest
error:
- type: log
name: notify-failure| Address | Breaks on |
|---|---|
orders.seed | The set-payload, in the flow's own chain. |
orders.check-header[then].charge | The rest inside the if's then branch. |
orders.check-header[else].log | The log inside the else branch. Reports reached: false whenever the condition is true. |
orders.each-order[body].classify | The switch, on the first iteration of the loop — vars.order is the first item. |
orders.each-order[body].classify[0].log | The log inside the switch's first case. The loop keeps going until a message actually reaches it, so this fires on whichever iteration first matches amount >= 100. |
orders.each-order[body].classify[default].log | The log in the switch's default branch. |
orders.fanout[audit].log-it | A block inside a fork branch, addressed by the branch's name. |
orders.fanout[1].rest | The same, by branch index. |
orders[error].notify-failure | A block on the flow's error chain. Reports reached: false unless the flow fails. |
The address may name a flow other than the one you invoke, which is how you
break inside a flow reached through flow-ref. The stop folds back through the
call, so the calling flow halts too:
octo invoke --config ./flows --flow caller --break-at 'sub.step'Things worth knowing
- The block after the breakpoint never runs. That is the point — the flow halts, so no downstream side effects fire.
- In a
foreach, the breakpoint fires on the first iteration that reaches it, and the loop stops there. Breaking inside aswitchcase that only some items match is a good way to inspect the first item that does. - In a
cache-scope, a cache hit skips the body entirely, so a breakpoint inside the body correctly reportsreached: false. Nothing is written to the cache when a breakpoint halts the body. - A
fork's sibling branches still run to completion. A breakpoint in one branch does not cancel the others; the flow halts after the fork joins. - The block you break on still runs. The snapshot is taken after it. If it
fails or drops the message, there is no message to show and you get
reached: false— with the failure inerror. - Numbers in the printed body are JSON numbers, so integers may show as floats.
Spies
--spies records every message that crosses a block, and — unlike a breakpoint —
leaves the run alone. It takes a comma-separated list of the same
block addresses:
octo invoke --config samples/builtins-demo.yaml --flow demo \
--data '{"firedAt": "2026-07-10T12:00:00Z"}' \
--spies 'demo.set-threshold,demo.each-order[body].classify-order'The envelope grows a spies array, one entry per address, in the order you gave
them. Each entry holds the crossings in the order they happened:
| Field | Meaning |
|---|---|
address | The address you asked for, echoed back. |
records | One entry per crossing. Empty when the flow never reached the block. |
records[].seq | Orders records across every spy, so a multi-block trace reads in the order things actually happened. |
records[].at | When the crossing was recorded. |
records[].input | The message the block received. |
records[].output | The message it returned. Absent when it dropped or failed. |
records[].dropped | The block filtered the message out. |
records[].error | The block failed, with this message. |
A block that runs many times — inside a foreach body, a fork branch, a
cache-scope — reports one record per crossing, which is the point: a spy
that showed only the first iteration would hide the one that went wrong.
A spy also sees what nothing else can. A fork runs each branch on a clone and
discards the branch's message, so a block inside one has no way to report through
the flow's result; the spy's records leave out of band.
Mocks
--mocks replaces a block with a canned answer, so its real work — an HTTP
call, an LLM, a database write — never happens. It takes one JSON object keyed by
block address:
octo invoke --config samples/error-handling.yaml --flow charge-flowlevel \
--data '{"amount": 250}' \
--mocks '{"charge-flowlevel.call-charge":{
"cases":[{"when":"body.amount > 100","body":{"id":"ch_1"}}],
"default":{"error":"unexpected input"}}}'It is one JSON blob rather than a repeated flag because a spec is nested — a list of cases, each with an expression and an outcome — and there is no honest way to flatten that onto argv. For anything real, put it in a debug config file instead.
The mock spec
| Field | Type | Meaning |
|---|---|---|
cases | array | Tried in order; the first whose when holds is applied. |
default | case | Applied when no case matched. Has no when. |
Each case has a condition and exactly one outcome:
| Field | Type | Meaning |
|---|---|---|
when | CEL | Evaluated against the message the block received (body, vars, env, …). Required on a case, forbidden on the default. |
body | any | The body the block returned. A literal value, not an expression. |
vars | object | Variables the block set, alongside its body — for blocks that report through a variable, like an HTTP call setting vars.status. Only valid with a body. |
error | string | Fail the block with this message. |
drop | bool | Filter the message out, as a filter block would. |
body is a literal rather than an expression on purpose: the dispatch already
happened in when, so a mock keeps exactly one CEL surface.
Things worth knowing
- A mock replaces the block; it does not wrap it. A wrapped HTTP call would still fire.
- No matching case and no
defaultfails the block — it does not fall through to the real one, which is gone from the flow. Keeping it wired would mean a "mocked" call could still reach the network and spend money on a run you believe is mocked. The escape hatch is one line: adefault, or a trailing case withwhen: "true". - A mocked failure is indistinguishable from a real one. The mock takes the
target's label, so the error reads
block "call-charge": card declinedand an error path branching onvars.error.blockbehaves exactly as in production. - Mocking a composite removes everything inside it — that is how you cut out a whole sub-tree. A spy or breakpoint addressed inside a mocked block is therefore rejected, rather than silently reporting nothing.
Combining the three flags
They compose on the same block, and the nesting is fixed:
breakpoint[ spy[ mock ] ]So a spy on a mocked block shows you what the flow fed it and what the mock answered, and a breakpoint on top stops the flow there. The order is not arbitrary — the mock has to be innermost because it replaces its target, and the breakpoint outermost because it halts the flow by marking the message, which a spy wrapped around it would record as part of the block's output.
Debug config file
--run-debug-config reads a whole debugging run from one file, so a session is
reproducible instead of a page of flags someone has to retype:
input:
data: { amount: 250 }
vars: { x-api-key: dev-key }
breakAt: orders.charge
spies:
- orders.validate
- orders.fanout[audit].log-it
mocks:
orders.charge:
cases:
- when: 'body.amount > 100'
error: card declined
default:
drop: trueocto invoke --config orders.yaml --flow orders --run-debug-config debug.yaml| Section | Equivalent flag |
|---|---|
input.data | --data |
input.vars | --vars |
breakAt | --break-at |
spies | --spies |
mocks | --mocks |
Every section is optional. It is YAML, and JSON is accepted too — JSON is a
subset of YAML, so a --mocks blob pastes straight in.
A flag overrides its section, whole. --spies replaces the file's list
rather than adding to it, so a flag always says exactly what the run will do —
merging would leave no way to run the file with fewer spies than it lists.
For the body the order is --data, then the file's input.data, then piped
stdin. The file comes before stdin, unlike a bare invoke: a run fully
described in a file must never sit waiting on a pipe for a body it already has.
A file with no input.data still falls through to stdin.
Unknown keys are rejected. A debug config is hand-written, and a typo'd key
that was silently ignored would look exactly like a feature that does not work —
a misspelled spys: would report no records at all, and you would go hunting for
the bug in your flow:
parse debug config "debug.yaml": field spys not found in type main.debugConfigPrint the file's JSON Schema with
octo schema --kind debug-config, so an editor can complete it
and a validator can check it.
octo eval
Evaluates a CEL expression against an ad-hoc message — no config and no runtime services are involved. It is the fastest way to experiment with expressions before pasting them into a flow; see CEL Expressions for a guided tour.
octo eval --expr '1 + 2'{"ok":true,"result":3}| Flag | Type | Default | Description |
|---|---|---|---|
--expr | string | (required) | The CEL expression to evaluate. |
--data | JSON | stdin | JSON object bound to the body variable. When omitted, piped stdin is read instead. |
--vars | JSON | {} | JSON object bound to the vars variable. |
--env | JSON | {} | JSON object bound to the env variable. |
The remaining message variables get defaults: eventID is a fresh id,
correlationID is empty, and now is the evaluation time.
octo eval --expr 'body.orders.filter(o, o.amount >= vars.min).map(o, o.id)' \
--data '{"orders": [{"id": 1, "amount": 50}, {"id": 2, "amount": 250}]}' \
--vars '{"min": 100}'{"ok":true,"result":[2]}The output is always a one-line JSON envelope:
{"ok":true,"result":<value>}
{"ok":false,"result":null,"error":"<message>"}ok distinguishes a successful evaluation from a compile or evaluation
failure; result is always present so a legitimate false/0/null result
is not confused with its absence. Both outcomes exit 0 — a bad expression is
a normal result, not a CLI failure — so scripts should parse the envelope
rather than inspect exit codes:
octo eval --expr '1 +'{"ok":false,"result":null,"error":"compile expression \"1 +\": ERROR: <input>:1:4: Syntax error: mismatched input '<EOF>' ..."}eval runs without a loaded config, so the templateResource() function has
no resources to render — calling it reports resource: not found. Every
other part of the expression environment behaves exactly as it does inside a
flow. See Octo Extensions.
octo schema
Generates the capability catalogue — the machine-readable description of every
registered block and connector (types, labels, categories, settings fields,
defaults) — and prints it as JSON. octo capabilities is an alias.
octo schema # print to stdout
octo schema --out capabilities.json| Flag | Type | Default | Description |
|---|---|---|---|
--kind | enum | capabilities | Which schema: capabilities, or debug-config for a debug config file. |
--format | enum | json | Print it as json or yaml. |
--out | path | stdout | Write it to a file instead of stdout. |
The output is an object with two arrays:
{
"blocks": [
{
"type": "ai-mapping",
"label": "AI Mapping",
"category": "processor",
"group": "AI & LLM",
"icon": "Sparkles",
"description": "...",
"fields": [ ... ]
}
],
"connectors": [ ... ]
}This catalogue powers the visual editor's block palette and property forms,
and CI's documentation drift check compares it against the reference pages —
every registered block and connector type must be documented by exactly one
page. If you add a block or connector, octo schema is the source of truth
the tooling holds the docs to.
The debug-config schema
--kind debug-config prints the JSON Schema of a
--run-debug-config file, so an editor can complete it and
a validator can check it:
octo schema --kind debug-config --out debug.schema.json
octo schema --kind debug-config --format yaml # same document, as YAML$schema: https://json-schema.org/draft/2020-12/schema
$id: https://octo.run/schemas/debug-config.json
title: octo debug config
type: object
additionalProperties: false
properties:
input: …
breakAt: …
spies: …
mocks: …octo version
Prints the release version and, when available, the build timestamp.
octo versionocto 0.4.2 (built 2026-07-09T06:11:43Z)octo --version and octo -version print the same line. The build date is
stamped at link time for released binaries; a go build binary falls back to
the VCS commit time, and the suffix is omitted when neither exists.
Environment variables
| Variable | Description |
|---|---|
LOG_LEVEL | Log verbosity for the process logger: debug, info (default), warn, or error. An invalid value logs a warning and falls back to info. Logs are written to stderr. |
OCTO_ENV_FILE | Path to an extra .env file loaded in addition to ./.env, overlaying it. See Flow File — env. |
Config-declared environment variables (the env: section of a flow file) are
resolved with the precedence OS environment > .env file > declared default;
see Flow File for the full rules.