Lesson 4. Logic without a model
The three previous lessons revolved around a model. This one does not: there will be no LLM node in the graph at all. A message arrives at the entry, a node checks a condition, two branches build different answers, and both converge into a single exit.
The main argument of this lesson is cost. Starting a workflow costs one credit, and everything else on the bill is model calls and paid external operations. There are none here, so every run costs exactly one credit out of the hundred monthly ones, no matter how many times you repeat it while debugging the condition. For comparison: one bot answer from the first lesson costs about two credits. The formula is broken down in “Credits”.
Along the way these are two skills people stumble over most often: branching and templates.
- Execute + Data
What the graph will do
The scenario is mundane: a visitor writes into the chat, and we want to answer urgent requests differently from ordinary questions — without a model, by a word in the text. The logic is deliberately simple: the point of the lesson is the mechanics, not the bot’s cleverness.
Building it
-
In the Workflows section press New Workflow and give it a name. On the empty canvas place Entry and Exit — they are in the
Input / Outputpalette section,Boundariesgroup. -
Drag in the If / Else node — the
Logic & Flowsection,Branchinggroup. Connect the output of Entry to its input. -
Open the node’s settings (select it and press the pencil) and put a single expression into the
Conditionfield:{{ 'urgent' in inputs.input | lower }}inputs.inputis what arrived at its input, i.e. the message text. The| lowerfilter lowercases it so that “Urgent” and “URGENT” count too. -
Place two Template nodes — the
Datasection,Values & Stategroup. Connect one to theTrueoutput of If / Else, the other to theFalseoutput. -
In the first one write the answer for an urgent request, in the second one for a regular one. You can insert the message itself:
Logged as urgent: {{ inputs.input }}. We will reply within an hour. -
Connect the outputs of both Template nodes to the input of Exit.
-
Press Validate, then Save.
The condition: write one expression
The Condition field is templated, and the result depends on how you write it.
Node configuration
If / Else
Parameters
The whole string is one expression: the result arrives as a boolean value.
There is one rule and it is the same for all templated fields: if the entire string consists
of a single {{ ... }}, a real value comes out — a number, a list, a yes/no. If the expression
is mixed with text, the result is always a string. Details are in
“Templates”.
For a condition this matters for the following reason. The platform also understands a mixed
form such as {{ inputs.input }} > 100: it substitutes the value and then tries to read the
result as a comparison of two numbers. As long as both sides turn out to be numbers, everything
is honest. But if the left side turns out to be text — say, “Hello” arrived — there is nothing
to compare, and the whole string counts as non-empty, that is, true. The True branch will
fire every time, and it will look as though “the condition is broken”.
Useful forms for this node: {{ 'word' in inputs.input | lower }} — substring containment;
{{ inputs.input | length > 200 }} — length; {{ variables.plan == 'paid' }} — a comparison
against a variable.
Two ways to reference a value
There are two different references in templates, and confusing them is the second most common reason for empty fields.
| Notation | What it reads |
|---|---|
{{ inputs.input }} |
The value delivered to this port by the immediate predecessor. The name is relative to each node: for the second Template node it is what came from If / Else, not from Entry. |
{{ nodes.if_1.output }} |
The value of any node in the graph by its identifier. if_1 is the very id shown in its settings next to the name. |
Next to output sit the other fields of the result. For If / Else that is
{{ nodes.if_1.branch }} — the string true or false: handy when you want the answer to
state which branch built it.
Speaking of values: If / Else changes nothing and passes its input through as is. So
{{ inputs.input }} in both Template nodes is the user’s original message.
A reference in a template does not run a node
This is the key idea, and it explains half of all empty results. {{ nodes.X.output }} is
a read from the run’s memory: it holds the outputs of nodes that have already finished. A
reference wakes nobody up and does not change the execution order — a node is started only by
an incoming edge (see “How a graph executes”).
So if you reference a node from a branch that was not taken, there will be nothing to read.
There will be no error either: an unknown name renders as an empty string, and the node
calmly finishes with a hole in the text. The hole can be found: the platform records every path
that failed to resolve into the node’s debug data under the key __undefined_refs__ — that is
the only trace of a typo in an id. Where to look at it is described in
“Templates”.
Why two branches into one input are legal
The branches exclude each other: if picks exactly one leg, and the second edge becomes
dead. The scheduler understands this and starts Exit as soon as at least one input has
fired and all the remaining ones are dead. Exit runs once and receives the value of the
branch that fired.
The wire.andjoin_partial_payload warning you saw during validation says exactly that: the
value of the second branch will not reach the exit. That is usually what you want, hence a
hint rather than an error.
The difference is exactly the question “can these two branches fire in the same run”. Mutually exclusive — a warning; simultaneous — an error.
Checking it
Open the Test tab and send two messages: one with the word “urgent”, one without. The answers should differ.
On the tab’s canvas you can see which node ran and which stayed grey. Click the skipped Template node — its card will show the reason for the skip: the condition above chose a different branch. This is the graph working normally, not a failure: the run counts as completed, not partial. The full list of reasons is in “Statuses”.
Each such run costs one credit and zero tokens — you can calmly try out different wordings of the condition.
If it did not work
| What you see | Why | What to do |
|---|---|---|
| The True branch always fires | The condition is written in the mixed form and after substitution became non-empty text rather than a comparison | Put the whole comparison inside one pair of braces: {{ inputs.input | length > 100 }} |
| Both branches fired | The branches do not come from one conditional node but run in parallel — then they do not exclude each other | Check that both edges leave the same If / Else node, from the True and False ports |
| The validator demands a barrier (wire.fanin_needs_barrier) | The branches can fire in one run — a race for the input port | Join them with a wait_all node before the shared input |
| A node is grey, marked as skipped | The condition chose another branch, or the path was cut off higher up the graph | Open the node's card in the run and read the skip reason |
| There is a hole in the answer: part of the text is empty | A name in the template did not resolve — a typo in an id or a reference to a node from a branch that was not taken | Look at __undefined_refs__ in the node's debug data; compare the id with the one in the settings |
| Curly braces show up in the answer verbatim | The field is not templated — substitution happens only in fields marked as templated | Move the expression into the Template field of a Template node |
| The andjoin_partial_payload warning | Expected for this graph: mutually exclusive branches converge into one input | Do nothing — it blocks neither saving nor publishing |