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: "amount >= 100000"
      - label: normal
        expression: "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.

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}"

Once handed over this way, the condition expression uses just the name.

expression: "amount >= 100000"    # ✅ the name alone, no ${}
expression: "${input.amount} >= 100000"   # ❌

Available operators

Operator Meaning Example
== equals status == 'paid'
!= not equal status != 'cancelled'
> >= < <= comparison amount >= 50000
contains contains message contains 'refund'
startsWith starts with orderId startsWith 'ORD-'
endsWith ends with email endsWith '@company.com'
  • Wrap strings in single quotes: 'paid'
  • If both sides convert to numbers it is a numeric comparison; otherwise it is a lexicographic string comparison.

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: "category contains 'refund'"
      - label: delivery
        expression: "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 cannot put a CONDITIONAL inside a loop. (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.

If you want to branch inside a loop

You cannot put a CONDITIONAL inside a loop. The moment one branch is cut off, there is no path left for it to converge on LOOP_END.

Two alternatives.

Alternative 1 — filter before the loop (this is enough most of the time)

- 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}"

Alternative 2 — branch outside the loop

Judge the aggregated result with a CONDITIONAL placed after LOOP_END.


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 No CONDITIONAL in a loop body
Size At most 200 nodes, at most 256,000 characters of YAML

Next07. Managing Connections