Control Flow Steps
Control-flow steps coordinate when and how other steps run — they branch, gate, loop, retry, pause, and converge execution rather than transforming data. For common fields, string substitutions, metadata filters, caching, and execution order, see the Agent Steps Overview. For data-producing steps (promptcall, text, regex_replace, merge, extract*, evaluate_step), see Core Action Steps.
If / Else Step
Evaluates a set of conditions against the step input and metadata, then runs the then_steps branch on match and the else_steps branch otherwise. Use when you have a single yes/no question and want explicit handling on both sides.
If / Else shares the condition predicate language with Gate. The difference is structural: Gate is "if-without-else" — it gates whether its child_steps run, and there's no opposite-branch path. If / Else carries two distinct subtrees, one for each outcome.
Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
conditions | array | Yes | — | One or more condition objects (target / operator / value), identical to the Gate condition shape. At least one condition required. |
match | enum | No | all | all requires every condition to pass (AND); any requires at least one (OR). |
input_template | string | No | (unset) | Optional template resolved at runtime. When set, conditions targeting input (or input_length) read from this resolved value instead of the step's natural input — useful for dispatching on another step's output, a metadata field, or {{datetime.now}}. Leave unset to use the step's natural input. |
then_steps | array of steps | No | [] | Steps that run when the conditions match. May be empty (no-op fall-through). |
else_steps | array of steps | No | [] | Steps that run when the conditions don't match. May be empty (no-op fall-through). |
child_steps | array of steps | No | [] | Post-branch continuation chain. Runs after the chosen branch completes (or immediately if both branches are empty), receiving the chosen branch's output as input. Always runs regardless of which branch was selected. |
Output
The output of the last step in the executed branch, which then becomes the input to the If / Else step's own child_steps (the post-branch continuation chain). If the executed branch is empty, the If / Else's pass-through input flows directly into child_steps. The step always produces a single trace card with a branch marker indicating which side ran ("then" or "else"); the sibling un-taken branch is skipped.
When to Use If / Else vs Gate vs Switch
If / Else, Gate, and Switch all branch execution on a predicate or value. Pick by outcome shape:
- Gate: "Stop if X" or "only continue if X". One conditional outcome, no alternative — the gate either passes input through to its own
child_stepsor blocks execution. Use when the off-branch is "do nothing". - If / Else: Two distinct outcomes with their own step chains. Use when both branches do useful work — for example, "if the user is premium, send a personalised email; otherwise, send the standard one." Shares the predicate language with Gate, but adds an explicit
else_stepsarm. - Switch: Three or more mutually exclusive outcomes, evaluated against a single discriminator value (typically a classifier label). Chaining nested If / Else more than two levels deep gets noisy fast — move to Switch as soon as the structure becomes "given this label, pick one of N branches".
Use Case Examples
Routing on a content type:
load_content (id: load)
↓
if_else (conditions: [{target: "input_content_type", operator: "$eq", value: "text/html"}])
then_steps:
└─ extract_content (expected_format: "html")
else_steps:
└─ extract_content (expected_format: "json")
child_steps: # post-branch convergence
└─ display_result
Each branch ends with a content-producing step; the single display_result in the if_else's child_steps consumes whichever branch ran. Do not put display_result or streaming_result inside then_steps / else_steps — those are terminal output sinks and a branch can be followed by post-branch steps, which contradicts "terminal". The save-time validator rejects this shape.
Premium-vs-standard email handling:
load_content (id: subscriber)
↓
if_else (conditions: [{target: "metadata.plan", operator: "$eq", value: "premium"}])
then_steps:
├─ prompt_call (draft personalised email)
└─ send_email
else_steps:
└─ send_email (template: standard)
Side-effect-only branches need no convergence step — neither branch produces caller-visible output, so the if_else's child_steps stays empty.
Switch Step
Dispatches on a single runtime-resolved discriminator value to one of N named cases (with an optional else_steps default arm). Use for n-way routing on a classifier label or enum-like value — typically the output of an upstream LLM that emits one of a fixed set of labels.
Switch is mutually exclusive by construction: cases are evaluated top-to-bottom and the first match wins. Authors who need richer per-case logic (range checks, regex matches, multi-field predicates) should move that complexity into an upstream normalize/classify step that emits a single discriminator value, then dispatch on that.
Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
discriminator | string | No | {{input}} | Template that resolves at runtime to the value compared against each case. Supports {{...}} placeholders — commonly {{step.<classifier>.output}}. Leave blank to dispatch on the step's natural input. |
value_type | enum | No | (string) | Type hint used to validate and (for number) coerce case match values before equality matching. number coerces both the discriminator and case match values so "1" matches 1.0. date (YYYY-MM-DD) and datetime (ISO 8601) validate the case match format at save time but compare as strings at runtime — works for exact equality of normalized values, but does not support date-range bucketing (classify upstream into discrete labels for that). Omit (or use string) for plain string equality. |
cases | array | Yes | — | Ordered list of cases. Each case has a name (unique within the switch), a match value, and a steps subtree. At least one case required. |
else_steps | array of steps | No | [] | Default arm. Runs when no case matches. May be empty (no-match = no-op pass-through). |
child_steps | array of steps | No | [] | Post-branch continuation chain. Runs after the chosen case (or else_steps, or immediately on no-match-no-else) completes, receiving the chosen branch's output as input. Always runs regardless of which case was selected. |
Each case object:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display label and trace marker. Must be unique within the switch. |
match | string or string[] | Yes | Single value (equality) or list of values ($in-style). Lump synonyms into a list rather than duplicating cases. |
steps | array of steps | No | The chain that runs when this case matches. May be empty (no-op). |
Output
The output of the last step in the executed case (or else_steps arm), which then becomes the input to the Switch's own child_steps (the post-branch continuation chain). If no case matches and there are no else_steps, the Switch's pass-through input flows directly into child_steps. The trace card carries a branch marker naming the matched case (or "else" / null for no-match); unmatched sibling cases are skipped.
When to Use Switch vs If / Else vs Gate
Switch, If / Else, and Gate all branch execution. Pick by the shape of what you're branching on:
- Gate: A single yes/no predicate where the "no" path is "do nothing". No alternative branch.
- If / Else: Two outcomes, evaluated against a predicate. Use when the question is structural ("did X happen?", "is the input in shape Y?") and both branches do useful work.
- Switch: Three or more mutually exclusive outcomes, evaluated against a single discriminator value (typically a classifier label or enum). Use when an upstream step has already reduced the input to one of a fixed set of labels.
Avoid nesting If / Else more than two levels deep — it's almost always cleaner to introduce a classify step upstream and use a Switch.
Use Case Examples
Classifier-driven message routing:
prompt_call (classify message → "urgent" | "normal" | "spam", id: classify)
↓
switch (discriminator: "{{step.classify.output}}")
cases:
- name: "urgent"
match: "urgent"
steps:
├─ send_email (recipient: oncall)
└─ text (template: "Paged on-call about: {{agent.input}}")
- name: "normal"
match: "normal"
steps:
└─ prompt_call (draft standard response)
- name: "spam"
match: ["spam", "junk", "promotional"]
steps: [] # silent filter — no-op
else_steps:
└─ text (template: "Unrecognised: {{agent.input}}")
child_steps: # post-branch convergence
└─ display_result
Each populated branch ends with a content-producing step; the single display_result in the switch's child_steps consumes whichever branch ran (the spam case is empty, so a spam classification produces a no-op pass-through into the display). display_result / streaming_result are never placed inside cases[].steps or else_steps — the save-time validator rejects that shape.
Routing on a metadata enum:
load_content (id: load)
↓
switch (discriminator: "{{metadata.content_type}}")
cases:
- name: "html"
match: "text/html"
steps:
└─ extract_content (expected_format: "html")
- name: "json"
match: "application/json"
steps:
└─ extract_content (expected_format: "json")
else_steps:
└─ regex_replace (strip noise from plain text)
For Each Step
Iterates a body of steps over a runtime-resolved list, producing one body execution per item. Use when the workflow needs per-item visibility in the trace, per-item retry or governance, or per-item side effects such as N emails, N webhook calls, or N metadata writes.
For Each is part of the Control family along with Gate, Join, Retry, If / Else, Switch, and Human-in-the-Loop — it controls how the body is scheduled, not what data flows through it. Merge is a passive aggregator and lives in Core Action Steps.
Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
input_template | string | Yes | — | Template that resolves at runtime to either a JSON array (always valid) or a JSON integer ordinal (only valid when limit is also set). Supports {{...}} placeholders. |
offset | integer | No | 0 | 0-based start index. If the resolved input has fewer than offset items, the loop behaves as empty (and either succeeds with an empty array output or fails when fail_on_empty is true). |
limit | integer | No | (none) | Upper exclusive index bound. If both offset and limit are set, offset MUST be strictly less than limit. Required when input_template resolves to an integer ordinal. |
parallel | boolean | No | false | Run iterations concurrently subject to system-level concurrency bounds. The output array preserves input order regardless of completion order. |
fail_fast | boolean | No | true | First iteration that fails aborts remaining iterations and the For Each step itself fails. When false, failed iterations record null placeholders in the output array and the loop continues. |
fail_on_empty | boolean | No | false | When true, the step fails if the resolved input yields zero iterations. When false (default), zero iterations succeed and emit an empty array. |
iterate_attachments | boolean | No | false | When true, the loop iterates over the attachments of a multi-asset manifest input instead of an array template. input_template is ignored in this mode. Each iteration's item is the attachment metadata ({storage_key, mime, name}). |
attachment_rules | list | No | null | Optional list of attachment rules narrowing which manifest attachments the loop iterates over. Only meaningful when iterate_attachments is true (combines with it: rules filter the manifest first, then the loop iterates the filtered subset). |
Output
A JSON array of per-iteration outputs (the final body step's output for each iteration). The array is always in input order, regardless of parallel completion order. With fail_fast: false, failed iterations appear as null placeholders so positions match the input.
Iteration Variables
Inside the body, reference the current iteration via the For Each step's id:
{{step.<for_each_step_id>.item}}— the current iteration's item value{{step.<for_each_step_id>.item_index}}— the 0-based iteration index
For nested For Each loops, each enclosing loop is addressed by its own step_id — there is no implicit shadowing. The string-substitution toolbar in the agent editor lists every enclosing loop so you can reference outer-loop variables explicitly.
Body Restrictions
Validated at definition time:
- The body MUST contain at least one step. An empty body would iterate without doing anything.
display_resultandstreaming_resultare NOT allowed inside a For Each body. Multiple iterations would each produce a separate display payload, leaving the UI in an ambiguous state. Place those steps after the For Each completes.human_in_the_loopis NOT allowed inside a For Each body either — each iteration would park independently and the resumption path resolves step runs byagent_step_idonly, with no way to disambiguate which iteration's parked step a vote refers to.call_agentIS allowed — one agent invocation per iteration.- Nested For Each is allowed with no depth limit. Reference the outer loop's item via its step_id.
When to Use For Each vs Pattern E (model-driven iteration)
The runtime offers two distinct iteration approaches. Picking the right one matters because they produce different trace shapes, cost profiles, and governance surfaces.
Use For Each when:
- Per-iteration trace cards matter for debugging or operations.
- Each item needs its own governance evaluation, retry budget, or evaluation criterion.
- Iteration drives side effects (send an email per record, write a metadata entry per item, call a child agent per row).
- The iteration count or item ordering is part of the workflow's structural contract.
Use Pattern E (a single prompt_call with seclai_web_tools) when:
- Iteration is part of model reasoning — open-ended research, "summarise each topic", "fetch and review each link".
- The model should decide how many items to process or which to skip.
- No per-iteration visibility or side effects are required — one combined model response is acceptable.
A single prompt_call that processes the whole list at once (without For Each and without seclai_web_tools) is the failure shape both patterns are designed to replace.
Input Types: Array vs Integer Ordinal
After string substitution, the resolved input_template must be either:
-
A JSON array. Each element becomes a per-iteration item. The body references the element via
{{step.<for_each_step_id>.item}}. Always valid. -
A JSON integer. Only valid when
limitis also set. The loop iterates indices in[offset, min(integer_value, limit))and the body's{{step.<for_each_step_id>.item}}resolves to the index value itself. Useful for "do X N times" where you have a count but no list.
Strings that parse as either of the above are accepted; whitespace is tolerated. Booleans, floats, objects, and unparseable strings cause the step to fail with a runtime error.
Offset and Limit Semantics
limit is an upper exclusive index bound, not a count. Examples
for a 200-item input:
offset: 0, limit: 5→ process indices 0..4 (5 items)offset: 2, limit: 5→ process indices 2..4 (3 items)offset: 10, limit: 5→ invalid (rejected at definition time;offsetmust be <limitwhen both set)offset: 0, limit: 500→ process all 200 items (limit caps at actual length)offset: 250, limit: null→ empty iteration (offset exceeds actual length)offset: null, limit: null→ process all items
When the iteration list is empty, fail_on_empty determines whether the step succeeds with an empty array output or fails.
Use Case Examples
Top-5 trending topics summariser:
prompt_call (extract trending topics → JSON array, id: extract)
↓
for_each (id: loop, input_template: "{{step.extract.output}}", limit: 5)
body:
├─ web_search (query: "{{step.loop.item}} latest", limit: 3)
└─ prompt_call (summarise {{step.loop.item}} from search results)
↓
prompt_call (combine 5 summaries into a digest)
↓
display_result
Send N reminder emails (integer ordinal input):
prompt_call (decide how many reminders to send, id: count)
↓
for_each (id: loop, input_template: "{{step.count.output}}", limit: 10)
body:
└─ send_email (to: "user@example.com", subject: "Reminder {{step.loop.item_index}}")
item_indexis 0-based, so the rendered subjects are "Reminder 0", "Reminder 1", … "Reminder N-1". If you want 1-based output ("Reminder 1", …), add aregex_replacestep between the index reference and the subject, or compute the number in the upstreamprompt_calland pass it through.
Per-row enrichment with per-item governance:
load_content (id: load)
↓
extract_content (parse JSON rows, id: parse)
↓
for_each (id: loop, input_template: "{{step.parse.output}}", parallel: true)
body:
├─ prompt_call (classify {{step.loop.item}})
└─ write_metadata ({{step.loop.item}}, key: "classification")
Each iteration runs through the agent's input/output governance pipeline independently.
Trace View
The agent trace graph unfurls each For Each step into its iteration runs, so every per-iteration step execution is visible. Iterations are grouped by the For Each's id and indexed by iteration_index. Retries inside a body iteration are also visible — both the original and the retried step runs appear in the graph.
Retry Step
Re-executes the workflow from a specified ancestor step up to a configurable number of times. When the retry step completes, the runner resets the target step and all its descendants back to pending and re-schedules the target. This continues until the maximum retry count is reached, at which point execution proceeds normally past the retry step.
Related steps: Gate makes retries conditional — place a gate before the retry to check output quality and only retry when the result is unsatisfactory. Evaluate scores the output that a gate inspects before triggering a retry.
Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
target_step_id | string | Yes | — | The id of an ancestor step in the parent chain to re-execute from. Must be alphanumeric (including hyphens and underscores). |
max_retries | integer | Yes | — | Maximum number of times the target will be re-executed (1–10). |
How It Works
- The retry step is a non-composite leaf step — it cannot contain child steps.
- Each time the retry step completes, the runner checks how many times it has already completed.
- If the count is within
max_retries, the target step and all its descendants are reset to pending and the target is re-scheduled. - If the count exceeds
max_retries, execution continues normally — the retry has been exhausted.
Best Practice: Pair with a Gate
Without a gate, a retry step will unconditionally re-run its target up to max_retries times. To make retries conditional, place a gate step between the output-producing step and the retry:
retrieval (target for retry)
└─ prompt_call
└─ gate (evaluates output quality)
└─ retry (target_step_id: retrieval, max_retries: 3)
- If the gate's conditions pass (
on_match: "continue"), child execution proceeds — including the retry step, which will unconditionally re-run the target (up tomax_retriestimes). Useon_match: "stop"with success conditions instead (see below). - If the gate blocks (
on_match: "stop"and conditions match, oron_match: "continue"and conditions fail), child steps including retry are not reached, and execution stops.
To implement conditional retry effectively:
- Use
on_match: "stop"on the gate, with conditions that detect a good result (e.g., output is not empty, contains expected keywords). - When the output is satisfactory, the gate stops — blocking the retry step.
- When the output fails the conditions, the gate passes through, and the retry step triggers re-execution.
Use Case Examples
Retry a failed retrieval up to 3 times:
target_step_id: search-knowledge-base
max_retries: 3
Quality-gated retry for LLM output:
prompt_call (id: generate-report)
└─ gate (conditions: [{target: "input", operator: "$not_empty"}], on_match: "stop")
└─ retry (target_step_id: generate-report, max_retries: 2)
If the prompt call produces non-empty output, the gate stops and the retry is never reached. If the output is empty, the gate passes through and retry re-executes the prompt call.
Human-in-the-Loop Step
Pauses the agent run and asks one or more humans to make a decision before continuing. Each pending decision shows up in the Approvals inbox at /app/<account>/hitl and is also delivered as a signed deep-link email. The chosen outcome becomes this step's output, which downstream if_else, switch, or gate steps route on — the Human-in-the-Loop step never branches internally.
For a conceptual overview of how HITL fits into agent design, see the Human-in-the-Loop concept page.
Fields
prompt_template— required string. Renders at runtime against the step's input and the agent's execution context. Reference upstream outputs with{{step.<id>.output}}so the human sees the data they're deciding on.recipient_distribution— who to notify.owner(just the agent owner),owner_admins(owner plus org admins; the default), orselected_members(a hand-picked user list inrecipient_user_ids).choices— labels the human can pick from. Defaults to["approve", "deny"]. Custom choices express multi-way decisions like["ship_it", "needs_revision", "abandon"]. The reserved labelstimeout,no_quorum, andcancelledare emitted as sentinel outcomes and may not appear inchoices.required_approvals— votes for the same choice that resolve the request. Default of 1 is "first responder wins"; higher values are M-of-N change-management gates.timeout_seconds— optional. When set, the janitor expires the request after this many seconds. Leave blank to wait indefinitely.timeout_outcome— on expiry, eitheremit_timeout(writetimeoutas the step output and continue, the default) orfail_run(mark the entire run FAILED).reminder_interval_seconds+reminder_max_count— optional reminder cadence for unresolved requests. Requirestimeout_seconds; we never remind indefinitely.
Output shape
When the request resolves (quorum reached, every recipient voted with no majority, timeout fired, or an operator cancelled), the step output is a JSON object:
{
"outcome": "approve",
"quorum_met": true,
"required": 2,
"total_recipients": 5,
"votes": [
{
"user_id": "...",
"choice": "approve",
"comment": "lgtm",
"decided_at": "2026-05-25T10:42:11Z"
}
]
}
outcome is the winning choice label, or one of the sentinels
timeout / no_quorum /
cancelled. Downstream gates read
{{step.<hitl_id>.output.outcome}}.
When the outcome is cancelled and the operator supplied a reason (via the inbox or the cancel_hitl_request MCP tool), the payload also carries a cancellation_reason string — readable downstream via {{step.<hitl_id>.output.cancellation_reason}}.
Placement
Human-in-the-Loop is rejected anywhere under a for_each.body[] — per-iteration step runs are keyed by iteration_index, and the resumption path resolves step runs by agent_step_id only. Place the Human-in-the-Loop step before or after the loop instead.
Billing
Charged the EMAIL credit rate per sent notification — recipients in per-user email cooldown (configured at /app/settings/email) still see the pending request in the inbox but cost nothing. No runtime charge while the run is parked; the passive wait time is excluded from agent runtime everywhere we report duration.
Use Case Examples
Draft email gated on human approval:
prompt_call (id: draft_email, generates an email body)
↓
human_in_the_loop (id: review_email,
prompt: "Approve sending this email:\n{{step.draft_email.output}}",
recipient_distribution: owner_admins,
timeout_seconds: 3600)
↓
if_else (id: route_decision,
input_template: "{{step.review_email.output.outcome}}",
conditions: [{operator: equals, value: "approve"}])
├─ then_steps:
│ └─ send_email (uses the draft email body)
└─ else_steps:
└─ display_result (notifies the agent owner the email was rejected)
Recipients in per-user cooldown (configured at /app/settings/email) still see the pending request in the inbox — only the email is suppressed.
HITL only when an evaluator flags low confidence:
Most agent outputs don't need a human in the loop — only the borderline ones. Combine evaluate_step (LLM-as-judge scoring) with an if_else so the workflow auto-approves high-confidence answers and only parks for human review when the score falls below a threshold:
prompt_call (id: draft_reply, generates a customer reply)
↓
evaluate_step (id: score_reply,
target_step_id: draft_reply,
evaluation_prompt: "Score 0-1 on clarity, accuracy, and tone.",
pass_threshold: 0.85)
↓
if_else (id: needs_review,
input_template: "{{step.score_reply.output.passed}}",
conditions: [{target: "input", operator: "$eq", value: "false", value_type: "boolean"}])
├─ then_steps (score below threshold → escalate to a human):
│ └─ human_in_the_loop (id: review_low_confidence,
│ prompt: "The model rated this reply {{step.score_reply.output.score}} (threshold 0.85). Approve?\n\nReply:\n{{step.draft_reply.output}}\n\nWhy:\n{{step.score_reply.output.explanation}}",
│ recipient_distribution: owner_admins,
│ timeout_seconds: 1800,
│ timeout_outcome: emit_timeout)
│ ↓
│ if_else (id: route_decision,
│ input_template: "{{step.review_low_confidence.output.outcome}}",
│ conditions: [{operator: "$eq", value: "approve"}])
│ ├─ then_steps:
│ │ └─ send_email (uses {{step.draft_reply.output}})
│ └─ else_steps:
│ └─ display_result (notifies the agent owner the reply was held back)
└─ else_steps (score met threshold → ship it without a human in the loop):
└─ send_email (uses {{step.draft_reply.output}})
Why this pattern works:
- Cost. Every HITL request costs at least one email credit and consumes human attention. Gating on
evaluate_step.passedtypically means only 5–20 % of runs reach a person — the rest auto-approve. - Latency. High-confidence runs complete in seconds. Only low-confidence runs incur the park-wait-resume cycle.
- Audit. The score, explanation, and final outcome all live on the agent run, so it's easy to slice "auto-approved", "human-approved", "human-denied", and "timeout" cohorts in traces.
Common variations:
- Use
evaluate_step.score < 0.5(viainput_template: "{{step.score.output.score}}"and a numeric$lt) to introduce a second tier — "auto-approve", "human review", and "reject without asking". - Swap
evaluate_stepfor aprompt_callclassifier when you need a discrete bucket label (e.g."safe" | "needs_review" | "block"); branch with aswitchinstead ofif_else. - Combine with
timeout_outcome: fail_runwhen an absent human response should fail the run rather than fall through to an auto-approve branch.
Gate Step
Evaluates conditions against the step's input to decide whether subsequent steps should execute. A gate acts as a conditional branch point — when conditions are met (or not met, depending on configuration), the gate either passes the input through or blocks execution.
Gates support three common branching patterns:
- If/else — Two parallel gates with opposite conditions (e.g.,
intent = questionandintent ≠ question). Exactly one fires per input, routing execution down the matching branch. A join on one branch and a merge recombine the result. - Switch — A row of parallel gates, each checking one mutually exclusive case. Exactly one gate fires, directing input to a dedicated processing branch. Join steps on n−1 branches feed a merge that combines the result.
- Parallel non-exclusive — Multiple gates run independently in parallel, each guarding a different branch. Unlike if/else and switch, these gates are not mutually exclusive — any combination of branches can fire depending on which conditions are met.
Related steps: Join and Merge recombine branches created by gates. Retry pairs with a gate to implement conditional retry logic. Evaluate scores output that a gate can branch on.
When to Use Gate vs If / Else vs Switch
Gate, If / Else, and Switch all branch execution on a predicate or value. Pick by outcome shape:
- Gate: A single yes/no predicate where the off-branch is "do nothing". Gate is "if-without-else" — it gates whether its
child_stepsrun, and the no-path is implicit (execution simply stops on that branch). Use when you only need one conditional outcome. - If / Else: Two distinct outcomes, both doing useful work. Use when "if condition then A else B" — both branches produce output. Shares the predicate language with Gate but adds an explicit
else_stepsarm and a post-branchchild_stepsconvergence chain. - Switch: Three or more mutually exclusive outcomes, evaluated against a single discriminator value. Use when an upstream step has already reduced the input to a label or enum. Avoid expressing this pattern as multiple parallel gates — Switch is purpose-built for n-way dispatch and produces a clearer trace.
The "parallel gates" if/else and switch patterns described below predate the dedicated If / Else and Switch steps; new agents should prefer the dedicated steps.
Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
conditions | array | Yes | — | The list of conditions to evaluate |
match | enum | No | all | How to combine condition results: all (AND — every condition must match) or any (OR — at least one must match) |
on_match | enum | No | continue | What to do when conditions are met: continue (pass input through) or stop (block execution, output is empty) |
Each condition has:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
target | string | Yes | — | What to evaluate. Built-in targets: input (step input text), input_length (character count), input_content_type (MIME type). Also supports metadata.<field> for metadata values. |
operator | enum | Yes | — | The comparison operator (see below) |
value | any | No | null | The value to compare against. Supports substitution variables. |
value_type | enum | No | null | How to interpret the value: number, date, datetime, or relative_time |
comment | string | No | null | A description of what this condition checks |
Condition Operators
| Operator | Description | Example |
|---|---|---|
$eq | Equals | target == value |
$ne | Not equals | target != value |
$lt | Less than | target < value |
$lte | Less than or equal | target <= value |
$gt | Greater than | target > value |
$gte | Greater than or equal | target >= value |
$in | In list | target in [value1, value2, ...] |
$nin | Not in list | target not in [value1, value2, ...] |
$regex | Matches regex pattern | re.search(value, target) |
$not_regex | Does not match regex | not re.search(value, target) |
$empty | Is empty or null | target is None or target.strip() == "" |
$not_empty | Is not empty | target is not None and target.strip() != "" |
For $lt, $lte, $gt, $gte: numeric comparison is attempted first; if both values are not numeric, string comparison is used.
Value Types
The value_type field controls how the comparison value is interpreted:
| Value Type | Description | Example Values |
|---|---|---|
number | Treat as a numeric value | 100, 3.14 |
date | Treat as a date | 2026-02-17 |
datetime | Treat as a datetime | 2026-02-17T14:30:00 |
relative_time | Parse as a relative time expression | now, today, yesterday, 3 days ago, 1 week ago, 2 hours ago, 5 days from now |
Relative time expressions are resolved at execution time. Supported expressions include:
now— Current datetimetoday— Start of todayyesterday— Start of yesterdaythis week/last week— Start of current/previous weekN days ago/N hours ago/N minutes agoN days from now/N hours from now
Use Case Examples
Only process non-empty input:
| Condition | Target | Operator | Value |
|---|---|---|---|
| 1 | input | $not_empty | — |
Match: all, On match: continue
Filter by content length (skip short content):
| Condition | Target | Operator | Value | Value Type |
|---|---|---|---|---|
| 1 | input_length | $gt | 100 | number |
Match: all, On match: continue — Only continue if input is longer than 100 characters.
Route by category (only process technology articles):
| Condition | Target | Operator | Value |
|---|---|---|---|
| 1 | metadata.category | $eq | technology |
Block specific content types:
| Condition | Target | Operator | Value |
|---|---|---|---|
| 1 | input_content_type | $in | ["text/html", "application/xml"] |
Match: all, On match: stop — Block HTML and XML content from proceeding.
Time-based gating (only process recent content):
| Condition | Target | Operator | Value | Value Type |
|---|---|---|---|---|
| 1 | metadata.published_date | $gt | 7 days ago | relative_time |
Match: all, On match: continue — Only process content published in the last 7 days.
Complex multi-condition gate:
| Condition | Target | Operator | Value |
|---|---|---|---|
| 1 | input | $not_empty | — |
| 2 | input_length | $gt | 50 |
| 3 | metadata.status | $ne | draft |
| 4 | metadata.language | $in | ["en", "es", "fr"] |
Match: all, On match: continue — All four conditions must be true to proceed.
If/else — two mutually exclusive gates:
Two gates run in parallel with opposite conditions — one checks intent = question, the other checks intent ≠ question. Exactly one gate fires per input, routing execution down the matching branch. A join on one branch and a merge recombine the result.
Switch — parallel mutually exclusive gates for multi-way routing:
A row of parallel gates, each checking one case (topic = tech, topic = finance, topic = general). The conditions are mutually exclusive so exactly one gate fires per input. Each gate leads to its own processing branch, and join steps on n−1 branches feed a merge that combines the result.
Parallel non-exclusive gates:
Multiple gates run independently in parallel, each guarding a separate branch. Unlike if/else and switch, these are not mutually exclusive — any combination of branches can fire. A merge with join steps combines whichever results are produced.
AI Assistant
The AI assistant can generate gate conditions for you. Describe the filtering logic you need (e.g., "only process articles longer than 200 characters about technology"), and it will create the appropriate conditions with the correct operators and match mode.
Join Step
Connects a parallel branch to a Merge step. The join step is a transparent relay — its output is identical to its input. Its purpose is to signal to the merge that a branch has completed.
Related steps: Gate creates the parallel branches that need joining. Merge receives the joined outputs and combines them.
Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
target | string | Yes | — | The ID of the merge step this join feeds into. Must match ^[a-zA-Z0-9_-]+$. |
Join steps cannot have child steps.
Use Case Example
See the Merge step section for a complete example of how join steps connect parallel branches to a merge.
Attachment rules config field (For Each)
When iterate_attachments is enabled, attachment_rules narrows which attachments from the upstream multi-asset manifest the loop iterates over. null / empty list defaults to "every attachment in the manifest". The rule shape is identical to the structured Attachment rules config field used by the consumer steps.
| Combination | Behaviour |
|---|---|
iterate_attachments: false (default) | Loop iterates input_template; attachment_rules is ignored |
iterate_attachments: true + attachment_rules: null | Loop iterates every attachment in the input manifest |
iterate_attachments: true + [{source:"input", kind:"pattern", pattern:"*.pdf"}] | Loop iterates only the PDFs |
iterate_attachments: true + [{source:"agent", kind:"all"}] | Loop iterates the trigger uploads instead of the immediate input manifest |
iterate_attachments: true + [{source:"step", step_id:"gen", kind:"pattern", pattern:"*.png"}] | Loop iterates PNGs emitted by a specific upstream step |
Each iteration's item is the attachment metadata ({storage_key, mime, name}) — body steps reference these via {{step.<for_each_id>.item.name}} etc.
Legacy agents that used the old single-string attachments selector auto-migrate to a one-rule list on load.