Octov0.4.2
Standalone Editor

Debugging Flows

Run one flow with a test input, stop it on any block, and read the message it was carrying.

Run starts your whole integration: every source comes up and waits for real traffic. That is the right thing when you want to try an endpoint, and the wrong thing when you have one flow and one question about it.

This page is about the other way to run: invoking a single flow with an input you supply, and stopping it on a block to see the message at that point. Nothing is listening, nothing is scheduled — the flow just runs, once, and tells you what came out.

It is the same engine as octo invoke on the command line. The editor is a front-end for that command.

Run a flow

Every flow card carries a to the left of its name. Press it and a menu offers the inputs to run with:

A flow card with its run menu open, offering No input, two saved test inputs, and Add test input

  • No input runs the flow with an empty message. Enough for a flow that builds its own payload; not enough for one that reads body.orderId.
  • Below that are the flow's saved test inputs. Pick one and the flow runs with it.

The result lands on the console's Output tab, which opens by itself.

Invoking a flow does not start its sources. An HTTP-sourced flow is not served on a port, and a cron-sourced flow does not wait for its schedule — the flow runs immediately on the message you give it. That is the whole point, but it is also the most likely reason a flow behaves differently here than in a real run: whatever the source would have put on the message, your test input has to supply.

Test inputs

A test input is a named message: a JSON body, and JSON variables.

The add-test-input form, with a name, a JSON body and a variables map

Variables matter more than they first appear. A source does not only hand over a body — the HTTP source copies request headers into the message's variables, and a flow that reads vars["x-api-key"] will fail with no such key if you only fill in the body. The variables field is where you stand in for that.

Inputs are saved per flow, so the ones you use every day are a click away, and they survive a reload. Hover a row in the menu to edit or delete it.

An integration you have not saved yet has nowhere to keep its inputs. You can still run it with a scratch input; the menu will tell you to save the flow first if you want to keep one.

Where they are stored

Test inputs live in .octo/editor-meta.json, next to your flows:

{
  "version": 1,
  "resources": {
    "orders.yaml": {
      "flows": {
        "orders": {
          "inputs": [
            {
              "id": "3f2a…",
              "name": "large order",
              "data": "{\"orderId\": 42, \"amount\": 250}",
              "vars": "{\"x-api-key\": \"dev-key\"}"
            }
          ]
        }
      }
    }
  }
}

Two things follow from this, and both are deliberate:

  • It is safe to commit. Unlike .env.dev, which holds real credentials, this file holds only the sample messages you chose to type — so a team can share the inputs a flow is usually exercised with. Keep secrets out of it and it stays shareable; if you must put one in a variable, .gitignore the file.
  • It never ships. No flow declares .octo/editor-meta.json under resources:, and a deployment only carries the resources its config declares. It is editor metadata, not part of the integration, and it does not reach production.

The file is keyed by flow name, and the editor keeps it in step as you work: rename a flow and its saved inputs — and its mocks and spies — follow the new name.

Why mocking a block can name it

A test input belongs to a flow. A mock or a spy belongs to a block, and has to still find that block tomorrow — so it is stored against the block's address, the same orders.charge path the CLI uses.

A block is addressed by its name, or by its type when it has none. Two unnamed log blocks in one chain therefore both answer to log, and neither has an address that could be written down and found again.

So placing a mock or a spy on such a block gives it a name — you will see log-2 appear on it. It is a real edit to your flow, and that is the point: the alternative is a key that silently stops matching the next time you open the project. Rename it to something better whenever you like; the mock follows.

Run to a block

The question that actually gets asked of a misbehaving flow is not "what did it return" — it is "what did the message look like when it got here". Hover any block and a cluster of buttons appears in its corner: to run to it, 🧪 to stand in for it, 👁 to watch it, and ✕ to delete it.

A block hovered mid-flow, showing the run-to-here, mock and watch buttons beside its delete button

Press it and the flow runs from the top and stops there. Output shows the message that block was carrying — here, set-payload inside an enrich, with the date it had just written into the body:

The Output tab showing the message captured at a breakpoint

The snapshot is taken after the block runs, so you see its effect — exactly what the next block would have received. Nothing downstream runs at all, so no later side effects fire.

It reuses the input the flow was last run with, so you can move the breakpoint from block to block without re-picking an input each time.

"Never reached" is an answer

Put the breakpoint inside an if's else branch, run an input that takes then, and the result says the block was never reached.

That is a normal outcome, not a failure — and it is frequently the thing you needed to learn. The branch you were staring at was never taken, so nothing in it could be the bug.

If the flow fails on its way to your block, that is different: the error names the block that actually broke, and it goes to Problems.

A block whose position cannot be described unambiguously has no ▶ — the editor would rather offer nothing than break on a different block. In practice this is rare; the editor addresses even unnamed, look-alike blocks correctly on its own.

Stand in for a block

Some blocks you would rather not run. A payment API charges a card; an LLM costs money and answers differently every time; a webhook tells someone something. The 🧪 button on a block replaces it for the run — the real block never executes.

A REST Call block's mock form: one case failing with an error when a CEL condition holds, and an Otherwise default returning a canned body

A mock answers with cases, tried in order, each guarded by a CEL condition evaluated against the message the block received. Each case does exactly one thing, because a block can only do one thing: return a body, fail with an error, or drop the message.

That makes an error path trivial to exercise. You do not need a broken upstream — you need a case that says the upstream broke:

WhenOutcome
body.amount > 100errorcard declined
(otherwise)body{"charged": true}

Always give the "otherwise". A mock replaces the block, so there is no real block left to fall through to — and a message matching no case therefore fails the block. That is deliberate: if an unmatched message quietly fell through to the real block, a "mocked" payment call could still reach the network and spend money on a run you believed was stubbed. The editor warns you when the default is missing.

A mock stays on the canvas with its button lit until you turn it off. Unticking "Stand in for this block" keeps the spec but stops applying it, so switching mocking off for one run does not cost you what you wrote.

Every run uses it — the flow's ▶ and a block's run-to-here alike. A run-to-here that quietly ignored your mocks would call the real payment API you thought you had stubbed out.

Watch a block without stopping

A breakpoint answers "what did the message look like here" — once, and then the flow stops. The 👁 button answers the same question without stopping anything: it records every message that crosses the block and lets the flow run on.

A Set Payload block inside a For Each, its watch button lit to show a spy is on it

The eye stays lit while the spy is on, so a block you are watching is obvious without hovering it.

Because it does not stop the flow, a spy sees things a breakpoint cannot:

  • A block that runs many times. Inside a foreach body or a fork branch, a block runs once per item — a spy records every crossing, in order.
  • A block that produced no message at all. A record shows what went in and what came out, and "what came out" includes the two outcomes that are not a message: the block dropped it, or the block failed.

Run the flow and a count appears on the eye. Click it to read what it collected — here, eight crossings of a block inside a foreach, one per item, each showing the message that went in and the one that came out:

The spy popover on a block inside a foreach: eight records, each showing the message that went in and what came out

The heading is the block's addressnew-flow.foreach[body].set-payload, the same path --spies takes on the command line.

Records add up across runs. They are not reset when you run again, because the interesting thing about a spy is usually how this run differs from the last one. Clear empties them when you want a fresh start.

You cannot watch — or break on — a block that sits inside a mocked block. The mock replaced the whole block, and everything under it went with it, so nothing in there can ever run. The editor says so rather than letting the run fail with a confusing "no such block".

The console

The console header with its four tabs: Logs, Problems, Output and Dev .env

Running a single flow uses two tabs that a full Run does not:

  • Problems — everything standing between you and a run: validation issues (a missing required setting, a connection that points at nothing), and the error from the last failed run. Click an issue and the editor selects the block it belongs to. The check is continuous, so the tab is right as you type, not only once you press Run.
  • Output — what your flows produced, newest first: the result message of a full run, or the message captured at a breakpoint, with the flow name and the input it ran with. Only output; no log lines.

The Problems tab showing why the last run failed

The distinction is worth stating plainly: Logs is what the flow said, Output is what it made. A manual run is spawned quietly (errors only), so anything it does print is a problem, and it belongs on the other tab.

A run whose message was filtered — a block returned nothing, so the flow dropped it — is reported as such on Output. Like "never reached", it is a result, not an error.

When you need the command line

The editor covers the common case: one flow, one input, one breakpoint. When you want the full address grammar — breaking inside a loop body, on the nth case of a switch, or inside a flow reached through flow-ref — the CLI does all of it, against the same runtime:

octo invoke --config orders.yaml --flow orders \
  --data '{"orderId": 42}' \
  --break-at 'orders.each-order[body].classify[default].log'

See Debugging a Flow for a worked tour, and the CLI reference for the grammar.

On this page