Skip to content

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.

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.

After a node completes, a conforming executor MUST resolve the next node using the following algorithm:

  1. Collect all outgoing edges from the completed node (from equals the current node ID).
  2. Exclude edges that have exhausted their max_iterations count (i.e., the edge has been followed max_iterations times already).
  3. If zero edges remain: execution is complete. The current node is a terminal node.
  4. If exactly one edge remains and it has no when clause: follow it unconditionally. The executor MUST NOT invoke the AI model.
  5. If every remaining conditional edge is an expression: evaluate them in the executor. The executor MUST NOT invoke the AI model.
  6. If the node sets route_by: decider and 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.
  7. Otherwise: present all remaining edge conditions to the AI model. The model evaluates each when clause against the accumulated context (workflow input + all prior node results) and selects one edge. The executor follows the selected edge.

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

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 low

A 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.

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-form summary prose, conversational rationale, debug commentary the model adds alongside the structured payload) MUST NOT reach the route evaluator.
  • MUST preserve the evals namespace on the routing view when present, so when conditions can reference priorNode.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.

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.

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'"
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.

  • 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 type or enum makes ANY expression that references it unevaluable, exists included. Only a field that is genuinely absent (and not required) reads as not existing.
  • exists is the explicit presence test and never warns. && and || short-circuit, so exists a.url && a.url != '' is safe.

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: decider node, else the AI model), which reads each edge’s description, 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.

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.properties block, 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.

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.

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_iterations edges 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 least min_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 output properties typed number, integer, boolean or string enum (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 reads input.* 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, decider or agent). A resumed run MUST replay each recorded route without asking the decider or the AI model again; replay is deterministic for recorded routes only.

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).

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

A conforming executor:

  • MUST track how many times each edge has been followed (keyed by from/to pair).
  • MUST exclude an edge from routing once it has been followed max_iterations times.
  • If excluding an exhausted edge leaves zero remaining edges, the node becomes terminal.

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 succeeded

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.

edges:
- from: gather
to: investigate
- from: investigate
to: notify

Execution: gather → investigate → notify (terminal).

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

After 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.

edges:
- from: implement
to: test
- from: test
to: implement
when: tests failed
max_iterations: 3
- from: test
to: done
when: all tests passed

The 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.