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:
| Flag | What it does to the block | Does the flow keep running? |
|---|---|---|
--break-at | Runs it, shows the message, stops | No, it halts there |
--spies | Runs it, records what went in and out | Yes |
--mocks | Replaces it with a canned answer | Yes |
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:
| Composite | Branch names | Example |
|---|---|---|
if | then, else | orders.check[else].api-call |
foreach, enrich, cache-scope | body | orders.loop[body].transform |
handle-errors, ai-retry | process, error | orders.guard[error].notify |
validate, jwt-validate | onReject | orders.check[onReject].explain |
fork | branch name, or index | orders.fanout[audit].log-it |
switch | case name, index, or default | orders.pick[vip].comp |
ai-router | route name, or default | orders.route[premium].charge |
ai-agent, mcp-router | tool name, or default | orders.agent[lookup].fetch |
| the flow itself | error | orders[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:
| Outcome | The block… | Use it to |
|---|---|---|
body (+ optional vars) | returned this message | stand in for a call's response |
error | failed with this message | exercise an error path |
drop | filtered the message out | stand 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 spyDrive it all from a file
Write a session down next to the flow it exercises:
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.yamlThe 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.debugConfigPrint the file's schema for editor completion and validation:
./bin/octo schema --kind debug-config --format yamlA 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:
| Button | Flag |
|---|---|
| ▶ | --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.