Conditions and branching
Full operator reference and branching semantics for the Condition node — every operator, truthy/falsy rules, and how skipped branches propagate.
Conditions and branching
The Condition node is a two-way router — it evaluates a group of conditions against upstream data and sends execution down either the true branch or the false branch. system-nodes.mdx describes the inputs, outputs, and config shape at a glance; this page is the deep-dive reference for the full operator catalogue, the coercion rules the executor applies, how skipped branches propagate, and how to express compound logic when a single node isn't enough.
Branching semantics
The engine's Condition handler evaluates the node's ConditionConfig against the resolved upstream context and writes a step_completed event with outputData: { branch: "true" | "false" }. It then walks every outgoing edge from the node — each edge carries a sourceHandle of either "true" or "false" — and does two things for the branch that was not taken:
- Adds every non-taken edge's id to the executor's
skippedEdgeIdsset, so the DAG resolver treats those edges as satisfied rather than pending. This letscascadeSkipkeep moving downstream past the Condition node. - Calls
getBranchNodes(definition, node.id, skippedBranch)to enumerate every descendant reachable only through the pruned branch, and appends astep_skippedjournal event for each one. Those nodes are added tocompletedNodeIdswithout ever being executed.
The executor's output for the Condition node itself is { branch: "true" | "false", matched: <per-condition boolean[]> }. matched is the individual result of each sub-condition before the AND/OR combinator — useful for debugging which row in a multi-condition group fired.
Downstream Merge inputs
When a branch is skipped and a Merge node sits further downstream, the Merge does not wait forever for the pruned input. The Merge handler treats an edge whose branch was skipped as contributing null for that targetHandle, so the join completes as soon as the live branches arrive. See and the Merge section of system-nodes.mdx.
Operators reference
Every row below is an operator declared in. Examples use the field value on the left of the comparison (resolved from upstream context by the field name in your condition row) and the configured value on the right. There are 23 operators total.
Text (8)
All text operators require both operands to be JavaScript strings. If either side is missing or non-string, the operator returns false with no coercion — numbers are not stringified, null/undefined do not match empty string. All text comparisons are case-sensitive.
| Operator | Operand types | Semantics | Example |
|---|---|---|---|
TEXT_CONTAINS | string, string | first.includes(second) | "acme corp" contains "corp" → true |
TEXT_DOES_NOT_CONTAIN | string, string | !first.includes(second) | "acme corp" does not contain "gmbh" → true |
TEXT_EXACTLY_MATCHES | string, string | first === second | "paid" exactly matches "paid" → true |
TEXT_DOES_NOT_EXACTLY_MATCH | string, string | first !== second | "paid" does not exactly match "PAID" → true |
TEXT_STARTS_WITH | string, string | first.startsWith(second) | "+79851234567" starts with "+7" → true |
TEXT_DOES_NOT_START_WITH | string, string | !first.startsWith(second) | "user@acme.com" does not start with "admin@" → true |
TEXT_ENDS_WITH | string, string | first.endsWith(second) | "order.pdf" ends with ".pdf" → true |
TEXT_DOES_NOT_END_WITH | string, string | !first.endsWith(second) | "invoice.pdf" does not end with ".docx" → true |
Number (6)
Number operators coerce both sides through toNumber(): a real JavaScript number passes through (except NaN, which becomes null), a numeric string is parsed via Number(value), and anything else becomes null. If either side coerces to null, the operator returns false. This means "42" compares equal to 42, but true, null, undefined, arrays, and objects all fail the comparison.
| Operator | Operand types | Semantics | Example |
|---|---|---|---|
NUMBER_EQUAL_TO | number|numeric-string | a === b after coercion | "100" equal to 100 → true |
NUMBER_NOT_EQUAL_TO | number|numeric-string | a !== b after coercion | 99 not equal to 100 → true |
NUMBER_GREATER_THAN | number|numeric-string | a > b after coercion | 150 greater than "100" → true |
NUMBER_LESS_THAN | number|numeric-string | a < b after coercion | 50 less than 100 → true |
NUMBER_GREATER_THAN_OR_EQUAL_TO | number|numeric-string | a >= b after coercion | 100 greater than or equal to 100 → true |
NUMBER_LESS_THAN_OR_EQUAL_TO | number|numeric-string | a <= b after coercion | 100 less than or equal to 100 → true |
Boolean (2)
Boolean operators coerce only the left operand and ignore the right. The coercion is deliberately narrow: the literal boolean true and the string "true" both read as true; everything else — including false, "false", "True", 1, 0, null, undefined, arrays, and objects — reads as false. If you need to branch on "yes", "1", or a JSON true inside a string, use TEXT_EXACTLY_MATCHES instead.
| Operator | Operand types | Semantics | Example |
|---|---|---|---|
BOOLEAN_IS_TRUE | any | toBooleanValue(first) === true | true or "true" → true; 1 → false |
BOOLEAN_IS_FALSE | any | toBooleanValue(first) === false | any value that isn't true / "true" → true |
Existence (2)
Existence operators check whether the resolved field path produced a value at all. They use strict null/undefined checks, so an empty string "", the number 0, false, and empty arrays all count as existing.
| Operator | Operand types | Semantics | Example |
|---|---|---|---|
EXISTS | any | first !== null && first !== undefined | "" exists → true; missing field → false |
DOES_NOT_EXIST | any | first === null || first === undefined | missing company field → true |
List (4)
List operators require the left operand to be a JavaScript array. If it isn't — including when the field is missing or is an object — they return false. LIST_CONTAINS / LIST_DOES_NOT_CONTAIN use Array.prototype.includes, which is strict-equality based and does not reach into object members; use FILTER or LOOP for deeper matching.
| Operator | Operand types | Semantics | Example |
|---|---|---|---|
LIST_CONTAINS | array, any | first.includes(second) (strict equality) | ["paid", "trial"] contains "paid" → true |
LIST_DOES_NOT_CONTAIN | array, any | !first.includes(second) | ["paid", "trial"] does not contain "free" → true |
LIST_IS_EMPTY | array | first.length === 0 | [] is empty → true |
LIST_IS_NOT_EMPTY | array | first.length > 0 | ["a"] is not empty → true |
Date (1)
The date operator coerces each side through toDate(): strings and numbers are passed to new Date(), anything else becomes null. Invalid dates (new Date(...) returns NaN time) also become null. If either side is null, the operator returns false. Comparisons are in milliseconds since epoch, so an ISO string and a Unix timestamp compare correctly against each other.
| Operator | Operand types | Semantics | Example |
|---|---|---|---|
DATE_IS_BEFORE | string|number (parseable as Date), same | a.getTime() < b.getTime() | "2026-04-01" is before "2026-05-01" → true |
Truthy, falsy, and coercion rules
A short summary of the rules derived from condition-operators:
- Strings vs. numbers. Only the number operators coerce. Every text operator type-checks both sides as
stringand returnsfalseotherwise. Never rely onTEXT_EXACTLY_MATCHESagainst a number — stringify it upstream with a Set node if needed. nullandundefined. All operators exceptEXISTS,DOES_NOT_EXIST, and the boolean operators returnfalsewhen either relevant operand isnullorundefined.EXISTSreturnstruefor empty strings,0,false, and[]— they are all defined values.- Booleans. The boolean operators accept only literal
trueor the exact string"true"as truthy. This is intentional, to keep webhook payloads that vary betweentrue,"true","yes", and1predictable. - Arrays. List operators require a real array; an object with numeric keys is not an array.
LIST_CONTAINSuses strict equality, so[1, 2, 3]does not contain"1". - Dates. Any value that
new Date()parses to a non-NaNtime is valid. ISO 8601 strings and Unix epoch milliseconds both work; arbitrary formatted strings (for example,"4/1/2026") are parser-dependent and best avoided. - Case sensitivity. Text operators are always case-sensitive. The
ConditionConfigschema accepts an optionalcaseSensitiveflag for forward compatibility, but the operator evaluator does not consult it today. Lowercase both sides with a Set node if you need case-insensitive matching.
Combining conditions
A single Condition node already supports compound logic natively. ConditionConfig carries an array of condition rows plus a combinator of "AND" or "OR":
combinator: "AND"→ the branch is taken only when every row evaluates true (per.every(Boolean)).combinator: "OR"→ the branch is taken when any row evaluates true (per.some(Boolean)).
This covers most two-way branches. When you need more than two destinations, or a mix of AND and OR that one combinator can't express, the product supports two patterns:
- Chained Condition nodes for AND-of-OR. Place one Condition after another on the
truebranch. Each node narrows the population further; anyfalsealong the chain prunes the rest via the normal skipped-edge mechanism. - Switch for N-way routing. When you have three or more mutually exclusive destinations — country, plan tier, event name — reach for the Switch node instead. Switch evaluates its branches in order and takes the first match, with an optional fallback handle. See the Switch section in
system-nodes.mdx. - Parallel Condition nodes for OR-of-AND. If two independent compound predicates each need to trigger the same action, fan out the upstream node to two Condition nodes and join their
truebranches at a Merge before the shared downstream.
Nested boolean logic beyond these patterns is a sign the branch should move into a Code node that returns a simple flag, which a single Condition then routes on.
Example — webhook lead enrichment
A realistic toy scenario that uses the existence operators end to end:
Webhook · lead_captured -- body: { email, name }
→ HTTP Request · GET /enrichment/:email -- output: { company?: string }
→ Condition (combinator: AND,
conditions: [
{ field: "company", operator: EXISTS }
])
│
├── true → HubSpot · Upsert contact (with company)
│ → Slack · "New enriched lead: {{trigger.name}} at {{company}}"
│
└── false → HubSpot · Upsert contact (basic)
→ Slack · "New lead (no enrichment): {{trigger.name}}"The EXISTS row is the key cell in the Existence table — it returns true for any value that is not null or undefined, which is exactly the semantic "the enrichment call returned a company name, even an empty string". If you want to treat "" as missing too, swap EXISTS for TEXT_DOES_NOT_EXACTLY_MATCH with value "", combined with EXISTS under combinator: "AND".
Related
- System nodes — inputs, outputs, and one-line semantics for Condition, Switch, Merge, Loop, and the rest of the flow family.
- Field mapping — how
fieldpaths liketrigger.body.emailand{{nodeId.company}}resolve against upstream outputs. - Error handling — what happens on the live branch if a downstream action fails.