Control Flow
Conditional routing, joins, retries, and self-healing gates.
A taskflow DAG is static topology — edges are fixed at definition time. But real workflows need dynamic behavior: skip a phase when severity is low, route to a fallback when the first attempt fails, retry a flaky model, or rework output that fails a quality gate. The fields on this page add that dynamic layer on top of the topology.
Every field here is checked by /tf verify at zero cost before any agent runs. Invalid operators, out-of-range retry values, and contradictory joins are caught up front — never after spending tokens.
The scenario: a PR review with conditional depth
Imagine you are building a code review flow. Not every PR needs the same treatment. A small docs tweak deserves a quick summary; a high-severity change should trigger a deep audit. Here is a flow that branches on a triage decision:
{
"name": "conditional-review",
"phases": [
{
"id": "triage",
"type": "agent",
"output": "json",
"task": "Classify the PR. Output JSON {severity, reason}."
},
{
"id": "deep-audit",
"when": "{steps.triage.json.severity} == high",
"dependsOn": ["triage"],
"agent": "risk-reviewer",
"task": "Deep-dive the change. Reason: {steps.triage.json.reason}"
},
{
"id": "quick-summary",
"when": "{steps.triage.json.severity} != high",
"dependsOn": ["triage"],
"task": "Write a one-paragraph summary."
}
]
}The when field on each branch is the guard. When triage resolves to severity: "high", the deep-audit branch runs and quick-summary is skipped — no tokens spent, no subagent spawned. The opposite happens for low-severity PRs.
when is the first tool in this guide. The rest of the page walks through six fields that, together, let you express nearly any runtime decision:
`when`
Conditional guards — skip a phase unless an expression is truthy.
`join`
Dependency joins — all vs any, and if/else routing.
`optional`
Tolerate failure without aborting the run.
`retry`
Explicit retry policy plus transient auto-retry.
`idempotent`
Side-effect classification for irreversible phases.
`onBlock`
Self-healing gates — re-run upstream on BLOCK.
when: skip a phase conditionally
A when expression is a small, safe boolean language evaluated before the phase runs. If it is truthy, the phase runs. If it is falsy, the phase is skipped: no subagent call, no tokens, and the run record shows status: "skipped".
The simplest guard compares a JSON field to a literal:
{
"id": "deep",
"when": "{steps.triage.json.severity} == high",
"dependsOn": ["triage"]
}You can combine guards with &&, ||, and !:
{
"id": "deep",
"when": "({steps.triage.json.severity} == high || {steps.triage.json.severity} == critical) && !{steps.triage.json.suppressed}",
"dependsOn": ["triage"]
}A bare placeholder is also a guard
If the upstream phase emits a boolean or a JSON field whose truthiness you trust, you can skip the operator entirely:
{
"id": "b",
"when": "{steps.triage.json.hasFindings}",
"task": "..."
}Because "0", "false", "no", "off", and "null" (case-insensitive) are all falsy, a model that emits {"hasFindings": "false"} still evaluates correctly. This is intentional robustness against LLM output drift. Explicit == high comparisons are clearer, though — and they survive future enum values.
Common patterns
| Pattern | Example | When to use |
|---|---|---|
| Equality on a JSON enum | {steps.t.json.severity} == high | Routing on a classifier's discrete output. |
| Bare truthiness | {steps.t.json.hasFindings} | A boolean flag where the model rarely drifts. |
| Compound route with override | (A == high || A == critical) && !B | Multiple routes converge, with a suppressor. |
| Numeric threshold | {steps.t.json.score} >= 0.8 | Quality-gate pass/fail thresholds. |
Fail-open: a broken guard never drops a phase
If a when expression fails to parse (unterminated string, missing ), trailing tokens), the phase runs anyway. A broken guard must never accidentally suppress work.
The error is surfaced in the phase's warnings and the run record, so you can find and fix it. The same applies to an empty or whitespace-only when (treated as true) and to unresolved placeholders — an unresolved {steps.missing.json} resolves to undefined (falsy), but the expression still parses and evaluates normally.
The full operator table, precedence, comparison semantics, and parse errors live in Condition reference at the end of this page.
join: how a phase waits on its dependencies
By default, a phase waits for every declared dependency to finish (join: "all"). That is the right default — most phases genuinely need all their inputs.
But return to the conditional-review flow above. Exactly one of deep-audit / quick-summary runs; the other is skipped. If a downstream phase depends on both, it would hang forever waiting for the skipped branch. join: "any" is the fix:
{
"id": "merge",
"type": "reduce",
"from": ["deep-audit", "quick-summary"],
"join": "any",
"dependsOn": ["deep-audit", "quick-summary"],
"task": "Combine the surviving branch's output into one report."
}With join: "any", the merge phase proceeds as soon as one dependency completes, instead of waiting for all of them.
If / else routing
Combining when guards with join: "any" on a merge phase is the idiomatic way to express if/else branches:
[
{ "id": "triage", "type": "agent", "output": "json", "task": "Classify severity." },
{ "id": "deep", "when": "{steps.triage.json.severity} == high", "dependsOn": ["triage"], "task": "Deep analysis." },
{ "id": "quick", "when": "{steps.triage.json.severity} != high", "dependsOn": ["triage"], "task": "Quick summary." },
{ "id": "merge", "type": "reduce", "from": ["deep", "quick"], "join": "any", "dependsOn": ["deep", "quick"] }
]Exactly one of deep / quick runs; merge proceeds as soon as the surviving branch completes.
What "satisfied" means
A dependency counts as satisfied if it ended done, or it ended failed and is marked optional (see optional). A skipped or non-optionally-failed dependency is not satisfied.
| Mode | Behavior |
|---|---|
"all" (default) | Every dependency must be satisfied. Otherwise the phase is skipped with reason "Upstream dependency not satisfied". |
"any" | At least one dependency must be satisfied. Otherwise the phase is skipped with reason "All dependencies failed or were skipped". |
Pitfall: join: "any" and downstream refs. When one branch is skipped, {steps.skipped-branch.output} stays as a literal placeholder — skipped phases are excluded from the interpolation context, and failed phases without output resolve to "[previous phase failed]". A reduce over from: [...] only sees completed branches, but a hand-written task that references both branches will interpolate the failed or skipped one. Guard with when, or use reduce's from list.
optional: tolerate failure
Some work is best-effort. A lint script might fail; a third-party enrichment call might 503. You don't always want that to abort the whole run.
{
"id": "lint",
"type": "script",
"run": "npm run lint",
"optional": true
}A failed non-optional phase sets the run status to failed and cascades: downstream phases whose dependsOn includes it are skipped (unless they join: "any" or the dep is itself optional). A failed optional phase is tolerated — the run continues, and the phase still counts as a "satisfied" dependency for join purposes (both "all" and "any").
Pairing with a fallback phase
optional + when together build a best-effort phase with a graceful fallback:
[
{
"id": "enrich",
"type": "agent",
"optional": true,
"task": "Call the external API and enrich the record. May fail."
},
{
"id": "fallback",
"when": "{steps.enrich.output} == \"[previous phase failed]\"",
"dependsOn": ["enrich"],
"task": "Produce a minimal record without enrichment."
}
]When enrich fails, its {steps.enrich.output} resolves to the sentinel "[previous phase failed]" so downstream refs don't break — and the fallback phase's when guard catches exactly that case.
optional shines for script phases (lint, format, metrics) and best-effort agent calls. For sub-flows, mark the inner flow phase optional rather than relying on the sub-flow's own status.
retry: try again on failure
LLM providers hiccup. Rate limits, 5xx, and timeouts are routine. taskflow handles this in two layers.
Layer 1: implicit transient auto-retry
Even without an explicit retry block, transient provider errors are auto-retried so a momentary 429 is absorbed inside the run instead of bubbling up and provoking the calling agent to re-invoke the whole tool (which stacks duplicate progress blocks).
| Property | Value |
|---|---|
| Max transient attempts | 3 (after the first try) |
| Default base delay | 2000 ms |
| Default factor | 2 (exponential) |
| Cap | 60000 ms |
A failure is transient if its message matches rate limit, too many requests, overloaded, 429, 502, 503, 504, service unavailable, temporarily unavailable, timeout, timed out, ECONNRESET, ETIMEDOUT, or socket hang up (case-insensitive). Deterministic failures — explicit aborts, idle timeouts, phase timeouts, contract violations — are not retried.
Layer 2: explicit per-phase retry
When you want control over the curve, or want to retry on hard failures too, add an explicit retry:
{
"id": "generate",
"retry": { "max": 5, "backoffMs": 1000, "factor": 2 },
"task": "..."
}| Field | Type | Default | Constraint | Description |
|---|---|---|---|---|
max | number | — (required) | 0–20 | Max retry attempts after the first try. 0 = no retries. |
backoffMs | number | 0 | 0–60000 | Base delay between attempts, in ms. |
factor | number | 1 | 1–10 | Backoff multiplier per attempt. 1 = fixed; 2 = exponential. |
Delay before attempt N (0-indexed): min(60000, round(backoffMs * factor ** attempt)).
How the two layers compose
The two policies compose, not replace:
- The explicit policy covers all failures (transient and hard) up to
retry.max. - The transient fallback covers only transient errors, up to 3 attempts, and only when
idempotent !== false. - The total attempt budget is
max(explicitMax, 1 + 3). - Backoff curve: if the phase defines an explicit
retry.backoffMs, that curve is used for both explicit and transient retries (keeping tests fast withbackoffMs: 0). Otherwise the transient defaults (2000 ms, factor 2) apply.
Output-contract (expect) violations are eligible for the explicit retry policy, never the transient fallback — a contract violation is a deterministic failure, not a provider hiccup.
idempotent: classify side effects
Some phases must never be repeated. A deploy, a webhook POST, a DB migration — re-running these on a transient 429 is exactly the hazard the author wants to prevent. Setting idempotent: false declares "this phase has irreversible side effects":
{
"id": "deploy",
"type": "agent",
"idempotent": false,
"task": "Run the production deploy. This must not be repeated on transient errors.",
"retry": { "max": 0 }
}What idempotent: false does
| Effect | Detail |
|---|---|
| Suppresses implicit transient auto-retry | The 3-attempt transient fallback is disabled. A 429 on a deploy will not be silently retried. |
| Excludes the result from all caches | Effective cache scope becomes "off" — not served from within-run resume, not served from or written to the cross-run store. |
Records sideEffect: true | Stamped on the phase state for audit. |
| Resume double-fire warning | On resume, a non-idempotent phase re-executes. If a prior attempt already completed, a warning is recorded. |
What it does not do
- It does not suppress an explicit
retry: {}. An explicit policy is the author's declaration that a repeat is acceptable. To forbid all retries, also setretry: { max: 0 }. - It does not change
optionalsemantics. A side-effecting phase can still beoptional: true.
Validation interactions
| Scenario | Validation warning |
|---|---|
idempotent: false on approval or flow | No-op — those phases run no subagent (no transient retry) and are already excluded from cross-run cache. The flag has no effect. |
idempotent: false + cache.scope: "cross-run" | idempotent:false overrides cache.scope 'cross-run' — a side-effecting phase is never cached. |
idempotent: false under an incremental: true flow | idempotent:false under an incremental flow — this phase is excluded from caching and will re-run every invocation. |
When to set it
Set idempotent: false for any phase whose re-execution is not safe to repeat — webhook POSTs, email/notification sends, deploys, migrations, DB writes, anything that charges money or mutates external state. Leave it true (the default) for read-only analysis, generation, and transformation phases — those benefit from transient retry and caching.
Pair idempotent: false with retry: { max: 0 } for a hard no-retry guarantee. Consider optional: true if a failed side effect should not abort the whole run.
onBlock: self-healing gates
onBlock is a gate-only field. It has no effect on other phase types.
A gate phase can emit VERDICT: BLOCK to halt the flow. But sometimes you don't want to halt — you want the gate to force a rework and try again. That is what onBlock: "retry" does:
[
{
"id": "implement",
"type": "agent",
"task": "Implement the feature."
},
{
"id": "quality",
"type": "gate",
"onBlock": "retry",
"retry": { "max": 3 },
"dependsOn": ["implement"],
"task": "Does this satisfy all criteria? Output VERDICT: PASS or VERDICT: BLOCK with reasons."
}
]Each BLOCK round:
The gate re-runs its upstream dependsOn. Every phase in the gate's dependsOn list (here, implement) executes again. Re-run upstream phases do not cache-hit — the gate deliberately passes its own prior state so the dependency actually re-runs.
The gate re-evaluates. It runs its task against the freshly produced upstream output.
Repeat or finish. The loop continues until PASS, or until retry.max rounds elapse (default 1 if retry is omitted).
| Value | Behavior |
|---|---|
"halt" (default) | Stop the flow. The gate's failed status cascades downstream. |
"retry" | Re-run the gate's upstream dependsOn phases, then re-evaluate the gate. Repeat until PASS or the cap. |
onBlock: "retry" re-runs upstream, not the gate itself. If your gate has no dependsOn (or its deps are deterministic script phases), the loop will re-evaluate identical input and BLOCK forever until the cap. Point the gate at the phase whose output you want reworked.
Guards against runaway loops
| Guard | Behavior |
|---|---|
| Attempt cap | retry.max (default 1). After the cap, the gate fails with its last BLOCK verdict. |
| Nested-depth cap | MAX_RETRY_DEPTH = 3. A gate whose upstream dep is itself a gate with onBlock: "retry" stops re-running upstream beyond depth 3 — prevents exponential re-execution. |
| Abort | If the run's signal is aborted, the loop breaks immediately. |
| Budget | If the run is over budget, the loop breaks immediately. |
Scoring gates (score) work the same way — the loop re-runs upstream deps and re-scores against the fresh output, sharing the depth cap and budget/abort guards.
Condition reference
The when expression language is a tiny, safe boolean grammar — no eval / Function, a hand-written recursive-descent parser. This section is the exhaustive reference; for everyday use, the common patterns table above is enough.
Operators and precedence
From loosest to tightest binding:
| Level | Operators | Notes |
|---|---|---|
| 1 (loosest) | || | left-associative, short-circuit |
| 2 | && | left-associative, short-circuit |
| 3 | ! (unary prefix) | applies to the following comparison — see note below |
| 4 | == != > < >= <= | non-associative: a == b == c is a parse error |
| 5 (tightest) | ( ) | grouping |
! binds looser than comparisons. !{steps.x.json.s} == high parses as !({steps.x.json.s} == high), not (!{steps.x.json.s}) == high. This mirrors Python's not and differs from C/JavaScript. Add explicit parentheses if you mean the latter.
Comparisons are non-associative: a == b == c is a parse error (trailing tokens). Chain with explicit &&: (a == b) && (b == c).
Comparison semantics: numeric vs string
==, !=, <, >, <=, >= dispatch by operand type:
- If both operands are numeric (a JSON
number, or a string that parses to a finite number), comparison is numeric:1.5 == "1.5"→true;"10" > "9"→true. - Otherwise, both operands are coerced to strings (
null/undefined→"") and compared lexicographically:"high" == "high"→true;"9" > "10"→false.
This means comparing a placeholder that resolves to a number against a bareword like high is always a string comparison — which is what you want for enum-like fields.
Operands
| Operand | Syntax | Resolves to |
|---|---|---|
| Placeholder | {steps.x.json.severity} | The placeholder's raw value (object, string, number, etc.) — not stringified |
| Quoted string | "high" or 'high' | The literal string. \X → literal X (so \" escapes a quote; \n → n, not newline) |
| Number | 42, 3.14, -1, 1e3 | A JS number |
| Booleans | true / false | true / false |
| Null | null | null |
| Bareword | high (unquoted) | Treated as a string "high" (always quote to be safe) |
Inside conditions, placeholders resolve to their raw value, not the stringified form used in task templates. So {steps.x.json.count} > 5 does a numeric comparison when count is a number. In a task string, the same placeholder would be pretty-printed JSON.
Truthiness
A bare operand (no comparison operator) is evaluated for truthiness:
| Value | Truthy? |
|---|---|
true | ✅ |
false | ❌ |
0, NaN | ❌ |
| any other number | ✅ |
"", "false", "0", "no", "off", "null" (case-insensitive, trimmed) | ❌ |
| any other non-empty string | ✅ |
empty array [] | ❌ |
| non-empty array | ✅ |
empty object {} | ❌ |
| non-empty object | ✅ |
null, undefined | ❌ |
String escaping
Single and double quotes are both accepted. Backslash escapes the next character literally (\" → ", \\ → \). All other \X sequences become literal X — \n becomes the two characters n, not a newline. This differs from JSON/JS escaping but is correct for condition strings, which only need quote escaping.
{
"id": "d",
"when": "{steps.x.json.label} == \"high-priority\"",
"task": "..."
}Barewords true / false / null are keywords, not strings. To compare against the literal string "true", quote it: {steps.x.json.flag} == "true".
Common parse errors
| Error | Cause | Fix |
|---|---|---|
trailing tokens | Non-associative comparison chained, e.g. a == b == c. | Group explicitly: (a == b) && (b == c). |
unterminated string | Missing closing quote. | Close the quote, or escape it. |
unterminated placeholder | { without a matching }. | Add the closing }. |
unexpected end | Operand expected but expression ended. | Complete the expression. |
missing ) | Unbalanced grouping. | Match every ( with a ). |
unexpected char | A character the tokenizer doesn't recognize. | Check for stray punctuation. |
| Phase runs despite a guard you expected to skip it | Fail-open: the expression had a parse error. | Check the phase's warnings for the error message. |
Next
Caching
Memoization — run-only, cross-run, off, fingerprints, TTL.
Budget
Run-wide cost caps and over-budget behavior.
Phase Types
The 10 phase types and every field on each.
Last updated on
Was this helpful?
Help us improve the docs or ask a question in the community.