Documentation

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

FieldTypeRequiredDefaultDescription
conditionsarrayYesOne or more condition objects (target / operator / value), identical to the Gate condition shape. At least one condition required.
matchenumNoallall requires every condition to pass (AND); any requires at least one (OR).
input_templatestringNo(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_stepsarray of stepsNo[]Steps that run when the conditions match. May be empty (no-op fall-through).
else_stepsarray of stepsNo[]Steps that run when the conditions don't match. May be empty (no-op fall-through).
child_stepsarray of stepsNo[]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_steps or 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_steps arm.
  • 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.

thenelseTriggerPrompt Callclassify intentIf / Elseintent == 'question'Prompt CallanswerPrompt CallsummariseDisplay Resultpost-branch child
Figure 36.If / Else — a classifier feeds the If / Else step, exactly one branch (then or else) runs, and the chosen branch's output flows into the post-branch child step (display_result) that always runs after.

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

FieldTypeRequiredDefaultDescription
discriminatorstringNo{{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_typeenumNo(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.
casesarrayYesOrdered 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_stepsarray of stepsNo[]Default arm. Runs when no case matches. May be empty (no-match = no-op pass-through).
child_stepsarray of stepsNo[]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:

FieldTypeRequiredDescription
namestringYesDisplay label and trace marker. Must be unique within the switch.
matchstring or string[]YesSingle value (equality) or list of values ($in-style). Lump synonyms into a list rather than duplicating cases.
stepsarray of stepsNoThe 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.

techfinanceelseTriggerPrompt Calltopic labelSwitch{{step.classify.output}}Prompt Calltech casePrompt Callfinance caseTextfallback (else)Display Resultpost-branch child
Figure 37.Switch — a classifier produces a label, the Switch dispatches to the first matching case (or the else arm when none match), and the chosen branch's output flows into the post-branch child step (display_result).

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

FieldTypeRequiredDefaultDescription
input_templatestringYesTemplate 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.
offsetintegerNo00-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).
limitintegerNo(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.
parallelbooleanNofalseRun iterations concurrently subject to system-level concurrency bounds. The output array preserves input order regardless of completion order.
fail_fastbooleanNotrueFirst 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_emptybooleanNofalseWhen true, the step fails if the resolved input yields zero iterations. When false (default), zero iterations succeed and emit an empty array.
iterate_attachmentsbooleanNofalseWhen 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_ruleslistNonullOptional 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_result and streaming_result are 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_loop is NOT allowed inside a For Each body either — each iteration would park independently and the resumption path resolves step runs by agent_step_id only, with no way to disambiguate which iteration's parked step a vote refers to.
  • call_agent IS 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:

  1. A JSON array. Each element becomes a per-iteration item. The body references the element via {{step.<for_each_step_id>.item}}. Always valid.

  2. A JSON integer. Only valid when limit is 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; offset must be < limit when 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
TriggerPrompt Callextract topicsFor Each5 iterationsBodyiter 1Bodyiter 2Bodyiter 3 … NPrompt Callcombine digestDisplay Result
Figure 35.For Each — an upstream classifier emits a JSON list, the For Each step iterates the body once per item (3 of N iterations shown), and a downstream prompt call combines the per-iteration outputs into a digest before display.

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_index is 0-based, so the rendered subjects are "Reminder 0", "Reminder 1", … "Reminder N-1". If you want 1-based output ("Reminder 1", …), add a regex_replace step between the index reference and the subject, or compute the number in the upstream prompt_call and 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

FieldTypeRequiredDefaultDescription
target_step_idstringYesThe id of an ancestor step in the parent chain to re-execute from. Must be alphanumeric (including hyphens and underscores).
max_retriesintegerYesMaximum number of times the target will be re-executed (1–10).

How It Works

  1. The retry step is a non-composite leaf step — it cannot contain child steps.
  2. Each time the retry step completes, the runner checks how many times it has already completed.
  3. If the count is within max_retries, the target step and all its descendants are reset to pending and the target is re-scheduled.
  4. 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 to max_retries times). Use on_match: "stop" with success conditions instead (see below).
  • If the gate blocks (on_match: "stop" and conditions match, or on_match: "continue" and conditions fail), child steps including retry are not reached, and execution stops.

To implement conditional retry effectively:

  1. Use on_match: "stop" on the gate, with conditions that detect a good result (e.g., output is not empty, contains expected keywords).
  2. When the output is satisfactory, the gate stops — blocking the retry step.
  3. 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
TriggerRetrievalsearch KBPrompt Callanswer questionRetry→ retrieval (max 3)
Figure 33.Simple retry — a retrieval step is retried up to 3 times on failure before giving up.

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.

passretryTriggerPrompt Callgenerate reportGatestop if non-emptyRetrymax 2
Figure 34.Quality-gated retry: the gate stops execution when the output is satisfactory. If the gate passes through, the retry step 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), or selected_members (a hand-picked user list in recipient_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 labels timeout, no_quorum, and cancelled are emitted as sentinel outcomes and may not appear in choices.
  • 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, either emit_timeout (write timeout as the step output and continue, the default) or fail_run (mark the entire run FAILED).
  • reminder_interval_seconds + reminder_max_count — optional reminder cadence for unresolved requests. Requires timeout_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.

email linkvoteresume after voteapprovedeny / timeoutTriggerPrompt Calldraft emailHuman in the Loopapprove send?Recipientapproves or deniesIf / Elseoutcome == 'approve'Send EmailapprovedDisplay Resultrejected note
Figure 38.Human-in-the-Loop — the agent drafts an email, parks for human approval, then resumes after the vote. A downstream if_else routes on the outcome — approve sends the email, deny / timeout shows a rejection note instead.

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}})
passed = truepassed = falseemail linkvoteresume after voteapprovedeny / timeoutTriggerPrompt Calldraft replyEvaluate StepLLM-as-judge scoreIf / Elsepassed = true ?Send Emailauto (high confidence)Human in the Loopreview replyRecipientapproves or deniesIf / Elseoutcome == 'approve'Send Emailafter approvalDisplay Resultwithheld / denied
Figure 39.Conditional HITL — the evaluator scores the draft and an if_else routes on `passed`. High-confidence runs auto-send and never touch the Recipient column; low-confidence runs park the HITL step, which emails the recipient and waits for a vote before a nested if_else routes approve → send / deny → withheld.

Why this pattern works:

  • Cost. Every HITL request costs at least one email credit and consumes human attention. Gating on evaluate_step.passed typically 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 (via input_template: "{{step.score.output.score}}" and a numeric $lt) to introduce a second tier — "auto-approve", "human review", and "reject without asking".
  • Swap evaluate_step for a prompt_call classifier when you need a discrete bucket label (e.g. "safe" | "needs_review" | "block"); branch with a switch instead of if_else.
  • Combine with timeout_outcome: fail_run when 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 = question and intent ≠ 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_steps run, 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_steps arm and a post-branch child_steps convergence 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

FieldTypeRequiredDefaultDescription
conditionsarrayYesThe list of conditions to evaluate
matchenumNoallHow to combine condition results: all (AND — every condition must match) or any (OR — at least one must match)
on_matchenumNocontinueWhat to do when conditions are met: continue (pass input through) or stop (block execution, output is empty)

Each condition has:

FieldTypeRequiredDefaultDescription
targetstringYesWhat 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.
operatorenumYesThe comparison operator (see below)
valueanyNonullThe value to compare against. Supports substitution variables.
value_typeenumNonullHow to interpret the value: number, date, datetime, or relative_time
commentstringNonullA description of what this condition checks

Condition Operators

OperatorDescriptionExample
$eqEqualstarget == value
$neNot equalstarget != value
$ltLess thantarget < value
$lteLess than or equaltarget <= value
$gtGreater thantarget > value
$gteGreater than or equaltarget >= value
$inIn listtarget in [value1, value2, ...]
$ninNot in listtarget not in [value1, value2, ...]
$regexMatches regex patternre.search(value, target)
$not_regexDoes not match regexnot re.search(value, target)
$emptyIs empty or nulltarget is None or target.strip() == ""
$not_emptyIs not emptytarget 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 TypeDescriptionExample Values
numberTreat as a numeric value100, 3.14
dateTreat as a date2026-02-17
datetimeTreat as a datetime2026-02-17T14:30:00
relative_timeParse as a relative time expressionnow, 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 datetime
  • today — Start of today
  • yesterday — Start of yesterday
  • this week / last week — Start of current/previous week
  • N days ago / N hours ago / N minutes ago
  • N days from now / N hours from now

Use Case Examples

Only process non-empty input:

ConditionTargetOperatorValue
1input$not_empty

Match: all, On match: continue

TriggerGateinput not_emptyPrompt Callprocess inputStreaming Result
Figure 12.Non-empty gate — the gate passes input through only when it is not empty, protecting downstream steps from blank data.

Filter by content length (skip short content):

ConditionTargetOperatorValueValue Type
1input_length$gt100number

Match: all, On match: continue — Only continue if input is longer than 100 characters.

TriggerGatelength > 100Extract Dataanalyze contentDisplay Result
Figure 13.Length gate — only content exceeding 100 characters passes through for analysis.

Route by category (only process technology articles):

ConditionTargetOperatorValue
1metadata.category$eqtechnology
Triggercontent eventGatecategory = technologyPrompt Calltech analysisPublish Contenttech digest
Figure 14.Category gate — only technology articles pass through to the analysis and publish pipeline.

Block specific content types:

ConditionTargetOperatorValue
1input_content_type$in["text/html", "application/xml"]

Match: all, On match: stop — Block HTML and XML content from proceeding.

TriggerGateblock HTML / XMLStoppedcontent blockedPrompt Callprocess text
Figure 15.Block gate — HTML and XML content is stopped; only other content types pass through to the prompt call.

Time-based gating (only process recent content):

ConditionTargetOperatorValueValue Type
1metadata.published_date$gt7 days agorelative_time

Match: all, On match: continue — Only process content published in the last 7 days.

Triggercontent eventGatepublished < 7d agoPrompt Callsummarize recentStreaming Result
Figure 16.Time-based gate — only content published in the last 7 days passes through for summarization.

Complex multi-condition gate:

ConditionTargetOperatorValue
1input$not_empty
2input_length$gt50
3metadata.status$nedraft
4metadata.language$in["en", "es", "fr"]

Match: all, On match: continue — All four conditions must be true to proceed.

Triggercontent eventGate4 conditions (all)Extract Datafull analysisPublish Content
Figure 17.Multi-condition gate — all four conditions (non-empty, length, status, language) must pass before the full analysis pipeline runs.
TriggerClassify InputRoute GateSummary PathDetail Path
Figure 18.Gate branching: a classify step feeds a route gate that directs execution down different paths based on conditions.

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.

TriggerPrompt Callclassify intentGateintent = questionGateintent ≠ questionPrompt Callanswer questionPrompt Callsummarize articleJoinMergemerge result
Figure 19.If/else gates — two mutually exclusive gates split by intent. Questions are answered directly, other inputs are summarized. A join and merge recombine the branches.

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.

TriggerPrompt Callclassify topicGatetopic = techGatetopic = financeGatetopic = generalPrompt Calltech analysisPrompt Callmarket reportPrompt Callgeneral summaryJoinJoinMergemerge result
Figure 20.Switch gates — parallel mutually exclusive gates route tech, finance, and general content to dedicated branches. Joins and a merge recombine.

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.

TriggerGatelength > 500Gatetopic = techGatepublished < 7dPrompt Calldeep summaryExtract Datatech analysisSend Emailbreaking newsJoinJoinMergemerge available
Figure 21.Parallel gates — three independent gates (length, topic, recency) each guard a separate processing branch. Any combination can fire; results are merged by a merge step.

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

FieldTypeRequiredDefaultDescription
targetstringYesThe 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.

TriggerPrompt CallsummarizePrompt Callextract entitiesJoin→ combineMerge
Figure 25.Join + Merge — two parallel branches each terminate with a join step, feeding into a merge that combines their outputs.

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.

CombinationBehaviour
iterate_attachments: false (default)Loop iterates input_template; attachment_rules is ignored
iterate_attachments: true + attachment_rules: nullLoop 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.