Edges & Routing
Edges define the execution flow between Nodes in a Workflow. Each edge connects a source node to a target node, with an optional condition that determines whether the edge is followed. A condition is either natural language, which the AI model evaluates, or an expression, which the executor evaluates itself with no model call.
Fields
Section titled “Fields”| Field | Type | Required | Default | Description |
|---|---|---|---|---|
from |
string | REQUIRED | — | Source node ID. MUST reference a key in nodes. |
to |
string | REQUIRED | — | Target node ID. MUST reference a key in nodes. |
when |
string | object | OPTIONAL | none | Routing condition: natural language, or { expr } (see Expressions). |
description |
string | OPTIONAL | none | The condition in natural language. For an { expr } edge, what the next rung reads when the expression falls through (see Expressions). |
max_iterations |
integer (1 to 100) | OPTIONAL | unlimited | Maximum times this edge can be followed. Values above 100 are rejected. |
Routing Algorithm
Section titled “Routing Algorithm”After a node completes, a conforming executor MUST resolve the next node using the following algorithm:
- Collect all outgoing edges from the completed node (
fromequals the current node ID). - Exclude edges that have exhausted their
max_iterationscount (i.e., the edge has been followedmax_iterationstimes already). - If zero edges remain: execution is complete. The current node is a terminal node.
- If exactly one edge remains and it has no
whenclause: follow it unconditionally. The executor MUST NOT invoke the AI model. - If every remaining conditional edge is an expression: evaluate them in the executor. The executor MUST NOT invoke the AI model.
- If the node sets
route_by: deciderand the executor has a decider: ask it to choose among the remaining edges. If its answer passes every gate, follow that edge. The executor MUST NOT invoke the AI model for this route. - Otherwise: present all remaining edge conditions to the AI model. The model evaluates each
whenclause against the accumulated context (workflow input + all prior node results) and selects one edge. The executor follows the selected edge.
Unconditional Edges
Section titled “Unconditional Edges”An edge without a when clause is unconditional.
If a node has exactly one unconditional outgoing edge and no conditional edges (after filtering exhausted edges), the executor MUST follow it without invoking the AI model. This is a deterministic transition — no evaluation required.
edges: - from: gather to: investigateConditional Edges
Section titled “Conditional Edges”An edge with a when clause is conditional. The when field contains a natural language condition, or an expression.
edges: - from: investigate to: create_issue when: novel_count is greater than 0 AND highest_severity is medium or higher - from: investigate to: skip when: novel_count is 0, OR highest_severity is lowA conforming executor:
- MUST evaluate natural-language conditions by providing the AI model with the accumulated context (workflow input + all prior node results) and the list of conditions as choices.
- MUST NOT evaluate natural-language conditions programmatically. The AI model is the evaluator.
- MUST follow the edge selected by the AI model.
When a node has a mix of conditional and unconditional outgoing edges, the unconditional edge serves as a default path. The executor presents the conditional edges as choices, with the unconditional edge as a “none of the above” fallback.
Route-eval view of prior node results
Section titled “Route-eval view of prior node results”When a prior node declares an output JSON Schema with a non-empty properties block, a conforming executor:
- MUST scope the routing view of that node’s result data to the keys declared in
output.properties. Non-schema fields (free-formsummaryprose, conversational rationale, debug commentary the model adds alongside the structured payload) MUST NOT reach the route evaluator. - MUST preserve the
evalsnamespace on the routing view when present, sowhenconditions can referencepriorNode.evals.<name>.pass. - MUST continue to expose the full prior-node
data(including any non-schema fields) to downstream node prompts via context accumulation. The schema-strict filter is exclusively for route evaluation.
When a prior node has no output schema, or the schema declares no properties block, the executor MUST expose the full data to the route evaluator (back-compatible behavior).
Rationale: a natural-language route evaluator can be swayed by any prose it sees in the context. Declaring an output schema is the author’s contract for “these are the fields that drive routing”; the runtime treats the schema as authoritative for routing without changing what downstream nodes can read.
Nested properties
Section titled “Nested properties”The schema-strict filter is top-level. If a declared property is itself an object (type: "object" with nested properties), the entire nested value passes through unchanged. The filter does not recurse. Authors who need to hide internal fields of a nested object MUST omit them from the parent object’s output, not rely on this filter.
Expressions
Section titled “Expressions”A when written as { expr: "..." } is a deterministic condition: a small boolean expression over prior nodes’ declared output fields. The executor evaluates it itself. No model call, the same inputs always route the same way, and nothing in the routed data can steer the decision, because data values are only ever compared, never parsed or run.
edges: - from: investigate to: create_issue when: expr: "investigate.novel_count > 0 && investigate.highest_severity in ['medium', 'high', 'critical']" - from: investigate to: skip when: expr: "investigate.novel_count == 0 || investigate.highest_severity == 'low'"Syntax
Section titled “Syntax”| Form | Example | Meaning |
|---|---|---|
| Path | investigate.novel_count |
A node id, then a declared output field, then any declared nested field. |
| Literal | 'high', "high", 3, -1.5, true, null |
String (single or double quotes), number, boolean, null. |
| List | ['pass', 'no-framework'] |
Literals only. Used on the right of in. |
| Comparison | == != < <= > >= |
At most one per operand pair; no chaining. |
| Membership | a.status in ['pass', 'skipped'] |
True when the value equals one of the list items. |
| Presence | exists a.url |
True when the field is present and not null. |
| Logic | !, &&, ||, ( ) |
Comparisons bind tightest, then !, then &&, then ||: !a.n > 0 is !(a.n > 0). |
There are no function calls, no arithmetic, no assignment, and no list indexing. An expression is at most 2000 characters.
Semantics
Section titled “Semantics”- Strict types, no coercion.
==and!=compare strings, numbers, booleans, and null by value and type (3 == '3'is false).<,<=,>,>=need two numbers or two strings.!,&&,||, and the expression as a whole need booleans; there is no truthiness. - Missing never routes. A path whose value is absent (the field was not emitted, or the node did not run or did not succeed) makes the expression unevaluable, and the executor MUST log a warning. A type mismatch at runtime is treated the same way. Neither a negation nor a default edge can turn a missing field into a route: see Routing outcome.
- Invalid is unknown, not absent. A field whose value breaks its declared
typeorenummakes ANY expression that references it unevaluable,existsincluded. Only a field that is genuinely absent (and not required) reads as not existing. existsis the explicit presence test and never warns.&&and||short-circuit, soexists a.url && a.url != ''is safe.
Routing outcome
Section titled “Routing outcome”Before routing, a node whose output breaks its declared contract (a required field missing, a property outside its declared type or enum, or an x-sweny-count-of count that disagrees with its array) gets one repair request: the executor SHOULD run the node’s agent once more, naming each broken field. A required field that is still missing fails the node as before, unless an expression in the workflow reads it.
When every conditional out-edge of a node is an expression, the executor evaluates them all and:
- any is unevaluable (see Missing never routes) and at most one is true: the route falls through to the next rung (the decider for a
route_by: decidernode, else the AI model), which reads each edge’sdescription, or its expression when it has none. It is offered only the edges that could still be the answer: every unevaluable edge, plus the true edge when there is one, or else the default edge. A definitely false edge MUST NOT be offered, and an answer naming one MUST be rejected (the default edge, when offered, is the fallback; otherwise the branch ends). A dry run stops here; - exactly one is true: follow it;
- none is true: follow the node’s unconditional (default) edge if it has one;
- otherwise (none true and no default, or two or more true): fail closed. The run stops with a route evaluation error, the same as a failed model route evaluation. The executor MUST NOT guess an edge.
Load-time validation
Section titled “Load-time validation”A conforming validator MUST reject a workflow (INVALID_WHEN_EXPRESSION) when an expression:
- does not parse;
- names a node that is not the edge’s source or one of its ancestors (a node that can run before it);
- names a node without a non-empty
output.propertiesblock, or a field (or nested field) that block does not declare; - compares a declared field with a literal of another declared type, or with a string outside the field’s declared
enum; - uses a field where a boolean is needed when the field is not declared boolean.
A node’s conditional out-edges MUST be all expressions or all natural language (MIXED_EDGE_CONDITIONS). An unconditional default edge may accompany either.
Dry runs
Section titled “Dry runs”Under a dry run every node already runs read-only. Because an expression needs no model call, a dry run follows expression edges and stops only at the first node whose routing would need the AI model. Routing on a dry run stays reproducible from the node outputs alone.
Decision models
Section titled “Decision models”Write an expression first: it needs no model at all. A decision model is a fast classifier for the natural-language conditions no declared field can express. It answers one multiple-choice question per route.
A node opts in with route_by: decider (default agent). Nodes that do not opt in never consult it, so safety conditions belong in expressions. The provider (endpoint, model, key) is operator config supplied to the executor, never part of the workflow. The workflow MAY only tighten the gates:
decider: min_confidence: 0.9 # optional, 0.7 to 1, default 0.85 min_margin: 0.25 # optional, 0.1 to 1, default 0.2| Field | Type | Required | Default | Description |
|---|---|---|---|---|
min_confidence |
number | OPTIONAL | 0.85 |
Lowest confidence accepted. MUST be at least 0.7. |
min_margin |
number | OPTIONAL | 0.2 |
Lowest lead of the top label over the runner-up. MUST be at least 0.1. |
A workflow that declares decider.provider or the removed decider.mode is invalid.
For a route_by: decider node whose conditional edges are natural language, a conforming executor with a decider:
- MUST ask it with the node’s remaining out-edge targets (the default edge included, exhausted
max_iterationsedges excluded) as the only labels. - MUST follow its label only when the label is exactly one of those targets, the probabilities cover exactly those targets, are each in [0, 1] and sum to 1 within 0.01, the label is the unique most probable one, its confidence is at least
min_confidence, and its top-two margin is at leastmin_margin. - MUST fall through to the AI model, with unchanged semantics, on any other outcome: a timeout, an HTTP error, a malformed response, an unknown label, or a failed gate.
- MUST send only an explicit projection of declared typed fields: each prior node’s status, its
outputproperties typednumber,integer,booleanor stringenum(a value outside its type sent as null) and its eval verdicts, redacted and fenced as untrusted data. It MUST NOT send free-text strings, undeclared fields, the run input, tool outputs, environment values or secrets. When a condition readsinput.*or names a node with no such field, it MUST skip the decider. - MUST NOT send state anywhere but the operator’s URL and MUST NOT fall back to another provider. A URL that repository content can set MUST be limited to loopback and MUST NOT receive a credential. The executor SHOULD check the addresses a host name resolves to (not its text), connect to the checked address, and refuse link-local and metadata addresses always and private ones unless the operator’s environment allows them.
- SHOULD stop calling a decider that failed several times in a row, and cap calls per run; both are per logical run, continued across a resume.
- MUST record in the trace which rung chose each conditional route (
expr,decideroragent). A resumed run MUST replay each recorded route without asking the decider or the AI model again; replay is deterministic for recorded routes only.
Terminal Nodes
Section titled “Terminal Nodes”A node with zero outgoing edges (after filtering exhausted edges) is a terminal node. Execution ends when a terminal node completes.
A workflow MUST have at least one reachable terminal node (otherwise it would loop forever or have an unbounded cycle, which is structurally invalid).
Bounded Cycles
Section titled “Bounded Cycles”The max_iterations field limits how many times an edge can be followed, enabling controlled retry loops.
edges: - from: review to: fix when: review found issues that need fixing max_iterations: 3 - from: review to: done when: review passed, no issues found - from: fix to: reviewA conforming executor:
- MUST track how many times each edge has been followed (keyed by
from/topair). - MUST exclude an edge from routing once it has been followed
max_iterationstimes. - If excluding an exhausted edge leaves zero remaining edges, the node becomes terminal.
Self-Loops
Section titled “Self-Loops”A self-loop is an edge where from equals to. Self-loops MUST have max_iterations set. A workflow containing a self-loop without max_iterations is structurally invalid.
edges: - from: retry to: retry when: operation failed and retries remaining max_iterations: 3 - from: retry to: done when: operation succeededUnbounded Cycles
Section titled “Unbounded Cycles”A cycle in the graph where no edge in the cycle has max_iterations is invalid. A conforming validator MUST detect and reject unbounded cycles.
The detection algorithm: remove all edges that have max_iterations set (and all self-loops), then check the remaining subgraph for cycles using depth-first search. If a back-edge is found, the cycle is unbounded.
Examples
Section titled “Examples”Linear Flow
Section titled “Linear Flow”edges: - from: gather to: investigate - from: investigate to: notifyExecution: gather → investigate → notify (terminal).
Conditional Branching
Section titled “Conditional Branching”edges: - from: investigate to: create_issue when: novel_count is greater than 0 AND highest_severity is medium or higher - from: investigate to: skip when: novel_count is 0, OR highest_severity is low - from: create_issue to: notify - from: skip to: notifyAfter investigate completes, the AI model evaluates both conditions against the investigation results and selects the matching branch. Both branches converge at notify. The built-in triage workflow writes these two conditions as expressions, so the same branch is chosen with no model call.
Bounded Retry Loop
Section titled “Bounded Retry Loop”edges: - from: implement to: test - from: test to: implement when: tests failed max_iterations: 3 - from: test to: done when: all tests passedThe test → implement edge can be followed at most 3 times. After 3 retries, the edge is excluded. If all tests passed also doesn’t match, the node becomes terminal.