Octov0.4.2
Guides

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:

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

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:

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

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

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 spy

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

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

It 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.debugConfig

To have your editor complete the file and a validator check it, print its schema:

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

A 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-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; 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, 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. Since no source runs, nothing sets the message's variables either — so a block that reads vars needs --vars to 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 with no 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:

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 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.

On this page