Little documentation
Guides and APIInternals

Expressions

The deterministic {{ ... }} mini-language used in LWIR and how it is parsed, validated, and evaluated.

Little Workflow: published on npm as little-workflow@0.2.0-alpha.1 (2026-10-06). Sections labeled UNRELEASED describe source-only changes. Release status

LWIR holds plain JSON, but cells that read run-time values use a string template language: "{{ input.ticketId }}". The full implementation lives in expressions.ts and is intentionally tiny — no arbitrary JavaScript, no I/O, no async.

Where Expressions Show Up

Expressions are accepted in three places inside a step:

  • step.with — ai.generate prompts (with.prompt, with.system), tool.call arguments (with.args), parallel items / itemKey, and decision when. Other with fields reach the step as written.
  • step.input — explicit per-step input maps.
  • step.cache — caching keys.

Inside a parallel step body, the executor adds item.* to the allowed roots; outside of one, item references are a validation error.

Two Template Forms

"{{ input.ticketId }}"                 // template-only form, returns the resolved value
"Ticket {{ input.ticketId }} (sev?)"  // embedded form, returns a string

resolveExpressionValue() decides at evaluation time:

100%

Template-only resolution preserves the underlying type — {{ input.candidates }} returns the actual array. Embedded resolution always returns a string and uses JSON.stringify for non-primitive interpolations.

The exception is an ai.generate step's with.prompt and with.system. A prompt is always text, so a template-only value there that resolves to an object or array is JSON-stringified by the embedded rule: "{{ steps.credit.output }}" becomes JSON text.

Allowed Roots

type ExpressionRoot = "input" | "item" | "step" | "steps" | "workflow";
  • input — the run input that passed the workflow input schema.
  • item — the current branch's item, only valid inside a parallel.steps[] body.
  • step — the current step's resolved with / input (rarely used).
  • steps — steps.<id>.output, steps.<id>.lastOutput, or steps.<id>.allVisits to pull a prior step's result.
  • workflow — workflow-level metadata (id, version, etc.).

Anything else (process, env, globalThis, secrets) is rejected at validation time with expression.invalid_root.

Multi-visit step accessors

Steps with maxVisits > 1 expose two additional accessors:

  • {{ steps.X.lastOutput }} — output of the most recently committed visit. Equivalent to {{ steps.X.output }} when maxVisits === 1.
  • {{ steps.X.allVisits }} — array of all committed visit outputs in chronological order.

Before step X has completed once, as when an earlier step in a loop reads it through a back-edge, lastOutput (and any path under it) has no value and allVisits is []. In an embedded expression a missing value becomes empty text, so "Notes: {{ steps.review.lastOutput.note }}" reads "Notes: " on the first pass. {{ steps.X.output }} on a step that has not completed is still an error.

Using {{ steps.X.output }} on a step with maxVisits > 1 is a validation error (lwir.output_ambiguous_for_multi_visit). The planner must use lastOutput or allVisits explicitly — silent degradation at runtime is rejected at compile time.

References via lastOutput or allVisits skip the usual needs-declaration requirement when the referenced step can reach the referencing step through the execution-flow graph (needs-edges plus decision-transition-edges). This back-edge-in-a-cycle pattern — where step X's output feeds the step earlier in the loop — is intentional and valid without an explicit needs entry.

Path Resolution

A path expression is a root plus a sequence of .identifier or ["literal" | <number>] segments. The grammar is hand-rolled in parsePathExpression() and forbids three poisoned identifiers:

const FORBIDDEN_PATH_SEGMENTS = new Set([
  "__proto__",
  "constructor",
  "prototype",
]);

Bracket access supports quoted strings (["customer name"]) and integer indices ([0]). readPath() then walks the value using Object.hasOwn() to prevent prototype lookups.

Operator Grammar

The evaluator handles a strict subset of boolean and comparison logic, without runaway recursion:

100%

Operators short-circuit. coalesce(a, b) swallows resolution errors so missing-step or missing-key references can fall through to the next argument.

Function Library

evaluateFunctionCall() exposes a small set of pure helpers:

FunctionSignatureNotes
coalesce(...args)args >= 2Returns the first defined, non-null value. Optional-style fallthrough.
date(value)(any) -> stringISO-8601 timestamp via new Date(...).toISOString().
hmac(key, value)(string, any) -> stringsha256:<hex> HMAC of JSON.stringify(value) keyed by key.
json(value)(any) -> stringJSON.stringify(value).
len(value)(string | array | object) -> numberString length, array length, or Object.keys().length.
lower(value) / upper(value)(any) -> stringLowercase/uppercase string conversion.
sha256(...values)args >= 1Hash of a single value or the array of values. Pair with canonical.ts.

There is no escape hatch — no js(), no eval(), no require(). Anything beyond this table fails with Unsupported expression function.

How LWIR Validation Sees Expressions

lwir.ts runs a static pass with validateExpressions() against step.with, step.input, and step.cache. It does not evaluate; it just parses each {{ ... }} and confirms:

  • the root identifier is in the allowed set
  • bracket indexing is well-formed
  • no __proto__ / constructor / prototype segments appear
  • functions are in the whitelist with the right arity
  • inside parallel.steps, item.* is allowed; outside, it isn't

Errors surface as expression.invalid_root, expression.invalid_function, expression.invalid_function_arity, expression.invalid_path, or expression.forbidden_segment.

Runtime Resolution

Just before dispatching a step, the runtime resolves the step's input with resolveExpressionValue(): step.input, else with.args. For an ai.generate step it also resolves with.prompt and with.system to text, against the same context, so "Credit: {{ steps.credit.output.score }}" reaches the model with the score filled in. The parallel executor resolves items and itemKey, and a decision step resolves each when. Other with fields are not resolved. The ExpressionContext is:

type ExpressionContext = {
  input: unknown;                                 // the run input
  item?: unknown;                                 // present inside parallel branches
  step?: unknown;                                 // resolved with/input for this step
  workflow?: unknown;                              // metadata
  steps: Record<string, { output?: unknown }>;     // prior committed step outputs
};

steps[id].output is filled from the event log — when StepCompleted records the result envelope, replay materializes output into the context. References to steps that have not committed are evaluated lazily and bubble up as Expression references incomplete step errors, which the scheduler treats as "the step isn't ready yet."

code.run Step Body — Direct Tool Access

LWIR expressions are the mechanism for passing data between steps as JSON values. They are not the mechanism for invoking tools from code. For code.run steps, the runtime injects a separate tools proxy alongside the step input:

// code.run step body signature (current milestone):
async ({ input, tools }) => output
  • input — the step's resolved input (after all LWIR expressions in step.input have been evaluated).
  • tools — a proxy over all globally-registered tools, each exposed as an async method. For example: await tools.fetchCandidates({ jobId: input.jobId }).

The tools proxy does not use expression syntax. Calls are plain JavaScript await tools.toolName(args) inside the code body. Each invocation is event-logged with caller: "code" in the harness.tool_call.started event — distinguishable from model-driven tool calls.

This matters for token economics: the code body can call tools and process their results without the model seeing the intermediate data. The model only sees the step's final output. See Harness for the full event vocabulary and tool call attribution.

Expressions ({{ ... }} templates) remain the mechanism for wiring step outputs into the next step's with and input fields — they are evaluated by the LWIR runtime before the step body runs.

Why This Is Pure

The combination of (1) a tiny grammar, (2) a strict function whitelist, (3) Object.hasOwn-only path access, and (4) no async, no I/O, no network means:

  • expressions are safe to evaluate during static validation and during replay.
  • a malicious planner draft cannot escape the sandbox by hiding eval or process.env in a prompt.
  • changing an expression always changes the canonical LWIR hash, so locked workflow versions are stable.

If you need richer logic, write a tool.call step. Tools have explicit allowed handlers, explicit schemas, and live outside the expression language.

On this page