Edge conditions¶
A Hadron chatbot has two ways to decide where the conversation goes
next: deterministic edge conditions (the routing engine
evaluates a rule against memory and chat state) and
LLM-controlled routing (the LLM returns a next_stage; its
onTrack: false signal is recorded but not acted on
today). They overlap in capability but answer
different questions, and reaching for the right one changes how
predictable, debuggable, and cheap your bot is.
This page explains the model and helps you pick. For the broader conversation/stage/goal hierarchy these edges live in, see Conversation routing. For how to actually build a conditional flow in the portal, see Build a conditional conversation flow.
The two routing modes¶
| Mode | Decided by | When it fires | Cost per turn |
|---|---|---|---|
| Edge condition | The routing engine, evaluating a JSONLogic expression | Every turn the engine considers the edge (on_complete or always; on_enter is not evaluated today) |
A handful of memory reads — cheap |
| LLM routing | The LLM, returning an explicit next_stage (a stage in the current conversation) |
Every turn the LLM runs | One LLM call — costly |
Both run on the same per-turn loop. When the LLM's respond sets
next_stage, the engine moves to that stage and checks no edges that
turn; when it doesn't, the engine checks the eligible edges. (onTrack
is recorded on the chat but doesn't route today.) The two modes are not alternatives —
production bots use both.
When to use which¶
Use edge conditions when the criterion is mechanical:
- "Skip the profile-questionnaire stage if
memory.business_typealready exists." - "After 20 turns in the same stage, escalate to a fallback conversation."
- "On
chat.message_count > 0, mark the welcome as already-sent." - "Detour to billing if
memory.subscription_status === 'expired'."
These decisions don't need judgment — there's data, there's a rule, the rule fires or it doesn't. Cementing them as edges makes the bot predictable (the same state always leads to the same routing), debuggable (every fired edge is recorded in route history), and cheap (no LLM call to make the decision).
Use LLM routing when the criterion needs judgment:
- "Has the user said something that suggests they actually want
to talk about pricing instead of features?" — the model flags this
with
onTrack: false. That was designed to pair with a fallback edge, but today the flag is only recorded, and no edge can see it (see TheonTrackfield). - "Did the user just confirm they're done, or are they hedging?"
— the LLM returns
next_stage: "wrap-up"only when it reads a clear yes. - "Is the user asking about a topic this conversation can handle?" — the LLM evaluates intent against the conversation's goal description.
These calls require reading the conversation, weighing tone, and reasoning about intent. JSONLogic can't do that. The LLM can.
A useful mental model: the LLM moves between stages; the engine
fires edges. The LLM never sees edges. On a turn where it doesn't set
next_stage, the engine walks the current stage's edges in priority
order (lower first) and fires the first one whose condition holds. A
stage with an edge whose condition is chat.message_count > 5 can't
fire it before the chat has more than five messages, however the LLM
answered. (Today only edges that hand off to another conversation do
anything; see
What the chat engine acts on today.)
What an edge looks like¶
An edge attaches to a stage and points at another stage or conversation. It carries condition, timing, and behavior:
{
"target": "conversations:profile-building",
"condition": { "missing": ["memory.business_type"] },
"timing": "on_enter",
"behavior": "detour",
"label": "Fill profile if business type is missing",
"priority": 10
}
Field meanings:
target— the destination, by stagelocor conversation URN. Edges crossing into a different conversation switch the active routing context.condition— a JSONLogic expression evaluated against memory + chat state.nullmeans "always fires."timing— when the engine considers the edge:on_complete— meant for when the current stage finishes (the usual choice for forward progression). Today it behaves exactly likealways: nothing detects a finished stage, so the edge is checked on every turn where the model doesn't choose a stage. Give it a condition that says when the stage is done.on_enter— meant for when the source stage is entered (the prerequisite pattern). Not evaluated by the chat engine today, and neither is an edge to a stage — see What the chat engine acts on today.always— every turn where the model doesn't choose a stage (the escape pattern).behavior— what firing means:transition— permanent move; the previous stage is left.detour— move, and push a goal onto the goal stack. Nothing pops the stack today, so a detour does not return the user afterwards — see Goal stack.label— human-readable description shown in the editor.priority— lower fires first when multiple edges qualify on the same turn.
Variables you can read¶
Conditions read from a small set of scoped variables. Anything outside these scopes is undefined.
| Scope | What's in it |
|---|---|
memory.* |
Fields on the agent's memory the chat reads from. Cascades from edge-memory through the user's per-agent memory using deepest-wins semantics — an override on a specific node beats a value at the root. |
chat.* |
Per-chat state: chat.message_count, the current chat.conversation_name, and chat.stage. |
agent.* |
Agent config and state — locale, persona name, capabilities. |
message.data.* |
Fields the LLM extracted into the latest respond({ data }) call. |
now(), today() |
ISO timestamp and YYYY-MM-DD date for time-based conditions. |
message.data.exception and message.data.exceptions are
reserved — they hold the agent-exceptions surface (deferred
feature). Don't use these names for application data.
The portal authors a curated subset¶
The condition builder in the portal exposes a curated set of
operators — ==, !=, <, <=, >, >=, in, and the
unary missing. The runtime evaluator supports the full
JSONLogic operator set including arithmetic (+, -, *, /),
nested operators, and combinators (and, or, !).
When an edge has a condition the UI can't represent — typically because someone hand-edited a JSONLogic expression through the GraphQL API — the editor falls back to a raw JSON view so nothing is silently lost. Round-tripping through the structured editor preserves the condition exactly.
This asymmetry is intentional: the UI optimizes for ops who don't write code; the engine doesn't constrain you when you do.
What's deferred¶
The edge-conditions feature shipped its v1 in the portal builder (hadron-portal #174) and the runtime evaluator. Capabilities still on the roadmap:
- UI authoring of arithmetic and nested operators. Today the evaluator supports them; the structured editor doesn't. Use the raw-JSON fallback or the GraphQL API.
- Cross-memory conditions. A condition cannot read from a
sibling memory or another agent's memory today. Pre-compute the
value in your extraction spec and read it from
memory.*instead. - Custom operators. Agents cannot register JSONLogic predicates. Same workaround.
- Per-user time zones for
now()/today(). Both run in the agent's configured timezone (or wall-clock UTC). Per-user tz is a future spec.
Related¶
- Edge conditions reference —
exhaustive operator list, scope semantics, the cascade rule for
memory.*reads, encrypted-memory behavior, and time-based worked examples. - Conversation routing — the broader topics / conversations / stages / goals model.
- Build a conditional conversation flow — walk through building a two-stage conditional flow in the portal.
- Building a chatbot agent — agent setup the conditional flows assume is already done.