Debugging a Flow
Stop a flow at a block, watch what crosses it, and stand in for the blocks that call the world.
When a flow does not do what you expect, the question is almost always the same:
what did the message look like when it got here — and did it even get here?
This guide answers both with octo invoke --break-at, which runs a flow until it
reaches a block you name, prints the message that block produced, and stops.
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 |
It follows samples/builtins-demo.yaml,
which has an if, a foreach, and a nested switch — enough shape to address
blocks at every depth. Every command below is copy-pasteable from the repository
root.
Set up
Build the binary and export a body to reuse. The flow's first block reads
body.firedAt, so the body must supply it:
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}]}}}Read that as: execution reached the block, the earlier set-payload had already
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 — exactly what the next block would have received. Nothing
after it ran at all.
Descend into a branch
A bracket names the branch to descend into. The sample's if is named
any-orders, so its two branches are any-orders[then] and any-orders[else].
There is a single unnamed log block in each, and an unnamed block is addressable
by its type as long as 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 — the command exits 0. This
is frequently the thing you actually needed to learn: the branch you were staring
at was never taken, so nothing in it could possibly be the bug.
An address that does not resolve is a different matter — that is a mistake in
your command, so invoke exits non-zero and prints no envelope. A typo can never
masquerade as reached: false. The error tells you what is really 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 — 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.
The interesting part: order 1 has amount: 50, so it falls to default and the
breakpoint does not fire on the first iteration. The loop continues, 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":{…}}}The snapshot shows vars.order is the second order — the first one that
actually reached this block. Breaking inside a branch only some items take is a
quick way to inspect the first item that takes it.
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 blows up before reaching your block, the envelope says so and 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.
The failure is a debugging result, so 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 when it has
a single obvious chain: weather.handle-errors[process].weather-step, never
weather.weather-step.
This table is also machine-readable, so you never have to remember it — 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"), so it can write a nested address correctly the first
time rather than learning it from a failed run.
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
rather than guessed at.
The address can also 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 caller halts too:
./bin/octo invoke --config ./flows --flow caller --break-at 'sub.step'Watch a block without stopping
A breakpoint answers "what did the message look like here", once, and then halts
the flow. --spies answers "what crossed this block", every time, and leaves
the run alone. That is what you want when the bug is 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 — so a trace over several blocks reads in the order things
actually happened.
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 spy sees what nothing else can: a fork runs each branch on a clone and
throws the branch's message away, so a block inside one has no other way to
report what it received. And unlike a breakpoint, the flow still runs to
completion — the result comes back in the same envelope, under result.
A block the flow never reached reports an empty record list, not an error.
Nothing crossed it — which, as with reached: false, is often the answer you
needed.
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. This is what closes the loop:
a flow you could only run against a live upstream becomes a flow you can run
anywhere, including in CI.
samples/error-handling.yaml
points its rest block at a discard port, so the call always fails to connect.
Unmocked, 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 a flow that cannot succeed does:
./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
changes what the flow does, but it observes nothing, so there is no debug
envelope to wrap.
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
Because a case can fail the block, you can drive 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"}}Note 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 exactly as it would in production.
Combining them
The three flags compose on the same block. A mock is innermost, a spy wraps it, a breakpoint wraps that — so a spy on a mocked block shows you 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 —
that is how you cut out a whole sub-tree that would call the network. Which means
a spy or breakpoint addressed inside a mocked block can never fire, so invoke
rejects it rather than silently reporting nothing:
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
A useful session is now a page of flags. Typing it again tomorrow is how a reproduction gets lost — so write it down and check it in 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.yamlIt is YAML, and JSON works too (JSON is a subset of YAML), so a --mocks blob
that worked on the command line pastes straight in. Any flag overrides the
section it corresponds to, whole — so the same file re-runs against a different
payload without editing it:
./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 fires and fails the block.
This flow has an error chain, which 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 is rejected, not ignored — a misspelled spys: that was quietly
skipped would look exactly like a feature that does not work:
parse debug config "debug.yaml": field spys not found in type main.debugConfigTo have your editor complete the file and a validator check it, print its schema:
./bin/octo schema --kind debug-config --format yamlA file like this one, plus what should have happened, is a test. That is all
dolphin is: it writes a debug config per case, runs it
through this same invoke, and checks the answer. A reproduction you checked in is
one assertion away from a regression test that runs in CI.
Things worth knowing
- The block you break on still runs. The snapshot is taken after it, so you
see its effect. If it fails or drops the message, there is nothing to show and
you get
reached: false. - A mocked block does not. It is replaced, not wrapped — that is the whole
point. And a message matching no case (with no
default) fails the block, rather than falling through to the real one: a "mocked" call that quietly still reached the network would be the exact bug mocking exists to prevent. - Nothing downstream runs. The flow halts, so no later side effects fire.
- A
cache-scopehit skips its body, so a breakpoint inside the body reportsreached: 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; the flow halts once the fork joins. - All three are
invoke-only, and are refused on a source-backed service. A breakpoint would halt whichever production message arrived first and a mock would answer it with a canned response. A spy is read-only, but nothing drains its records outside an invoke, so it would grow without bound. - None of them is a block you can write.
spy,mockandbreakpointare injected by the runtime; a config that declares one by hand fails to build, and they never appear in the editor palette. --dataonly fills the body. Since no source runs, nothing sets the message's variables either — so a block that readsvarsneeds--varsto seed them, e.g.--vars '{"x-api-key": "dev-key"}'to stand in for the headers an HTTP source would have copied across. Without it, the expression fails withno such key.
See the CLI reference for the full address grammar.
Without the command line
All three features are on the canvas too, and the editor writes the addresses for you. Hover any block and it offers the same three things, in the same order:
| 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 itself, and add up across runs. See Debugging flows in the editor.
Without a human
An agent gets the same three through MCP's invoke_flow, which takes breakAt,
spies and mocks directly — so it can stub out a payment API and watch an inner
block without editing the flow. See the MCP server.