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 signals onTrack: false or returns a next_stage). 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, on_enter, or always) A handful of memory reads — cheap
LLM routing The LLM, returning onTrack: false, an explicit next_stage, or h-chat-push-goal Every turn the LLM runs One LLM call — costly

Both run on the same per-turn loop. The engine evaluates eligible edges first; the LLM's respond({ next_stage }) and onTrack signal then layer on top. 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?"onTrack: false
  • a fallback edge.
  • "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: conditions filter eligibility, the LLM picks among eligible options. A conversation with three "escape" edges, all with conditions like `chat.stage_message_count

5`, will only present those edges to the LLM after the user has exchanged five messages in the current stage. The LLM never sees the edges that aren't yet eligible — it can't accidentally fire them too early.

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 — when the current stage finishes (most common for forward progression).
  • on_enter — when entering the source stage (used by Prerequisite edges to gate entry).
  • always — every turn, regardless of stage transition (used by Escape and Fallback edges).
  • behavior — what firing means:
  • transition — permanent move; the previous stage is left.
  • detour — push the destination onto the goal stack, with a return point baked in. Pop returns to where the user was.
  • 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, chat.stage_message_count, the current chat.conversation, 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.