Skip to content

Control flow blocks

Control flow blocks decide where the engine goes next. They read from the shared state map, evaluate an expression, and direct execution down the matching branch. They never write to user-visible state and never deny a request on their own — that responsibility belongs to the blocks that follow them.

Every expression in a control flow block can reference any value you can see in the {} picker: event.* context, step.<block-label>.* outputs from earlier blocks, and session.* fields (after a session terminal has run). See Flows concepts — State for the full namespace reference.

Block id: decision  ·  Available in: all flow types

"Branch to one of multiple outbound edges based on the evaluated condition. Pipeline-author writes the condition as a structured expression." Decision is the workhorse for risk routing, conditional MFA, geo-gating, or any situation that needs if/elif/else logic across more than two paths.

Reads from state

  • Any event.*, step.*, or session.* path your expression references

Writes to state

  • branch — the routing key the engine uses to pick the next node; not a named user-visible output

Use when — you need to send high-risk logins to an MFA challenge, route Tor exit-node traffic to a Deny block, or handle three or more distinct outcomes from a single evaluation point. Decision is the right choice when you already know the names of your outbound edges and want the block label to document them clearly.

A typical risk-routing configuration:

when: step.risk-evaluate.severity == "high" → branch: deny
when: step.risk-evaluate.severity == "medium" → branch: challenge
default → branch: allow

Cautions — Decision writes the flat branch key, not a namespaced step.<slug>.* field. You cannot reference step.my-decision.branch from a later block; the routing is internal to the engine's edge resolution.


Block id: condition  ·  Available in: all flow types

"Evaluate a series of CEL-subset expressions against the current state. The first matching case's child blocks execute; if no case matches and a default branch is present its blocks execute instead." Condition is a container block: its child blocks live nested inside it in the editor, and the engine runs them inline rather than as separate graph edges.

Reads from state

  • Any event.*, step.*, or session.* path referenced in the when expressions

Writes to state

  • Nothing directly. Outputs come from the child blocks that execute inside the matching case.

Use when — you want a self-contained if/else section in the middle of a stage without adding separate branch edges. A Condition block keeps related logic visually grouped: the when expression, the blocks that run on match, and an optional default are all in one place. Good for small inline decisions — "if the user's email is not verified, send a verification email; otherwise continue."

Cautions — Condition has no default outbound edge in the graph sense; if no case matches and no default branch is configured, the engine continues to the next sibling block. Make sure your case coverage is intentional.


Block id: switch  ·  Available in: all flow types

"Multi-arm conditional: evaluate cases in order, run the first matching arm's child blocks. No default branch — add a catch-all case (when: true) if a fallthrough is needed." Switch is also a container block. Unlike Condition it has no built-in default; you write a final case with when: "true" if you need unconditional fallthrough.

Reads from state

  • Any event.*, step.*, or session.* path referenced in the when expressions

Writes to state

  • Nothing directly. Outputs come from the child blocks that execute inside the matching arm.

Use when — you have three or more exclusive outcomes based on a single value, such as routing by event.request.country or dispatching different notification templates based on event.client.type. Switch keeps each arm explicit and ordered, which is easier to audit than a deep chain of nested Condition blocks.

Cautions — Switch evaluates cases in declaration order and stops at the first match. If two cases could both match, only the earlier one runs. There is no implicit default; an unmatched switch falls through to the next sibling block. Add a catch-all case if you need to handle the "none of the above" scenario.


If a block earlier in the flow writes a value you want to branch on, but your Decision or Condition expression isn't resolving it, the most common cause is a slug mismatch. The step.* namespace uses the label you assigned to the block in the editor, not the block's type id. Open the block's settings panel and confirm the label matches what you typed in your expression. The {} picker inserts the correct path for you — use it rather than typing step.* paths by hand.