Octov0.4.2
Reference

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 help

Flags accept one or two dashes: --config and -config are equivalent. Running octo with no arguments prints the help page above.

Subcommands

SubcommandPurpose
runStart the configured connectors and flows until interrupted (the default subcommand).
invokeLoad 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.
evalEvaluate a CEL expression against an ad-hoc message, without a config.
schemaEmit the capability catalogue (blocks and connectors) as JSON. capabilities is an alias.
versionPrint 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
FlagTypeDefaultDescription
--configpath(required)Path to the runtime config — a YAML file or a directory.
--watchboolfalseReload 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'
FlagTypeDefaultDescription
--configpath(required)Path to the runtime config (file or directory).
--flowstring(required)Name of the flow to invoke.
--dataJSONstdinJSON request body. When omitted, piped stdin is read instead.
--content-typeMIME(none)Treat --data as a raw body of this type instead of JSON. See Raw bodies.
--varsJSON(none)JSON object seeding the message variables.
--timeoutduration30sMaximum time to wait for the flow (Go duration, e.g. 10s, 2m).
--break-ataddress(none)Run until this block, print the message it produced, and stop. See Breakpoints.
--spiesaddresses(none)Comma-separated blocks to record every message that crosses. See Spies.
--mocksJSON(none)Blocks to answer for instead of running. See Mocks.
--run-debug-configpath(none)A file holding the whole run. See Debug config.
--envelopeboolfalseAlways print the outcome envelope, reporting a flow failure in it rather than exiting non-zero.
--envelope-outstringWrite 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:

FieldMeaning
reachedWhether execution ever got to the block. Present only under --break-at.
blockThe address you asked for, echoed back.
messageThe message the block produced. Present only when reached is true.
resultThe flow's own result message. Present when it was not stopped at a breakpoint.
droppedThe flow filtered the message out and returned nothing.
spiesWhat each spied block saw. See Spies.
errorThe 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-http

If 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:

CompositeBranch names
ifthen, else
foreach, enrich, cache-scopebody
handle-errors, ai-retryprocess, error
validate, jwt-validate (filter blocks)onReject
forkeach branch's name — or its index (0, 1, …)
switcheach case's name — or its index — plus default
ai-routereach route's name, plus default
ai-agent, mcp-routereach 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
AddressBreaks on
orders.seedThe set-payload, in the flow's own chain.
orders.check-header[then].chargeThe rest inside the if's then branch.
orders.check-header[else].logThe log inside the else branch. Reports reached: false whenever the condition is true.
orders.each-order[body].classifyThe switch, on the first iteration of the loop — vars.order is the first item.
orders.each-order[body].classify[0].logThe 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].logThe log in the switch's default branch.
orders.fanout[audit].log-itA block inside a fork branch, addressed by the branch's name.
orders.fanout[1].restThe same, by branch index.
orders[error].notify-failureA 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 a switch case 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 reports reached: 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 in error.
  • 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:

FieldMeaning
addressThe address you asked for, echoed back.
recordsOne entry per crossing. Empty when the flow never reached the block.
records[].seqOrders records across every spy, so a multi-block trace reads in the order things actually happened.
records[].atWhen the crossing was recorded.
records[].inputThe message the block received.
records[].outputThe message it returned. Absent when it dropped or failed.
records[].droppedThe block filtered the message out.
records[].errorThe 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

FieldTypeMeaning
casesarrayTried in order; the first whose when holds is applied.
defaultcaseApplied when no case matched. Has no when.

Each case has a condition and exactly one outcome:

FieldTypeMeaning
whenCELEvaluated against the message the block received (body, vars, env, …). Required on a case, forbidden on the default.
bodyanyThe body the block returned. A literal value, not an expression.
varsobjectVariables 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.
errorstringFail the block with this message.
dropboolFilter 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 default fails 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: a default, or a trailing case with when: "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 declined and an error path branching on vars.error.block behaves 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:

debug.yaml
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: true
octo invoke --config orders.yaml --flow orders --run-debug-config debug.yaml
SectionEquivalent 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.debugConfig

Print 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}
FlagTypeDefaultDescription
--exprstring(required)The CEL expression to evaluate.
--dataJSONstdinJSON object bound to the body variable. When omitted, piped stdin is read instead.
--varsJSON{}JSON object bound to the vars variable.
--envJSON{}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
FlagTypeDefaultDescription
--kindenumcapabilitiesWhich schema: capabilities, or debug-config for a debug config file.
--formatenumjsonPrint it as json or yaml.
--outpathstdoutWrite 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 version
octo 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

VariableDescription
LOG_LEVELLog 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_FILEPath 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.

On this page