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.
| Field | Type | Default | Description |
|---|---|---|---|
| 1. Empty check | short-circuit | → True | An empty or whitespace-only expression evaluates to True without parsing. A condition with no expression always takes its true branch. |
| 2. Length check | ≤ 500 chars | MAX_LENGTH | The raw string must be at most 500 characters or evaluation raises before parsing. |
| 3. Parse | ast.parse | mode=eval | The 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 nodes | MAX_NODES | Every 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. Interpret | tree-walk | — | A 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 isTrue, 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 raisesUnknown variableif it is not present yetinput— the whole run-input object, soinput.get('ticket')works even before any node has runstate— an alias for the entire variables dictionary, handy when a key name is awkward or you want a.get()with a default
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.
| Field | Type | Default | Description |
|---|---|---|---|
| Literals | constant | — | Strings, ints, floats, True, False, None. e.g. 70, 3.14, 'billing', True. |
| Names | state var | — | A bare identifier resolves to a public state variable, or to the state / input aliases. |
| Boolean logic | and · or · not | — | Short-circuiting: and returns the first falsy operand (or the last), or returns the first truthy operand. |
| Unary | not · - · + | — | 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 / identity | in · not in · is · is not | — | in / not in for substrings and containers; is / is not for identity, most often against None. |
| Ternary | a if c else b | — | Conditional expression. Only the taken branch is evaluated. |
| Subscript | x[k] | — | Index or key access: items[0], data['status_code']. |
| Slice | x[a:b:c] | — | Any of start, stop, step may be omitted: name[:3], reply[-1:], values[::2]. |
| Collections | list · tuple · dict · set | — | Literal collections: [1, 2], (a, b), {'k': v}, {1, 2, 3}. No comprehensions. |
| Call | f(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.
| Field | Type | Default | Description |
|---|---|---|---|
| len(x) | int | — | Length of a string, list, dict, tuple, or set. |
| str(x) | str | — | Coerce to string — the workhorse for making agent output comparable. |
| int(x) | int | — | Coerce to integer. Raises inside the run if x is not numeric-looking. |
| float(x) | float | — | Coerce to float. |
| bool(x) | bool | — | Truthiness of x. |
| min(…) | any | — | Smallest of the arguments or of an iterable. |
| max(…) | any | — | Largest of the arguments or of an iterable. |
| sum(x) | number | — | Sum of a numeric iterable. |
| abs(x) | number | — | Absolute value. |
| any(x) | bool | — | True if any element of the iterable is truthy. |
| all(x) | bool | — | True if every element is truthy (True for empty). |
| sorted(x) | list | — | A sorted list. Accepts reverse=True; key= is unavailable (lambdas are blocked). |
| round(x, n?) | number | — | Round 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.
| Field | Type | Default | Description |
|---|---|---|---|
| lower · upper | str → str | — | Case folding. lower() is the standard way to normalize before comparing. |
| strip · lstrip · rstrip | str → str | — | Trim whitespace (or given characters) from both ends, the left, or the right. |
| startswith · endswith | str → bool | — | Prefix / suffix test. Accepts a tuple of options. |
| split · rsplit | str → list | — | Split on a separator; rsplit splits from the right when maxsplit is given. |
| replace | str → str | — | Replace all occurrences of a substring. |
| count | str/list → int | — | Count non-overlapping occurrences. |
| find · index | str → int | — | Position of a substring. find returns -1 if absent; index raises. |
| join | str → str | — | Join an iterable of strings, e.g. ', '.join(tags). |
| title · capitalize | str → str | — | Title-case every word, or capitalize the first character. |
| isdigit · isalpha | str → bool | — | Whether every character is a digit / a letter. |
| get | dict → any | — | Safe key lookup with an optional default: data.get('region', 'unknown'). |
| keys · values · items | dict → view | — | Dictionary views, usable with in, len, sorted, and list(). |
.append or .format) is rejected too. There is no way to walk from a value to its class, module, or globals.Hard limits
| Field | Type | Default | Description |
|---|---|---|---|
| MAX_LENGTH | int | 500 | Maximum characters in the raw expression string. Over the limit → 'Expression longer than 500 characters'. |
| MAX_NODES | int | 200 | Maximum AST nodes after parsing. Over the limit → 'Expression too complex'. Guards against pathological nesting. |
| Pow exponent | abs ≤ 64 | 64 | For 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).
# 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.
__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'
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: ….
| Field | Type | Default | Description |
|---|---|---|---|
| Unknown variable 'x' | runtime | — | A 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 allowed | walk | — | A call to a name that is not one of the thirteen whitelisted builtins. |
| Method 'x' is not allowed | walk | — | An attribute / method not in the whitelist of twenty-two method names. |
| Access to 'x' is not allowed | walk | — | An attribute whose name begins with an underscore — all dunder and private access. |
| Name 'x' is not allowed | walk | — | A bare name beginning with a double underscore, e.g. __import__ or __builtins__. |
| Disallowed syntax: X | walk | — | An AST node type outside the allowlist — ListComp, Lambda, NamedExpr, JoinedStr, GeneratorExp, and the like. |
| Exponent too large | runtime | — | a ** b with numeric operands where |b| > 64. |
| Expression too complex | walk | — | More than 200 AST nodes. |
| Expression longer than 500 characters | length | — | The raw string exceeds MAX_LENGTH. |
| Syntax error: … | parse | — | The 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'scondition_valueor, failing that, itslabel, matched case-insensitively) - A falsy result follows edges labelled
false - An edge with no
condition_valueand nolabelalways 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.
{
"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:
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.
{
"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 atmax_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.
# 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'sretry_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.