API Reference

Expressions

Expressions are Station's small, deterministic, side-effect-free language for the input mappings and when guards in dynamic broadcasts. They're pure functions of the broadcast's trigger input and upstream node outputs — no I/O, no time, no randomness, no loops, no user-defined functions. The persisted form is a JSON AST; a familiar string syntax exists for the dashboard and playground and compiles to the same AST.

Expressions ship in the station-expressions package. It has no dependencies on the rest of Station and is safe to use anywhere a small, sandboxable expression language is useful.


ExprNode

Six kinds of node make up the AST:

type ExprNode =  | { kind: "ref"; path: string[] }  | { kind: "lit"; value: unknown }  | { kind: "tmpl"; parts: (string | ExprNode)[] }  | { kind: "op"; op: BinaryOp | UnaryOp; args: ExprNode[] }  | { kind: "obj"; entries: Record<string, ExprNode> }  | { kind: "arr"; items: ExprNode[] };
KindShapeDescription
ref{ path: string[] }A path lookup against the evaluation context. See Reference paths.
lit{ value: unknown }A constant. Any JSON-serialisable value: number, string, boolean, null, array, or plain object.
tmpl{ parts: (string | ExprNode)[] }Template literal. Interleaves string fragments with embedded expressions and concatenates them — every embedded value is coerced to its string form.
op{ op: string; args: ExprNode[] }Operator application. Unary ops have a single arg; binary ops have two.
obj{ entries: Record<string, ExprNode> }Object construction. Each value is an expression that evaluates into the corresponding key.
arr{ items: ExprNode[] }Array construction.

Reference paths

A ref resolves against an evaluation context with two roots: input (the broadcast's trigger input) and upstream (an object keyed by upstream node name).

PathResolves to
["input", "orderId"]The broadcast trigger input's orderId field.
["upstream", "validate", "total"]The validate node's output, field total.
["validate", "total"]Shorthand — any first segment that is not input is treated as upstream.<name>.

Missing paths return undefined rather than throwing — the evaluator never panics on a typo at runtime. The validator catches missing-property errors at save time, against the signal schemas.


Operators

OperatorArityNotes
== !=binaryStrict equality (=== / !== in JS terms).
< > <= >=binaryNumeric or string comparison.
&& ||binaryShort-circuit boolean logic.
!unaryBoolean negation.
+binaryOverloaded: if either operand is a string, the result is a string concatenation; otherwise numeric addition.
- * /binaryNumeric.

That's the full operator set. There are no bitwise operators, no modulo, no ternary, no spread. If you need them, push the logic into a signal.


String syntax

The parser accepts a familiar string form and compiles to the same AST. It's what the dashboard's expression editor produces and what the POST /expressions/parse endpoint exposes.

import { parse, stringify } from "station-expressions"; parse('input.amount > 100 && input.user.tier == "premium"');// → { kind: "op", op: "&&", args: [...] } stringify(node);// → '(input.amount > 100)'

Parsing is lossless against the AST: parse(stringify(n)) produces a structurally identical node.


Evaluation

import { evaluate } from "station-expressions"; const node = {  kind: "op", op: ">",  args: [    { kind: "ref", path: ["input", "amount"] },    { kind: "lit", value: 100 },  ],}; evaluate(node, { input: { amount: 250 }, upstream: {} });// → true

Evaluation is bounded by MAX_NODES = 10_000 — a runaway expression throws ExpressionEvalError rather than spinning. There are no closures, no captured state, no global access.


Validation

validate type-checks an expression against the schemas derived from a signal's Zod input/output types. It's what the broadcast-definition save endpoint runs before it accepts a spec.

import { validate } from "station-expressions"; validate(node, {  inputSchema: {    type: "object",    properties: { amount: { type: "number" } },  },  upstreamSchemas: {},  expectedSchema: { type: "boolean" },});// → { ok: true, errors: [] }

Behaviour around unknown and any

Schema fields with type: "any" opt out of type-checking — refs into them are treated as compatible with anything, and operator type rules are relaxed when either side is any. This is what lets you write expressions against signals whose schemas haven't been narrowed (or against the broadcast trigger input when you haven't declared an explicit schema).

Schema fields with type: "unknown", by contrast, propagate as errors when used in operator positions that require a concrete type. Use any when you genuinely don't want the validator to check; use unknown when you want it to force an upstream type narrowing.


HTTP API

The Station server exposes the expression toolkit under /api/v1/expressions for clients (mainly the dashboard) to parse, evaluate and validate without re-implementing the language.

EndpointBodyReturns
POST /api/v1/expressions/parse{ "source": "input.x > 0" }{ "node": ExprNode } on success; 400 with parse error details on failure.
POST /api/v1/expressions/evaluate{ "node": ExprNode, "context": { input, upstream } }{ "value": unknown }
POST /api/v1/expressions/validate{ "node": ExprNode, "inputSchema": SchemaField, "upstreamSchemas": {...}, "expectedSchema": SchemaField }{ "ok": boolean, "errors": [...] }

Determinism guarantees


The escape hatch

If you can't express something in this language — you need a loop, a regex, an HTTP call, a clever transform — write a code-defined signal that does the logic in TypeScript and reference it from your broadcast graph. The signal is Station's unit of arbitrary code; expressions are for connecting signals together. Don't fight the language to embed logic that belongs in a signal.