Reference

Expression language

Condition nodes, loop nodes, and expression checks all share one small language. It looks like Python, but it runs in a whitelisting AST interpreter that can only read your run state — never imports, file access, attribute traversal, or arbitrary calls.

Everywhere Ballast asks you for a boolean — the expression on a condition or loop node, or an expression-type check — it evaluates the same tiny language. It is deliberately not Python's eval. The evaluator parses your text into an abstract syntax tree, walks the entire tree and rejects any node type, name, attribute, or call that is not on an explicit allowlist, and only then interprets it against your run state. There is no bytecode, no__builtins__, and no way to reach the host.

How an expression is evaluated

Every expression passes through the same five stages, in order. The first stage to object raises an ExpressionError with a message that is safe to show in a step's output.

FieldTypeDescription
1. Empty checkshort-circuitAn empty or whitespace-only expression evaluates to True without parsing. A condition with no expression always takes its true branch.
2. Length check≤ 500 charsThe raw string must be at most 500 characters or evaluation raises before parsing.
3. Parseast.parseThe text is parsed in expression mode. A SyntaxError becomes 'Syntax error: …'. Statements (assignment, import, def) do not parse in this mode.
4. Whitelist walk≤ 200 nodesEvery AST node is checked against the allowlist and the node budget. Disallowed node types, dunder names, and non-whitelisted attributes are rejected here — before anything runs.
5. Interprettree-walkA recursive evaluator visits the validated tree against your state. Names resolve to state variables; only whitelisted builtins and methods can be called.

The pre-flight walk (stages 3–4) is also exposed on its own: validate_expressionruns everything except stage 5. That is how the builder validates a condition node's expression, and how an expressioncheck is checked before it is stored — a malformed expression is a 400 at save time, not a surprise at run time.

Empty means true

Because an empty expression is True, a condition node you leave blank always follows its true edge, and a loop you leave blank runs until it hits max_iterations. This is intentional — it lets you wire a graph before you have written the logic.

The evaluation scope

An expression sees the run's public state: the original run input, every agent's output_key, every tool result, and any value written by an upstream node. Each is available directly by its key name. The original run input is also available whole under input, and its top-level keys are spread into state as names in their own right.

  • score, category, draft_reply— a bare name resolves to that state variable, or raises Unknown variable if it is not present yet
  • input — the whole run-input object, so input.get('ticket') works even before any node has run
  • state— an alias for the entire variables dictionary, handy when a key name is awkward or you want a .get() with a default
The state alias is a snapshot of the same variables and is only injected when your state does not already contain a variable literally named state. It is a copy, so state does not contain a nested state key — state['score'] works, state['state'] does not.

Internal keys are invisible

Keys prefixed with __— the engine's per-loop iteration counters such as __loop_<node_id>— are stripped out before an expression ever sees them, and a bare name beginning with __ is rejected by the whitelist regardless. Everything that is not internal is fair game.

Allowed syntax

The interpreter permits exactly the constructs below. Anything not in this list — comprehensions, lambdas, f-strings, walrus, assignment, starred args, generator expressions — is rejected by the whitelist walk.

FieldTypeDescription
LiteralsconstantStrings, ints, floats, True, False, None. e.g. 70, 3.14, 'billing', True.
Namesstate varA bare identifier resolves to a public state variable, or to the state / input aliases.
Boolean logicand · or · notShort-circuiting: and returns the first falsy operand (or the last), or returns the first truthy operand.
Unarynot · - · +Logical not, negation (-x), and unary plus (+x).
Arithmetic+ - * / // % **Add, subtract, multiply, true-divide, floor-divide, modulo, power. ** exponents are capped (see Limits).
Comparison== != < <= > >=Standard comparisons, and they chain: 0 <= score <= 100 evaluates left to right, short-circuiting on the first false.
Membership / identityin · not in · is · is notin / not in for substrings and containers; is / is not for identity, most often against None.
Ternarya if c else bConditional expression. Only the taken branch is evaluated.
Subscriptx[k]Index or key access: items[0], data['status_code'].
Slicex[a:b:c]Any of start, stop, step may be omitted: name[:3], reply[-1:], values[::2].
Collectionslist · tuple · dict · setLiteral collections: [1, 2], (a, b), {'k': v}, {1, 2, 3}. No comprehensions.
Callf(x) · x.m()Only whitelisted builtins by name and whitelisted methods by attribute. Keyword arguments are allowed (e.g. sorted(xs, reverse=True)).

Whitelisted builtins

Thirteen builtins are callable by name. Nothing else is — open, eval, getattr, __import__, print, and every other name raises Function 'name' is not allowed.

FieldTypeDescription
len(x)intLength of a string, list, dict, tuple, or set.
str(x)strCoerce to string — the workhorse for making agent output comparable.
int(x)intCoerce to integer. Raises inside the run if x is not numeric-looking.
float(x)floatCoerce to float.
bool(x)boolTruthiness of x.
min(…)anySmallest of the arguments or of an iterable.
max(…)anyLargest of the arguments or of an iterable.
sum(x)numberSum of a numeric iterable.
abs(x)numberAbsolute value.
any(x)boolTrue if any element of the iterable is truthy.
all(x)boolTrue if every element is truthy (True for empty).
sorted(x)listA sorted list. Accepts reverse=True; key= is unavailable (lambdas are blocked).
round(x, n?)numberRound to n digits (default 0).

Whitelisted methods

Method calls are the only form of attribute access allowed, and only for these twenty-two names. The evaluator does not care what type the receiver is — it evaluates the receiver, confirms the method name is whitelisted, and calls it. Calling a method a value does not have (e.g. .upper() on a number) fails the node like any runtime error.

FieldTypeDescription
lower · upperstr → strCase folding. lower() is the standard way to normalize before comparing.
strip · lstrip · rstripstr → strTrim whitespace (or given characters) from both ends, the left, or the right.
startswith · endswithstr → boolPrefix / suffix test. Accepts a tuple of options.
split · rsplitstr → listSplit on a separator; rsplit splits from the right when maxsplit is given.
replacestr → strReplace all occurrences of a substring.
countstr/list → intCount non-overlapping occurrences.
find · indexstr → intPosition of a substring. find returns -1 if absent; index raises.
joinstr → strJoin an iterable of strings, e.g. ', '.join(tags).
title · capitalizestr → strTitle-case every word, or capitalize the first character.
isdigit · isalphastr → boolWhether every character is a digit / a letter.
getdict → anySafe key lookup with an optional default: data.get('region', 'unknown').
keys · values · itemsdict → viewDictionary views, usable with in, len, sorted, and list().
Any attribute name starting with an underscore is rejected before it is reached, and any non-whitelisted attribute (even a real one like .append or .format) is rejected too. There is no way to walk from a value to its class, module, or globals.

Hard limits

FieldTypeDescription
MAX_LENGTHintMaximum characters in the raw expression string. Over the limit → 'Expression longer than 500 characters'.
MAX_NODESintMaximum AST nodes after parsing. Over the limit → 'Expression too complex'. Guards against pathological nesting.
Pow exponentabs ≤ 64For a ** b with numeric operands, |b| must be ≤ 64 or the evaluator raises 'Exponent too large' instead of computing it — a cheap guard against CPU blow-ups like 2 ** 999999.

Expressions that pass

Copy-paste patterns. Each assumes the named variables exist in state (from run input, an agent's output_key, or a tool result).

Condition & loop expressions
# numeric threshold on an agent's output
int(score) >= 70

# chained comparison — a valid range in one expression
0 <= int(score) <= 100

# category routing, normalized
category.strip().lower() == 'billing'

# substring check on a free-text summary
'unusual' in str(summary).lower()

# inspect a structured tool result
raw_data['status_code'] == 200

# safe access with a default (never raises Unknown variable)
data.get('region', 'unknown') != 'unknown'

# combine conditions
int(score) >= 70 and category != 'spam'

# membership against a literal list
category in ['billing', 'refund', 'chargeback']

# a gate decision recorded by an upstream human gate
approval_review.get('approved') == True

# length + shape guard before trusting a reply
len(draft_reply) > 20 and not draft_reply.isdigit()

# ternary that produces a truthy label, then tests it
('escalate' if int(score) > 90 else '') != ''

# the input alias — available before any node runs
input.get('priority') == 'high'

# the state alias with a default
state.get('retries_left') is not None

# keyword argument on a builtin
sorted(scores, reverse=True)[0] > 90

Expressions that are rejected

Each of these is refused by the whitelist walk before evaluation. The comment shows the exact ExpressionError message.

Rejected — and why
__import__('os').system('rm -rf /')   # Name '__import__' is not allowed
open('/etc/passwd').read()            # Function 'open' is not allowed
().__class__.__bases__                # Access to '__class__' is not allowed
score.__class__                       # Access to '__class__' is not allowed
reply.format(x=1)                     # Method 'format' is not allowed
data.pop('k')                         # Method 'pop' is not allowed
[x for x in items]                    # Disallowed syntax: ListComp
lambda x: x                           # Disallowed syntax: Lambda
(x := 5)                              # Disallowed syntax: NamedExpr
f"score is {score}"                   # Disallowed syntax: JoinedStr
score = 5                             # Syntax error: … (assignment doesn't parse in eval mode)
2 ** 999                              # Exponent too large
missing_var + 1                       # Unknown variable 'missing_var'
The last two are runtime, not walk-time: 2 ** 999 parses and passes the whitelist but is refused when the power operator runs, and Unknown variable only fires when a name is actually resolved against state. Everything above them is caught by validate_expression at save time.

Error messages

Every failure is an ExpressionError. The message surfaces on the failing step (for condition and loop nodes) or in a check's detail as expression error: ….

FieldTypeDescription
Unknown variable 'x'runtimeA bare name x is not in state. Guard with state.get('x') or x if 'x' in state … when the key may not exist yet.
Function 'x' is not allowedwalkA call to a name that is not one of the thirteen whitelisted builtins.
Method 'x' is not allowedwalkAn attribute / method not in the whitelist of twenty-two method names.
Access to 'x' is not allowedwalkAn attribute whose name begins with an underscore — all dunder and private access.
Name 'x' is not allowedwalkA bare name beginning with a double underscore, e.g. __import__ or __builtins__.
Disallowed syntax: XwalkAn AST node type outside the allowlist — ListComp, Lambda, NamedExpr, JoinedStr, GeneratorExp, and the like.
Exponent too largeruntimea ** b with numeric operands where |b| > 64.
Expression too complexwalkMore than 200 AST nodes.
Expression longer than 500 characterslengthThe raw string exceeds MAX_LENGTH.
Syntax error: …parseThe text does not parse as a Python expression (includes statements like assignment or import).

Condition nodes

A condition node evaluates its expression against public state and coerces the result with bool(...). The engine then follows only the outgoing edges whose branch matches:

  • A truthy result follows edges labelled true (an edge's condition_value or, failing that, its label, matched case-insensitively)
  • A falsy result follows edges labelled false
  • An edge with no condition_value and no label always fires, regardless of the result

The step's output records what happened: { "expression": "…", "result": true }. If a condition has no matching branch for the result it produced — commonly a missing falseedge — the run simply ends at that node. The validate endpoint emits a warning for that case.

A condition node's config
{
  "id": "gate_on_score",
  "type": "condition",
  "config": {
    "label": "Score >= 70?",
    "expression": "int(score) >= 70"
  }
}
// edges out of gate_on_score:
//   { "source": "gate_on_score", "target": "auto_reply", "condition_value": "true" }
//   { "source": "gate_on_score", "target": "human_review", "condition_value": "false" }

Loop nodes

A loop node combines the same expression with a hard iteration cap. It continues only when both hold:

Loop continue condition
keep_going = bool(eval_expression(expression, state)) and iteration <= max_iterations

max_iterations defaults to 5. The engine tracks the iteration count in a hidden state key, __loop_<node_id>, which it increments as each pass begins — so on the first evaluation iteration is 1. Because the counter is prefixed with __, your expression cannot read it directly; write to a normal state variable if you need to reason about progress inside the loop body. When the loop finally stops, the engine clears the counter so a later re-entry starts fresh.

Like a condition, the loop follows true edges (back into the body) while it continues and false edges when it stops. The cap is a guarantee: even a permanently-truthy expression cannot run more than max_iterations times.

A loop that refines up to 3 times
{
  "id": "refine",
  "type": "loop",
  "config": {
    "label": "Refine until confident",
    "expression": "float(confidence) < 0.9",
    "max_iterations": 3
  }
}
// continues while confidence < 0.9 AND it has looped 3 times or fewer;
// the 'true' edge re-enters the refinement body, 'false' exits.

Always give a loop an exit

The expression is checked and the counter is checked. If your expression can never become falsy, the loop still terminates at max_iterations— but it will burn that many agent or tool calls first. Prefer an expression that genuinely converges, and keep the cap tight.

Expression checks

The third home for this language is the expression check type on evaluations. The mechanics are identical, with two additions to scope: an expression check also sees cost_usd, duration_ms, and input as names, because a check runs against the finished run rather than a mid-run state. A check that raises records expression error: … as a failed result and never disturbs the run itself.

Expression checks
# the reply must not leak an internal error string
'error' not in draft_reply.lower()

# stay under a per-run budget using the metric names
cost_usd <= 0.05 and duration_ms < 8000

# echo a field from the original input
input.get('locale') == 'en-US'

An expression is deterministic — don't retry it

Condition and loop nodes honour the node's retry_count, but re-running a pure expression against unchanged state produces the same error every time. Set retry_count: 0 on condition and loop nodes so a genuinely bad expression fails fast with its message rather than after several pointless backoffs.