# 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](https://seclai.com/docs/agent-steps). For data-producing steps (prompt*call, text, regex_replace, merge, extract*\*, evaluate_step), see [Core Action Steps](https://seclai.com/docs/agent-steps/core).

---

## If / Else Step

Evaluates a set of conditions against the step input and metadata, then runs the <code>then_steps</code> branch on match and the <code>else_steps</code> 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](#gate-step). The difference is structural: Gate is "if-without-else" — it gates whether its <code>child_steps</code> run, and there's no opposite-branch path. If / Else carries two distinct subtrees, one for each outcome.

### Fields

| Field                       | Type           | Required | Default          | Description                                                                                                                                                                                                                                                                                                                                       |
| --------------------------- | -------------- | -------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <code>conditions</code>     | array          | Yes      | —                | One or more condition objects (<code>target</code> / <code>operator</code> / <code>value</code>), identical to the [Gate condition shape](#gate-fields). At least one condition required.                                                                                                                                                         |
| <code>match</code>          | enum           | No       | <code>all</code> | <code>all</code> requires every condition to pass (AND); <code>any</code> requires at least one (OR).                                                                                                                                                                                                                                             |
| <code>input_template</code> | string         | No       | (unset)          | Optional template resolved at runtime. When set, conditions targeting <code>input</code> (or <code>input_length</code>) read from this resolved value instead of the step's natural input — useful for dispatching on another step's output, a metadata field, or <code>{`{{datetime.now}}`}</code>. Leave unset to use the step's natural input. |
| <code>then_steps</code>     | array of steps | No       | <code>[]</code>  | Steps that run when the conditions match. May be empty (no-op fall-through).                                                                                                                                                                                                                                                                      |
| <code>else_steps</code>     | array of steps | No       | <code>[]</code>  | Steps that run when the conditions don't match. May be empty (no-op fall-through).                                                                                                                                                                                                                                                                |
| <code>child_steps</code>    | array of steps | No       | <code>[]</code>  | 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 <code>child_steps</code> (the post-branch continuation chain). If the executed branch is empty, the If / Else's pass-through input flows directly into <code>child_steps</code>. The step always produces a single trace card with a <code>branch</code> marker indicating which side ran (<code>"then"</code> or <code>"else"</code>); the sibling un-taken branch is skipped.

### When to Use If / Else vs Gate vs Switch

If / Else, [Gate](#gate-step), and [Switch](#switch-step) all branch execution on a predicate or value. Pick by outcome shape:

- **[Gate](#gate-step)**: "Stop if X" or "only continue if X". One conditional outcome, no alternative — the gate either passes input through to its own <code>child_steps</code> 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 <code>else_steps</code> arm.
- **[Switch](#switch-step)**: 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 <code>display_result</code> in the if_else's <code>child_steps</code> consumes whichever branch ran. <strong>Do not</strong> put <code>display_result</code> or <code>streaming_result</code> inside <code>then_steps</code> / <code>else_steps</code> — 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.

*Figure: 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 <code>child_steps</code> stays empty.

---

## Switch Step

Dispatches on a single runtime-resolved discriminator value to one of N named cases (with an optional <code>else_steps</code> 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                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| -------------------------- | -------------- | -------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <code>discriminator</code> | string         | No       | <code>{`{{input}}`}</code> | Template that resolves at runtime to the value compared against each case. Supports <code>{`{{...}}`}</code> placeholders — commonly <code>{`{{step.<classifier>.output}}`}</code>. Leave blank to dispatch on the step's natural input.                                                                                                                                                                                                                                                                                                                                                        |
| <code>value_type</code>    | enum           | No       | (string)                   | Type hint used to validate and (for <code>number</code>) coerce case match values before equality matching. <code>number</code> coerces both the discriminator and case match values so <code>"1"</code> matches <code>1.0</code>. <code>date</code> (YYYY-MM-DD) and <code>datetime</code> (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 <code>string</code>) for plain string equality. |
| <code>cases</code>         | array          | Yes      | —                          | Ordered list of cases. Each case has a <code>name</code> (unique within the switch), a <code>match</code> value, and a <code>steps</code> subtree. At least one case required.                                                                                                                                                                                                                                                                                                                                                                                                                  |
| <code>else_steps</code>    | array of steps | No       | <code>[]</code>            | Default arm. Runs when no case matches. May be empty (no-match = no-op pass-through).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| <code>child_steps</code>   | array of steps | No       | <code>[]</code>            | Post-branch continuation chain. Runs _after_ the chosen case (or <code>else_steps</code>, 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                                                                                                                  |
| ------------------ | ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| <code>name</code>  | string             | Yes      | Display label and trace marker. Must be unique within the switch.                                                            |
| <code>match</code> | string or string[] | Yes      | Single value (equality) or list of values (<code>$in</code>-style). Lump synonyms into a list rather than duplicating cases. |
| <code>steps</code> | 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 <code>else_steps</code> arm), which then becomes the input to the Switch's own <code>child_steps</code> (the post-branch continuation chain). If no case matches and there are no <code>else_steps</code>, the Switch's pass-through input flows directly into <code>child_steps</code>. The trace card carries a <code>branch</code> marker naming the matched case (or <code>"else"</code> / <code>null</code> for no-match); unmatched sibling cases are skipped.

### When to Use Switch vs If / Else vs Gate

Switch, [If / Else](#if-else-step), and [Gate](#gate-step) all branch execution. Pick by the shape of what you're branching on:

- **[Gate](#gate-step)**: A single yes/no predicate where the "no" path is "do nothing". No alternative branch.
- **[If / Else](#if-else-step)**: 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 <code>display_result</code> in the switch's <code>child_steps</code> consumes whichever branch ran (the spam case is empty, so a spam classification produces a no-op pass-through into the display). <code>display_result</code> / <code>streaming_result</code> are never placed inside <code>cases[].steps</code> or <code>else_steps</code> — the save-time validator rejects that shape.

*Figure: 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](https://seclai.com/docs/agent-steps/core#merge-step).

### Fields

| Field                            | Type    | Required | Default            | Description                                                                                                                                                                                                                                                                                                                |
| -------------------------------- | ------- | -------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <code>input_template</code>      | string  | Yes      | —                  | Template that resolves at runtime to either a **JSON array** (always valid) or a **JSON integer** ordinal (only valid when <code>limit</code> is also set). Supports <code>{`{{...}}`}</code> placeholders.                                                                                                                |
| <code>offset</code>              | integer | No       | <code>0</code>     | 0-based start index. If the resolved input has fewer than <code>offset</code> items, the loop behaves as empty (and either succeeds with an empty array output or fails when <code>fail_on_empty</code> is true).                                                                                                          |
| <code>limit</code>               | integer | No       | (none)             | Upper **exclusive** index bound. If both <code>offset</code> and <code>limit</code> are set, <code>offset</code> MUST be strictly less than <code>limit</code>. Required when <code>input_template</code> resolves to an integer ordinal.                                                                                  |
| <code>parallel</code>            | boolean | No       | <code>false</code> | Run iterations concurrently subject to system-level concurrency bounds. The output array preserves **input order** regardless of completion order.                                                                                                                                                                         |
| <code>fail_fast</code>           | boolean | No       | <code>true</code>  | First iteration that fails aborts remaining iterations and the For Each step itself fails. When false, failed iterations record <code>null</code> placeholders in the output array and the loop continues.                                                                                                                 |
| <code>fail_on_empty</code>       | boolean | No       | <code>false</code> | When true, the step fails if the resolved input yields zero iterations. When false (default), zero iterations succeed and emit an empty array.                                                                                                                                                                             |
| <code>iterate_attachments</code> | boolean | No       | <code>false</code> | When true, the loop iterates over the **attachments** of a [multi-asset manifest input](https://seclai.com/docs/agent-steps#file-attachments) instead of an array template. <code>input_template</code> is ignored in this mode. Each iteration's <code>item</code> is the attachment metadata (<code>{`{storage_key, mime, name}`}</code>). |
| <code>attachment_rules</code>    | list    | No       | <code>null</code>  | Optional list of [attachment rules](#for-each-attachment-rules-config) narrowing which manifest attachments the loop iterates over. Only meaningful when <code>iterate_attachments</code> 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 <code>parallel</code> completion order. With <code>fail_fast: false</code>, failed iterations appear as <code>null</code> placeholders so positions match the input.

### Iteration Variables

Inside the body, reference the current iteration via the For Each step's <code>id</code>:

- <code>{`{{step.<for_each_step_id>.item}}`}</code> — the current iteration's
  item value
- <code>{`{{step.<for_each_step_id>.item_index}}`}</code> — 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.
- <code>display_result</code> and <code>streaming_result</code> 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.
- <code>human_in_the_loop</code> is NOT allowed inside a For Each body either —
  each iteration would park independently and the resumption path resolves step
  runs by <code>agent_step_id</code> only, with no way to disambiguate which
  iteration's parked step a vote refers to.
- <code>call_agent</code> 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 <code>prompt_call</code> with <code>seclai_web_tools</code>) 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 <code>prompt_call</code> that processes the whole list at once (without For Each and without <code>seclai_web_tools</code>) is the failure shape both patterns are designed to replace.

### Input Types: Array vs Integer Ordinal

After string substitution, the resolved <code>input_template</code> must be either:

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

2. **A JSON integer.** Only valid when <code>limit</code> is also set. The loop iterates indices in <code>[offset, min(integer_value, limit))</code> and the body's <code>{`{{step.<for_each_step_id>.item}}`}</code> 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

<code>limit</code> is an **upper exclusive index bound**, not a count. Examples
for a 200-item input:

- <code>offset: 0, limit: 5</code> → process indices 0..4 (5 items)
- <code>offset: 2, limit: 5</code> → process indices 2..4 (3 items)
- <code>offset: 10, limit: 5</code> → invalid (rejected at definition time; <code>offset</code> must be < <code>limit</code> when both set)
- <code>offset: 0, limit: 500</code> → process all 200 items (limit caps at
  actual length)
- <code>offset: 250, limit: null</code> → empty iteration (offset exceeds actual
  length)
- <code>offset: null, limit: null</code> → process all items

When the iteration list is empty, <code>fail_on_empty</code> 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
```

*Figure: 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}}")
```

> <code>item_index</code> 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 <code>regex_replace</code> step between the index reference and the
> subject, or compute the number in the upstream <code>prompt_call</code> 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 <code>id</code> and indexed by <code>iteration_index</code>. 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](#gate-step) makes retries conditional — place a gate before the retry to check output quality and only retry when the result is unsatisfactory. [Evaluate](https://seclai.com/docs/agent-steps/core#evaluate-step) scores the output that a gate inspects before triggering a retry.

### Fields

| Field                       | Type    | Required | Default | Description                                                                                                                               |
| --------------------------- | ------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| <code>target_step_id</code> | string  | Yes      | —       | The <code>id</code> of an ancestor step in the parent chain to re-execute from. Must be alphanumeric (including hyphens and underscores). |
| <code>max_retries</code>    | integer | Yes      | —       | Maximum 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
```

*Figure: 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.

*Figure: 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 <code>/app/&lt;account&gt;/hitl</code> and is also delivered as a signed deep-link email. The chosen outcome becomes this step's output, which downstream <code>if_else</code>, <code>switch</code>, or <code>gate</code> 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](https://seclai.com/docs/human-in-the-loop).

### Fields

- <code>prompt_template</code> — required string. Renders at runtime against the
  step's input and the agent's execution context. Reference upstream outputs
  with <code>{`{{step.<id>.output}}`}</code> so the human sees the data they're
  deciding on.
- <code>recipient_distribution</code> — who to notify. <code>owner</code> (just
  the agent owner), <code>owner_admins</code> (owner plus org admins; the
  default), or <code>selected_members</code> (a hand-picked user list in
  <code>recipient_user_ids</code>).
- <code>choices</code> — labels the human can pick from. Defaults to
  <code>["approve", "deny"]</code>. Custom choices express multi-way decisions
  like <code>["ship_it", "needs_revision", "abandon"]</code>. The reserved
  labels <code>__timeout__</code>, <code>__no_quorum__</code>, and
  <code>__cancelled__</code> are emitted as sentinel outcomes and may not appear
  in <code>choices</code>.
- <code>required_approvals</code> — 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.
- <code>timeout_seconds</code> — optional. When set, the janitor expires the
  request after this many seconds. Leave blank to wait indefinitely.
- <code>timeout_outcome</code> — on expiry, either <code>emit_timeout</code>
  (write <code>__timeout__</code> as the step output and continue, the default)
  or <code>fail_run</code> (mark the entire run FAILED).
- <code>reminder_interval_seconds</code> + <code>reminder_max_count</code> —
  optional reminder cadence for unresolved requests. Requires
  <code>timeout_seconds</code>; 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:

```json
{
  "outcome": "approve",
  "quorum_met": true,
  "required": 2,
  "total_recipients": 5,
  "votes": [
    {
      "user_id": "...",
      "choice": "approve",
      "comment": "lgtm",
      "decided_at": "2026-05-25T10:42:11Z"
    }
  ]
}
```

<code>outcome</code> is the winning choice label, or one of the sentinels
<code>__timeout__</code> / <code>__no_quorum__</code> /
<code>__cancelled__</code>. Downstream gates read
<code>{`{{step.<hitl_id>.output.outcome}}`}</code>.

When the outcome is <code>**cancelled**</code> and the operator supplied a reason (via the inbox or the <code>cancel_hitl_request</code> MCP tool), the payload also carries a <code>cancellation_reason</code> string — readable downstream via <code>{`{{step.<hitl_id>.output.cancellation_reason}}`}</code>.

### Placement

Human-in-the-Loop is rejected anywhere under a <code>for_each.body[]</code> — per-iteration step runs are keyed by <code>iteration_index</code>, and the resumption path resolves step runs by <code>agent_step_id</code> 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 <code>/app/settings/email</code>) 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 <code>/app/settings/email</code>) still see the pending request in the inbox — only the email is suppressed.

*Figure: 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`](https://seclai.com/docs/agent-steps/core#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}})
```

*Figure: 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](https://seclai.com/docs/agent-steps/core#prompt-call-step) 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](#join-step) and [Merge](https://seclai.com/docs/agent-steps/core#merge-step) recombine branches created by gates. [Retry](#retry-step) pairs with a gate to implement conditional retry logic. [Evaluate](https://seclai.com/docs/agent-steps/core#evaluate-step) scores output that a gate can branch on.

### When to Use Gate vs If / Else vs Switch

Gate, [If / Else](#if-else-step), and [Switch](#switch-step) 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 <code>child_steps</code> run, and the no-path is implicit (execution simply stops on that branch). Use when you only need one conditional outcome.
- **[If / Else](#if-else-step)**: 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 <code>else_steps</code> arm and a post-branch <code>child_steps</code> convergence chain.
- **[Switch](#switch-step)**: 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                                                                                                                              |
| ----------------------- | ----- | -------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| <code>conditions</code> | array | Yes      | —                     | The list of conditions to evaluate                                                                                                       |
| <code>match</code>      | enum  | No       | <code>all</code>      | How to combine condition results: <code>all</code> (AND — every condition must match) or <code>any</code> (OR — at least one must match) |
| <code>on_match</code>   | enum  | No       | <code>continue</code> | What to do when conditions are met: <code>continue</code> (pass input through) or <code>stop</code> (block execution, output is empty)   |

Each **condition** has:

| Field                   | Type   | Required | Default           | Description                                                                                                                                                                                                                              |
| ----------------------- | ------ | -------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <code>target</code>     | string | Yes      | —                 | What to evaluate. Built-in targets: <code>input</code> (step input text), <code>input_length</code> (character count), <code>input_content_type</code> (MIME type). Also supports <code>{'metadata.<field>'}</code> for metadata values. |
| <code>operator</code>   | enum   | Yes      | —                 | The comparison operator (see below)                                                                                                                                                                                                      |
| <code>value</code>      | any    | No       | <code>null</code> | The value to compare against. Supports substitution variables.                                                                                                                                                                           |
| <code>value_type</code> | enum   | No       | <code>null</code> | How to interpret the value: <code>number</code>, <code>date</code>, <code>datetime</code>, or <code>relative_time</code>                                                                                                                 |
| <code>comment</code>    | string | No       | <code>null</code> | A description of what this condition checks                                                                                                                                                                                              |

### Condition Operators

| Operator                | Description           | Example                                                  |
| ----------------------- | --------------------- | -------------------------------------------------------- |
| <code>$eq</code>        | Equals                | <code>target == value</code>                             |
| <code>$ne</code>        | Not equals            | <code>target != value</code>                             |
| <code>$lt</code>        | Less than             | <code>{'target < value'}</code>                          |
| <code>$lte</code>       | Less than or equal    | <code>{'target <= value'}</code>                         |
| <code>$gt</code>        | Greater than          | <code>{'target > value'}</code>                          |
| <code>$gte</code>       | Greater than or equal | <code>{'target >= value'}</code>                         |
| <code>$in</code>        | In list               | <code>{'target in [value1, value2, ...]'}</code>         |
| <code>$nin</code>       | Not in list           | <code>{'target not in [value1, value2, ...]'}</code>     |
| <code>$regex</code>     | Matches regex pattern | <code>re.search(value, target)</code>                    |
| <code>$not_regex</code> | Does not match regex  | <code>not re.search(value, target)</code>                |
| <code>$empty</code>     | Is empty or null      | <code>target is None or target.strip() == ""</code>      |
| <code>$not_empty</code> | Is not empty          | <code>target is not None and target.strip() != ""</code> |

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                                                                                                                                                         |
| -------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <code>number</code>        | Treat as a numeric value            | <code>100</code>, <code>3.14</code>                                                                                                                                    |
| <code>date</code>          | Treat as a date                     | <code>2026-02-17</code>                                                                                                                                                |
| <code>datetime</code>      | Treat as a datetime                 | <code>2026-02-17T14:30:00</code>                                                                                                                                       |
| <code>relative_time</code> | Parse as a relative time expression | <code>now</code>, <code>today</code>, <code>yesterday</code>, <code>3 days ago</code>, <code>1 week ago</code>, <code>2 hours ago</code>, <code>5 days from now</code> |

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:**

| Condition | Target             | Operator                | Value |
| --------- | ------------------ | ----------------------- | ----- |
| 1         | <code>input</code> | <code>$not_empty</code> | —     |

Match: `all`, On match: `continue`

*Figure: 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):**

| Condition | Target                    | Operator         | Value            | Value Type          |
| --------- | ------------------------- | ---------------- | ---------------- | ------------------- |
| 1         | <code>input_length</code> | <code>$gt</code> | <code>100</code> | <code>number</code> |

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

*Figure: Length gate — only content exceeding 100 characters passes through for analysis.*

**Route by category (only process technology articles):**

| Condition | Target                         | Operator         | Value                   |
| --------- | ------------------------------ | ---------------- | ----------------------- |
| 1         | <code>metadata.category</code> | <code>$eq</code> | <code>technology</code> |

*Figure: Category gate — only technology articles pass through to the analysis and publish pipeline.*

**Block specific content types:**

| Condition | Target                          | Operator         | Value                                             |
| --------- | ------------------------------- | ---------------- | ------------------------------------------------- |
| 1         | <code>input_content_type</code> | <code>$in</code> | <code>{'["text/html", "application/xml"]'}</code> |

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

*Figure: 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):**

| Condition | Target                               | Operator         | Value                   | Value Type                 |
| --------- | ------------------------------------ | ---------------- | ----------------------- | -------------------------- |
| 1         | <code>metadata.published_date</code> | <code>$gt</code> | <code>7 days ago</code> | <code>relative_time</code> |

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

*Figure: Time-based gate — only content published in the last 7 days passes through for summarization.*

**Complex multi-condition gate:**

| Condition | Target                         | Operator                | Value                               |
| --------- | ------------------------------ | ----------------------- | ----------------------------------- |
| 1         | <code>input</code>             | <code>$not_empty</code> | —                                   |
| 2         | <code>input_length</code>      | <code>$gt</code>        | <code>50</code>                     |
| 3         | <code>metadata.status</code>   | <code>$ne</code>        | <code>draft</code>                  |
| 4         | <code>metadata.language</code> | <code>$in</code>        | <code>{'["en", "es", "fr"]'}</code> |

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

*Figure: Multi-condition gate — all four conditions (non-empty, length, status, language) must pass before the full analysis pipeline runs.*

*Figure: 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.

*Figure: 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.

*Figure: 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.

*Figure: 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](https://seclai.com/docs/agent-steps/core#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](#gate-step) creates the parallel branches that need joining. [Merge](https://seclai.com/docs/agent-steps/core#merge-step) receives the joined outputs and combines them.

### Fields

| Field               | Type   | Required | Default | Description                                                                                  |
| ------------------- | ------ | -------- | ------- | -------------------------------------------------------------------------------------------- |
| <code>target</code> | string | Yes      | —       | The ID of the merge step this join feeds into. Must match <code>{'^[a-zA-Z0-9_-]+$'}</code>. |

Join steps **cannot** have child steps.

### Use Case Example

See the [Merge step](https://seclai.com/docs/agent-steps/core#merge-step) section for a complete example of how join steps connect parallel branches to a merge.

*Figure: 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](https://seclai.com/docs/agent-steps#file-attachments) 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](https://seclai.com/docs/agent-steps/integration#attachment-rules-config) 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 <code>{`{{step.<for_each_id>.item.name}}`}</code> etc.

Legacy agents that used the old single-string `attachments` selector auto-migrate to a one-rule list on load.

---
