Reproducible
The same inputs take the same path. Every time, not almost every time.
Every other transition in a workflow is decided by the language model. Each edge out of an agent node carries a condition in plain English — “the caller has confirmed their appointment” — and the model chooses. For a judgement about what a person meant, that is the right instrument.
It is the wrong one for a fact. order_value > 50000 is not a judgement.
Neither is “this is the third attempt” or “the caller already chose Telugu”. A
branch node evaluates those itself, with no model involved.
Reproducible
The same inputs take the same path. Every time, not almost every time.
Auditable
The winning rule and its reason are written into the run record.
Silent
No turn, no pause. The caller hears nothing between the two nodes either side.
Use a branch when the answer is already in a variable:
| Situation | Why a branch, not an edge condition |
|---|---|
| Escalate complaints above ₹50,000 | A threshold you may have to demonstrate to a regulator or a client. “The prompt usually gets it right” is not a control. |
| Split by the caller’s language | They already chose. Asking a model to re-derive it adds a way to get it wrong. |
| Stop after three attempts | A count is a count. |
| Act on a flag from a pre-call fetch | The CRM already answered. Re-asking the model is asking it to guess at your own data. |
Keep LLM-decided edges for what they are good at: did the caller agree? are they frustrated? have they finished explaining?
Add the node
Drag Branch onto the canvas from Agent Nodes in the add-node panel.
Draw the edges out first
Give each one a label — high value, standard. The labels are how rules
find their edge, so it is easier to draw them before writing the rules.
Write the rules
Each rule names a variable, a condition, a value, and the edge label to send matching calls down.
Set 'Otherwise send to'
The label of the edge every non-matching call takes. It is required — a call that reaches a branch always leaves it.
| Condition | Notes |
|---|---|
equals, does not equal | Case-insensitive. |
contains, does not contain | Case-insensitive. |
starts with, ends with | Useful for phone number prefixes. |
matches regex | For anything the rest cannot express. |
is greater than, is at least, is less than, is at most | Both sides must be numbers. 50,000 and ₹50000 both parse; fifty thousand does not. |
is one of, is not one of | Comma-separated: te, en, hi. |
is empty, is not empty | No value needed. |
is true, is false | Accepts true, "true", "yes", 1, and their opposites — a flag arrives differently from a CSV, a JSON body and an extraction. |
A variable that is missing or blank never matches — the only exception is
is empty, where absence is the question.
This matters most with does not equal. It reads as “anything but this”, so it
looks like it should match a caller whose language you never captured. It does
not:
language does not equal en → regionalA caller whose language came back blank from a failed pre-call fetch is not
a caller who chose a regional language — you simply do not know. They take the
default. If you want to catch that case, say so:
language is empty → ask themlanguage does not equal en → regionalotherwise → englishAnything already in the call’s context:
Write the bare name — order_value, not {{order_value}}.
Every branch a call passes through is recorded on the run with the rule that won and why:
{ "branch_decisions": [ { "node": "Route by value", "label": "high value", "matched": true, "reason": "high value: order_value=60000 greater_than 50000", "rule_index": 0 } ]}"matched": false means nothing matched and the call took the default. That is
the line to look for when a call went somewhere unexpected — and the reason a
deterministic branch is worth having: the answer is in the record, not in a
model’s reasoning you cannot inspect.
Each of these is caught when you save, because none of them fails loudly at run time — a broken branch quietly routes everything to the default while looking like it works.
Branch nodes are available in the TypeScript and Python SDKs, and to any assistant connected over MCP:
import { branch } from "@decibyl/sdk/typed";
const router = await wf.addTyped(branch({ name: "Route by value", rules: [ { label: "high value", variable: "order_value", operator: "greater_than", value: "50000" }, ], default_label: "standard",}));
wf.edge(router, escalate, { label: "high value", condition: "deterministic" });wf.edge(router, normal, { label: "standard", condition: "deterministic" });There is no and / or and no parentheses, on purpose. A branch node exists to
make routing legible to somebody who did not write it — an operator during an
incident, a reviewer during a tender. When you need two conditions, use two
branch nodes in a row: it reads better on a canvas than a nested expression, and
each decision gets its own line in the run record.