Flow Controls
Branch, loop, parallelize, and error-handle your workflows with the built-in task dispatchers.
Flow controls are the structural building blocks of a workflow — they decide which steps run, how many times, and what happens when something fails. In the editor they appear alongside components in the step picker; under the hood each one is a task dispatcher that orchestrates its child tasks.
| Flow control | What it does |
|---|---|
| Condition | Run one of two branches depending on a boolean expression. |
| Branch | Route to one of several cases by matching a value (switch-style). |
| Loop | Repeat its child steps over a list or a fixed number of iterations, with access to the current item and index. |
| Loop Break | End the enclosing Loop early from inside one of its iterations. |
| Each | Execute child steps once per item of a list. |
| Map | Transform a list by running child steps for every item and collecting the results. |
| Parallel | Run several child steps at the same time and continue when all finish. |
| Fork/Join | Split into multiple named branches that run concurrently, then join their results. |
| Subflow | Invoke another workflow as a step and use its output — see Subflows. |
| Approval | Pause the workflow until a human approves or rejects — see Human-in-the-Loop. |
| On Error | Catch a failing step and run a recovery branch instead of failing the run. |
| Terminate | End the workflow immediately from inside a branch. |
| Graph | Route through named nodes and expression-based transitions, with cycles allowed — a state machine (coming soon). |
Every flow control's inputs and outputs are documented in the flow-controls reference. Flow controls nest freely — a Loop inside a Condition inside a Fork branch is a normal workflow.
Outputs
Map returns a value today: a list of each iteration's last-task output, in source-list order — reference it downstream as ${mapNodeName}.
Coming soon. Condition, Branch, and Fork/Join return values are on the upcoming release track and are not yet available in the latest released version of ByteChef.
Once released, Condition and Branch will return the last task's output from whichever case actually ran — an empty case returns nothing (null), and a Branch case with a literal value instead of tasks returns that value directly. Fork/Join will return an object keyed branch_0, branch_1, … in branch declaration order, each value being that branch's last task's output.
Graph
Coming soon. The Graph flow control is on the upcoming release track and is not yet available in the latest released version of ByteChef.
Where Loop repeats a fixed body and Condition/Branch pick one of a small, fixed set of paths, Graph is a state machine: a set of named nodes, each with its own task list, wired together by expressions that decide which node runs next. A node's transition can point forward, sideways, or back to a node that already ran — the shape you'd otherwise hand-build out of nested Loop and Condition steps for a "retry until it passes" or "send it back for another pass" flow.
Nodes and transitions
A graph's nodes list is its state list. Each node has:
name— unique within the graph. This is the vocabulary every node'snextexpression targets, including its own.tasks— an ordinary task list, run in order once the node is entered. A node can contain any other flow control, and a graph can itself be nested inside a Condition, Loop, Fork/Join, or another graph, exactly like every other flow control.next— a formula-mode expression, evaluated against the accumulated run context once the node's tasks finish, that resolves to the name of the node to run next.
- name: triage
type: graph/v1
parameters:
startNode: classify # optional; defaults to the first declared node
maxTransitions: 100 # optional; default 100
nodes:
- name: classify
tasks: [ ... ]
next: "=${score.value} > 0.8 ? 'approve' : 'review'"
- name: review
tasks: [ ... ]
next: "=${review.verdict} == 'REDO' ? 'classify' : 'approve'" # jumps back
- name: approve
tasks: [ ... ]
# no `next` — this node is terminal, the graph completes hereA run starts at startNode (or the first declared node, if omitted) and keeps following next until it lands on a node with no next expression — that omission is what marks a node terminal; there's no separate "end" step to add. An expression that evaluates to a blank or null value has the same effect. An expression that resolves to a name that isn't one of the graph's nodes fails the run with an error naming the offending node and the value it produced, so a typo in a node name is caught at run time instead of silently dead-ending the graph.
A node can also declare an empty task list — a pure router. Its next is evaluated immediately on entry, with no work of its own in between; it still counts as one transition against the budget below.
Cycles and the transition budget
Because next can point at a node that already ran — including the node it's declared on — a graph can express review loops and retry-until-done flows directly: review jumping back to classify above is a normal transition, not an error. To keep a routing expression that never resolves to a terminal node from running forever, every graph carries a transition budget, maxTransitions (default 100). Every node-to-node hop, including a router node's immediate next, counts against it; once the budget is used up the run fails cleanly instead of looping indefinitely.
Context accumulates across the whole run the normal way: a node that runs a second time (after a cycle back to it) sees everything produced so far, including its own earlier run, and a data pill referencing a step inside that node resolves to that step's most recent execution.
Outputs
${graphName} resolves to the terminal node's last-task output — the same convention as Condition and Branch above. If the terminal node's task list is empty, or its last task produces no output, ${graphName} is null.
In the editor
- Each node gets its own lane on the canvas, similar to Fork/Join's branch columns — including empty (router) nodes, which stay addressable rather than collapsing away.
- A node's name is an inline-editable chip above its lane; names must stay unique and non-empty within the graph.
- Selecting the graph container shows every node's
nextexpression as a field in the properties panel, so transitions can be reviewed and edited without opening each node individually. - Where a
nextexpression contains plain quoted node names (e.g.'approve','review'), the editor draws routed transition edges between the corresponding lanes, plus a small badge on the node listing its possible next steps — available under the experimental layout engine. An expression whose target can't be read directly off the text — like the LLM-routed example below — renders as a dynamic badge instead of a fixed edge, since the actual destination is only known once the run reaches it. Under the standard layout engine, transitions always show as badges only; routed edges appear only with the experimental engine.
An LLM-routed example: ticket triage
A common use of Graph is letting an AI Agent step choose the next node itself instead of hand-writing the routing logic. Give the agent a structured output schema with an enum of the possible next states, and point next straight at the field the model fills in.
Take a support-ticket triage flow: an AI Agent node classifies an incoming ticket, and the graph routes to whichever node handles that outcome.
- name: triage
type: graph/v1
parameters:
startNode: classify
maxTransitions: 20
nodes:
- name: classify
tasks:
- name: classifyTicket
type: aiAgent/v1/chat
parameters:
format: SIMPLE
systemPrompt: >-
You triage incoming support tickets. Choose exactly one category and say
how confident you are in that choice.
userPrompt: "Subject: ${trigger_1.subject}\n\n${trigger_1.body}"
response:
responseFormat: JSON
responseSchema: |
{
"type": "object",
"properties": {
"decision": {
"type": "string",
"enum": ["refund", "technical", "escalate", "resolved"]
},
"confidence": { "type": "number" }
},
"required": ["decision", "confidence"]
}
next: "=${classifyTicket.decision} ?: 'escalate'"
- name: refund
tasks: [ ... ]
- name: technical
tasks: [ ... ]
- name: escalate
tasks: [ ... ]
- name: resolved
tasks: [ ... ]Setting response.responseFormat to JSON (with a response.responseSchema describing the shape) is what turns the AI Agent's reply from free text into a structured object — the model's answer is parsed against the schema, and its properties become data pills exactly like any other step's output: ${classifyTicket.decision}, ${classifyTicket.confidence}. The next expression then does nothing more than forward the model's own decision as the target node name, falling back to 'escalate' with the Elvis operator (?:) if the model produced no usable answer at all — a parsing failure or an empty response — so a bad turn from the model routes to a safe node instead of failing the whole run.
Because the schema's enum only ever produces one of refund, technical, escalate, or resolved, every value the model can return already names a real node in the graph — the fallback exists for "the model produced nothing," not for an out-of-vocabulary answer.
Gating on confidence. A router doesn't have to trust the model outright. Combine the ternary and comparison operators to defer to a human whenever confidence is low, regardless of what the model decided:
next: "=${classifyTicket.confidence} > 0.7 ? ${classifyTicket.decision} : 'human_review'"Here the model's decision is used as the target only above the confidence threshold; anything less certain always lands on a human_review node instead — worth adding to the graph with tasks that notify a person and let them pick a category before the graph continues.
Both variants read the model's own output to pick the next state, so neither one has a node name the editor can read directly off the expression text — both render as a dynamic badge on the classify node rather than fixed edges to refund/technical/escalate/resolved/human_review.
How is this guide?
Last updated on