Skip to content

06. Flow Control

For when an automation that runs in a straight line is not enough.

What you want Node to use
Take a different path depending on a value CONDITIONAL
Bring split paths back together JOINT
Process an array one item at a time LOOP_START / LOOP_END

Branching — CONDITIONAL

Looks at a value and picks which way to go.

- id: route
  name: Branch by amount
  type: CONDITIONAL
  execution-info:
    conditions:
      - label: vip
        expression: "${input.amount} >= 100000"
      - label: normal
        expression: "${input.amount} >= 10000"
      - label: small
        otherwise: true          # if none of the above match, come here

edges:
  - from: route
    to: vip-handler
    label: vip                   # ← label says which branch this is
  - from: route
    to: normal-handler
    label: normal
  - from: route
    to: small-handler
    label: small

Three key rules.

  1. Conditions are checked from the top. Only the first match runs.
  2. Every label must have exactly one outgoing edge. Without it the save is rejected.
  3. otherwise: true is the default branch taken "when nothing above matches". Including one is recommended.
  4. Operands are either ${...} expressions or literals. A bare name is not a value — it is the literal text of that name (below).

The values a condition sees must come over an edge

⚠️ Condition expressions are evaluated in the ${input.*} scope. If the incoming edge has no request.data, the condition can see no values at all.

edges:
  - from: fetch-order
    to: route
    request:
      data:
        amount: "${nodes.fetch-order.response.body.totalAmount}"   # ← this has to be here
        status: "${nodes.fetch-order.response.body.status}"

⚠️ Condition expressions still use ${input.*}

Even after handing the value over, do not write a bare name in the condition. eeumsae splits the expression on the operator first, then resolves each operand as an expression. A bare name with no ${...} is not an expression to resolve — it becomes the literal string "amount".

expression: "${input.amount} >= 100000"   # ✅ reads the value the edge handed over
expression: "amount >= 100000"            # ❌ compares the string "amount" against 100000

Why the ❌ form is dangerous: it does not raise an error. The condition silently evaluates to false every time, so every run falls through to otherwise. If a branch "always goes the same way", start here.

Available operators

Operator Meaning Example
== equals ${input.status} == 'paid'
!= not equal ${input.status} != 'cancelled'
> >= < <= comparison ${input.amount} >= 50000
contains contains ${input.message} contains 'refund'
startsWith starts with ${input.orderId} startsWith 'ORD-'
endsWith ends with ${input.email} endsWith '@company.com'
  • Wrap string literals in single quotes: 'paid'
  • If both sides convert to numbers it is a numeric comparison; otherwise it is a lexicographic string comparison.
  • Where a value may be missing you can attach a fallback: expression: "${input.status | \"pending\"} == 'pending'" → 05. Connecting Data with Expressions

Branching on an AI answer

contains is especially useful for splitting on what the AI replied.

- id: classify
  name: Classify the inquiry
  type: CALL
  integration: llm_chat
  input:
    apiContract: OPENAI_CHAT
    model: gpt-4o-mini
    apiKey: "${secrets.OPENAI_API_KEY}"
    systemPrompt: "Classify the customer inquiry with exactly one word: refund / delivery / product / other. Say nothing else."
    userPrompt: "${nodes.trigger.response.body.message}"

- id: route
  name: Branch by category
  type: CONDITIONAL
  execution-info:
    conditions:
      - label: refund
        expression: "${input.category} contains 'refund'"
      - label: delivery
        expression: "${input.category} contains 'delivery'"
      - label: other
        otherwise: true

edges:
  - from: classify
    to: route
    request:
      data:
        category: "${nodes.classify.response.body.content}"
  - from: route
    to: refund-flow
    label: refund
  - from: route
    to: delivery-flow
    label: delivery
  - from: route
    to: default-flow
    label: other

The trick is to nail the AI down in systemPrompt so that it answers with exactly one word. It still will not be perfect, so always keep an otherwise branch.


Joining — JOINT

The spot where split paths meet again. It is the only node that may take several incoming edges.

- id: join
  name: Join
  type: JOINT

There is nothing to configure. Writing type is the whole thing.

What it does

It waits until every incoming branch has finished. Once they all have, it moves on to the next node. Use it when you want to call two APIs at the same time and process the results only after both have arrived.

             ┌─→ [Fetch orders] ─┐
[Trigger] ─→ ┤                   ├─→ [JOINT] ─→ [Summarize using both]
             └─→ [Fetch stock]  ─┘
edges:
  - from: trigger
    to: fetch-orders
  - from: trigger
    to: fetch-stock
  - from: fetch-orders
    to: join
  - from: fetch-stock
    to: join
  - from: join
    to: summarize

⚠️ A JOINT has no output of its own

A JOINT only waits. It does not produce a value. In the node after it, reference the node before the JOINT directly.

# ❌
userPrompt: "${nodes.join.response.body.data}"

# ✅
userPrompt: |
  Orders: ${nodes.fetch-orders.response.body.data}
  Stock:  ${nodes.fetch-stock.response.body.data}

Looping — LOOP_START / LOOP_END

Takes an array and repeats the same processing for each element.

- id: each-order
  name: Start per-order loop
  type: LOOP_START
  execution-info:
    items: "${nodes.fetch-orders.response.body.data | raw}"   # ← the array to iterate

# ─── the loop body starts here ───

- id: notify-each
  name: Notify per order
  type: CALL
  integration: http_request
  input:
    uri: "https://api.example.com/notify"
    method: POST
    body:
      orderId: "${item.orderId}"      # ← the current element
      sequence: "${index}"            # ← the iteration number, starting at 0

# ─── end of the loop body ───

- id: collect
  name: Collect loop results
  type: LOOP_END
  execution-info:
    loop-start: each-order            # ← the id of the matching LOOP_START

Rules

  1. LOOP_START and LOOP_END always come in pairs. With only one of them, the save is rejected.
  2. Always attach | raw to items. Without it the array becomes a string and nothing iterates.
  3. Every path in the loop body has to converge on LOOP_END.
  4. You can put a CONDITIONAL inside a loop — as long as the branches reconverge through a JOINT before LOOP_END. (See below.)

Values available inside a loop

Expression Meaning
${item} The whole current element
${item.field} A specific field of the current element
${index} Which iteration this is (starting at 0)

These values are valid only between LOOP_START and LOOP_END.

When loops are nested — ${loops.*}

${item} / ${index} refer to the innermost loop. To reach an outer element from inside a nested loop, name the layer by its LOOP_START id.

${loops.each-order.item}       # the current element of the outer loop each-order
${loops.each-order.index}      # the outer loop's iteration number
${item}                        # the inner loop's current element

If you are not actually inside that loop, the run fails.

Branching inside a loop

You can put a CONDITIONAL in a loop body. The branches must reconverge through a JOINT before LOOP_END — LOOP_END still takes exactly one incoming edge.

[LOOP_START] → [route] ─┬─(vip)───→ [VIP handling] ─┐
                        └─(normal)→ [normal handling]┴→ [JOINT] → [LOOP_END]
- id: each-order
  type: LOOP_START
  execution-info:
    items: "${nodes.fetch-orders.response.body.data | raw}"

- id: route
  name: Branch by amount
  type: CONDITIONAL
  execution-info:
    conditions:
      - label: vip
        expression: "${input.amount} >= 100000"
      - label: normal
        otherwise: true

- id: vip-handle
  type: CALL
  integration: http_request
  input: { uri: "https://api.example.com/vip", method: POST, body: { id: "${item.orderId}" } }

- id: normal-handle
  type: CALL
  integration: http_request
  input: { uri: "https://api.example.com/normal", method: POST, body: { id: "${item.orderId}" } }

- id: rejoin
  name: Reconverge
  type: JOINT

- id: collect
  type: LOOP_END
  execution-info:
    loop-start: each-order

edges:
  - from: each-order
    to: route
    request:
      data:
        amount: "${item.amount}"        # ← hand the condition its value over the edge
  - from: route
    to: vip-handle
    label: vip
  - from: route
    to: normal-handle
    label: normal
  - from: vip-handle
    to: rejoin
  - from: normal-handle
    to: rejoin
  - from: rejoin
    to: collect

Nodes on the branch that was not taken do not run; they are recorded as SKIPPED in the run history. JOINT and LOOP_END accept SKIPPED as "finished", so that iteration completes normally.

If you only need to pick which items to process, filtering before the loop is simpler.

- id: filter
  name: Keep only what needs processing
  type: CALL
  integration: transform_jmespath
  input:
    expression: "{targets: orders[?status == `paid`]}"
    data: "${nodes.fetch-orders.response.body | raw}"

- id: each
  type: LOOP_START
  execution-info:
    items: "${nodes.filter.response.body.targets | raw}"

The graph rules, collected

These are checked when you save. Break one and the save is rejected, with a message about what went wrong.

Rule Detail
Start There is exactly one ENTRYPOINT, with 0 incoming edges
Multiple inputs Only JOINT may take two or more incoming edges
No cycles Following the arrows must never return you to where you started
Node ids Must be unique within the workflow
Edge references from / to must point at nodes that actually exist
Branching Every label must have an outgoing edge
Loops LOOP_START and LOOP_END must pair up, and the body must converge on LOOP_END
Branching in loops CONDITIONAL is allowed, but must reconverge through a JOINT before LOOP_END
Size At most 200 nodes, at most 256,000 characters of YAML

Next → 07. Managing Connections