Octov0.11.7
Guides

Debugging a Flow

Stop a flow at a block, watch what crosses it, and stand in for the blocks that call the world.

octo invoke --break-at runs a flow until it reaches a block you name, prints the message that block produced, and stops. It answers what did the message look like when it got here, and did it even get here? Three flags address a block, and they compose:

FlagWhat it does to the blockDoes the flow keep running?
--break-atRuns it, shows the message, stopsNo, it halts there
--spiesRuns it, records what went in and outYes
--mocksReplaces it with a canned answerYes

The guide follows samples/builtins-demo.yaml, which has an if, a foreach, and a nested switch. Every command runs from the repository root.

Set up

Build the binary and export a body to reuse; the flow's first block reads body.firedAt:

go build -o bin/octo ./runtime/octo
export LOG_LEVEL=error                       # keep startup logs out of the way
D='{"firedAt": "2026-07-10T12:00:00Z"}'

Break on a block in the flow's chain

Address a block as <flow>.<block>. The sample's flow is demo, and its second block is named set-threshold:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data "$D" \
  --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}]}}}

set-payload had built the orders array into the body, and set-threshold has just put threshold: 100 into the variables. The snapshot is the message after the addressed block ran, what the next block would have received. Nothing after it ran.

Descend into a branch

A bracket names the branch to descend into. The sample's if is named any-orders, so its branches are any-orders[then] and any-orders[else]. Each holds one unnamed log block; an unnamed block is addressable by its type when it is the only one of its kind in that chain:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data "$D" \
  --break-at 'demo.any-orders[then].log'
{"reached":true,"block":"demo.any-orders[then].log","message":{"variables":{"threshold":100},"body":{…}}}

The most useful answer: it never got there

Now break on the other branch. The condition is true (there are orders), so the else branch never runs:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data "$D" \
  --break-at 'demo.any-orders[else].log'
{"reached":false,"block":"demo.any-orders[else].log"}

reached: false is a normal result, not a failure, and the command exits 0: the branch was never taken, so nothing in it can be the bug.

An address that does not resolve makes invoke exit non-zero with no envelope, so a typo can never masquerade as reached: false. The error names what is 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)

Break inside a loop

The sample's foreach is named each-order and its branch is body. Breaking on the switch inside it stops on the first iteration:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data "$D" \
  --break-at 'demo.each-order[body].classify-order'
{"reached":true,"block":"demo.each-order[body].classify-order","message":{
  "variables":{"order":{"id":1,"amount":50},"threshold":100}, "body":{…}}}

vars.order is the first item; the loop halts there and the second item is never processed.

Go two levels deep

Chain brackets to descend further. The switch inside the loop body is named classify-order; its first case (which matches amount >= threshold) is addressed by index, and its fallback by default. Order 1 has amount: 50, so it falls to default and the breakpoint does not fire on the first iteration. Order 2 (amount: 250) matches the case, and the breakpoint fires there:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data "$D" \
  --break-at 'demo.each-order[body].classify-order[0].log'
{"reached":true,"block":"demo.each-order[body].classify-order[0].log","message":{
  "variables":{"order":{"id":2,"amount":250},"threshold":100}, "body":{…}}}

vars.order is the second order, the first one that reached this block. Its sibling, the default branch, catches the first order instead:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data "$D" \
  --break-at 'demo.each-order[body].classify-order[default].log'
{"reached":true,"block":"…[default].log","message":{"variables":{"order":{"id":1,"amount":50},…}}}

When the flow fails first

If the flow fails before reaching your block, the envelope names the culprit. Drop the --data and the first block fails on the missing key:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data '{}' \
  --break-at 'demo.set-threshold'
{"reached":false,"block":"demo.set-threshold",
 "error":"block \"seed-orders\": set-payload value: evaluate expression: no such key: firedAt"}

reached: false with an error means the flow died on its way to your block. This still exits 0.

Addressing every kind of composite

Each composite exposes its branches by name:

CompositeBranch namesExample
ifthen, elseorders.check[else].api-call
foreach, enrich, cache-scopebodyorders.loop[body].transform
handle-errors, ai-retryprocess, errororders.guard[error].notify
validate, jwt-validateonRejectorders.check[onReject].explain
forkbranch name, or indexorders.fanout[audit].log-it
switchcase name, index, or defaultorders.pick[vip].comp
ai-routerroute name, or defaultorders.route[premium].charge
ai-agent, mcp-routertool name, or defaultorders.agent[lookup].fetch
the flow itselferrororders[error].notify

A composite on the way to your target always needs a branch, even with a single chain: weather.handle-errors[process].weather-step, never weather.weather-step. Every composite publishes its branches in the schema, under addressBranches:

bin/octo schema | jq '.blocks[] | select(.type == "handle-errors").addressBranches'
{
  "named": ["process", "error"],
  "note": "A block on the way to an address target must name a branch of this one: handle-errors[<branch>].<block>. Branches: process, error — e.g. handle-errors[process].<block>."
}

An AI agent driving the MCP server gets the same thing from getSchema("handle-errors").

Within a chain a block is matched by its name, else its type, else its ref. Name the block if two of a kind share a chain; an ambiguous address is rejected. The address can also name a flow other than the one you invoke, to break inside a flow reached through flow-ref; the caller halts too:

./bin/octo invoke --config ./flows --flow caller --break-at 'sub.step'

Watch a block without stopping

--spies records what crossed a block every time and leaves the run alone, for a bug in the third iteration of a loop or in one branch of a fork. The sample's foreach runs its body once per order, so a spy on the switch inside it reports one record per iteration:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data "$D" \
  --spies 'demo.each-order[body].classify-order'
{"seq":1,"order":{"id":1,"amount":50}}
{"seq":2,"order":{"id":2,"amount":250}}

(That is .spies[0].records[] | {seq, order: .input.variables.order}; the raw envelope carries the whole message on each side.) Each record holds the message that went in, the message that came out, and a seq that orders records across every spy.

Pass several addresses, comma-separated:

./bin/octo invoke --config samples/builtins-demo.yaml --flow demo --data "$D" \
  --spies 'demo.set-threshold,demo.each-order[body].classify-order'

A fork runs each branch on a clone and throws the branch's message away, so a spy is the only way to see what a block inside one received. The flow runs to completion and its result comes back in the same envelope, under result.

A block the flow never reached reports an empty record list, not an error.

Stand in for a block

--mocks replaces a block with a canned answer, so the real work (the HTTP call, the LLM, the database write) never happens and the flow runs anywhere, including CI. samples/error-handling.yaml points its rest block at a discard port, so the call always fails to connect and the flow's error path runs:

./bin/octo invoke --config samples/error-handling.yaml --flow charge-flowlevel \
  --data '{"amount": 250}'
{"event_id":"5c12…",
 "body":{"error":"block \"call-charge\": rest request: … connect: connection refused",
         "failedBlock":"call-charge","flow":"charge-flowlevel"}}

Mock the call, and the flow succeeds:

./bin/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","status":"captured"}}],
     "default":{"error":"unexpected input"}}}'
{"event_id":"5c12…","body":{"id":"ch_1","status":"captured"}}

A --mocks-only run prints its result message like any other invoke; mocking observes nothing, so there is no debug envelope. A case is a CEL when over the message the block received, and exactly one outcome:

OutcomeThe block…Use it to
body (+ optional vars)returned this messagestand in for a call's response
errorfailed with this messageexercise an error path
dropfiltered the message outstand in for a filter

Cases are tried in order; the first whose when holds wins. An optional default catches the rest.

Testing an error path without a broken upstream

A case can fail the block, which drives the error path on demand:

./bin/octo invoke --config samples/error-handling.yaml --flow charge-flowlevel \
  --data '{"amount": 250}' \
  --mocks '{"charge-flowlevel.call-charge":{"cases":[{"when":"true","error":"card declined"}]}}'
{"event_id":"5c12…",
 "body":{"error":"block \"call-charge\": card declined",
         "failedBlock":"call-charge","flow":"charge-flowlevel"}}

failedBlock still names call-charge: a mocked failure is indistinguishable from the real block failing, so an error path branching on vars.error.block behaves as in production.

Combining them

The three flags compose on the same block: a mock is innermost, a spy wraps it, a breakpoint wraps that. A spy on a mocked block shows what the flow fed it and what the mock answered:

./bin/octo invoke --config samples/error-handling.yaml --flow charge-flowlevel \
  --data '{"amount": 250}' \
  --spies 'charge-flowlevel.call-charge' \
  --mocks '{"charge-flowlevel.call-charge":{"cases":[{"when":"true","body":{"id":"ch_1"}}]}}' \
  | jq '.spies[0].records[0] | {in: .input.body, out: .output.body}'
{"in":{"amount":250},"out":{"id":"ch_1"}}

Mocking a composite (a fork, an ai-agent) removes everything inside it. A spy or breakpoint addressed inside a mocked block can never fire, so invoke rejects it:

spy "charge-inline.charge[process].call-charge" is inside mocked block "charge-inline.charge":
the mock replaces that block, so there is nothing in there to spy

Drive it all from a file

Write a session down next to the flow it exercises:

debug.yaml
input:
  data: { amount: 250 }
spies:
  - charge-flowlevel.call-charge
mocks:
  charge-flowlevel.call-charge:
    cases:
      - when: 'body.amount > 100'
        body: { id: ch_1, status: captured }
    default:
      error: unexpected input
./bin/octo invoke --config samples/error-handling.yaml --flow charge-flowlevel \
  --run-debug-config debug.yaml

The file is YAML, so a JSON --mocks blob pastes straight in. Any flag overrides the whole section it corresponds to, so the same file re-runs against a different payload:

./bin/octo invoke --config samples/error-handling.yaml --flow charge-flowlevel \
  --run-debug-config debug.yaml --data '{"amount": 5}'

An amount of 5 matches no case, so the file's default fails the block. The flow's error chain recovers it, so the failure comes back in the result:

{"result":{"body":{"error":"block \"call-charge\": unexpected input",
                   "failedBlock":"call-charge","flow":"charge-flowlevel"}},
 "spies":[{"records":[{"error":"unexpected input"}]}]}

The spy records the block failing; the result shows what the flow made of it.

A typo'd key such as spys: is rejected, not ignored:

parse debug config "debug.yaml": field spys not found in type main.debugConfig

Print the file's schema for editor completion and validation:

./bin/octo schema --kind debug-config --format yaml

A file like this one, plus what should have happened, is a test. dolphin writes a debug config per case, runs it through this same invoke, and checks the answer.

Things worth knowing

The block you break on still runs; if it fails or drops the message there is nothing to show and you get reached: false. Nothing downstream runs, so no later side effects fire. A mocked block does not run: it is replaced, not wrapped, so a message matching no case (with no default) fails the block rather than falling through to the real one.

A cache-scope hit skips its body, so a breakpoint inside the body reports reached: false, and a halted body is never written to the cache. A fork's sibling branches still finish: breaking in one branch does not cancel the others, and the flow halts once the fork joins.

All three flags are invoke-only and are refused on a source-backed service. None of them is a block you can write: spy, mock and breakpoint are injected by the runtime, a config that declares one by hand fails to build, and they never appear in the editor palette.

--data only fills the body. No source runs, so a block that reads vars needs --vars to seed them, e.g. --vars '{"x-api-key": "dev-key"}' for the headers an HTTP source would have copied across. Without it, the expression fails with no such key.

See the CLI reference for the full address grammar.

Without the command line

The editor offers all three on the canvas and writes the addresses. Hover any block:

ButtonFlag
▶--break-at, run to here and show the message
🧪--mocks, stand in for this block
👁--spies, record what crosses it

A spy's records come back as a count on the block and add up across runs. See Debugging flows in the editor for the run and debug panels.

Without a human

MCP's invoke_flow takes breakAt, spies and mocks directly. See the MCP server.

When the answer is worth keeping

A debugging session already contains a test: the input, the mocks, and what came back. Write it down as a dolphin suite; in the editor, a run on the console carries Save as test case, and the case then appears in that flow's ▶ menu as a scenario to replay. See Testing flows in the editor.

On this page