Skip to content

Branch Node

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.

When to use one

Use a branch when the answer is already in a variable:

SituationWhy a branch, not an edge condition
Escalate complaints above ₹50,000A 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 languageThey already chose. Asking a model to re-derive it adds a way to get it wrong.
Stop after three attemptsA count is a count.
Act on a flag from a pre-call fetchThe 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?

Set one up

  1. Add the node

    Drag Branch onto the canvas from Agent Nodes in the add-node panel.

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

  3. Write the rules

    Each rule names a variable, a condition, a value, and the edge label to send matching calls down.

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

Conditions

ConditionNotes
equals, does not equalCase-insensitive.
contains, does not containCase-insensitive.
starts with, ends withUseful for phone number prefixes.
matches regexFor anything the rest cannot express.
is greater than, is at least, is less than, is at mostBoth sides must be numbers. 50,000 and ₹50000 both parse; fifty thousand does not.
is one of, is not one ofComma-separated: te, en, hi.
is empty, is not emptyNo value needed.
is true, is falseAccepts true, "true", "yes", 1, and their opposites — a flag arrives differently from a CSV, a JSON body and an extraction.

The rule that surprises people

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 → regional

A 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 them
language does not equal en → regional
otherwise → english

Where the variables come from

Anything already in the call’s context:

  • Extraction on an earlier node — the most common source.
  • The trigger payload you posted to start the call.
  • A campaign CSV column.
  • A pre-call data fetch from your own API.

Write the bare name — order_value, not {{order_value}}.

Reading the decision afterwards

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.

What it will refuse to save

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.

  • A rule whose label matches no outgoing edge, and the same for the default.
  • A numeric comparison against something that is not a number.
  • An invalid regex.
  • Two rules with the same label — the second could never be reached.
  • A default reusing a rule’s label — matched and unmatched calls would leave by the same edge and the run log could not tell them apart.
  • Fewer than two differently-labelled edges out. A branch with one way out is not a branch.

From code

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" });

Limits

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.

Next