Skip to content

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_type already 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 The onTrack field).
  • "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 stage loc or conversation URN. Edges crossing into a different conversation switch the active routing context.
  • condition — a JSONLogic expression evaluated against memory + chat state. null means "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 like always: 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.