NOETIC
Framework

Expressions

Compute values, route, and stop loops in JSON workflows with JSONata expressions.

Quick Example

A loop that sums the numbers from n down to 1, with no model calls:

import { AgentHarness, parseAndRunWorkflow } from '@noetic-tools/core';

const workflow = {
  version: 1,
  root: {
    kind: 'loop',
    id: 'count',
    maxIterations: 'infinity',
    body: { kind: 'compute', id: 'step', expr: "{ 'n': n - 1, 'sum': sum + n }" },
    until: { kind: 'expr', expr: 'n = 0' },
  },
};

const harness = new AgentHarness({ name: 'runner', params: {} });
const result = await parseAndRunWorkflow({
  json: workflow,
  harness,
  ctx: harness.createContext(),
  tools: [],
  input: JSON.stringify({ n: 4, sum: 0 }),
});
// result: '{"n":0,"sum":10}'

JSON workflows pass text from node to node. Expressions let a workflow read that text as structured data, compute new values from it, and branch on it exactly. Noetic uses JSONata for its expressions, the same language AWS Step Functions uses in its JSON state machines. This page covers how Noetic runs expressions; for the language itself, see the JSONata documentation.

Where Expressions Appear

PlaceShapeWhat it does
compute node{ "kind": "compute", "id": "...", "expr": "<JSONata>" }Computes the node's output from its input.
conditional route{ "expr": "<JSONata>", "target": <WorkflowNode> }Takes the route when the expression gives true.
loop until predicate{ "kind": "expr", "expr": "<JSONata>" }Stops the loop when the expression gives true.
any node's emitItems{ "emitItems": { "onStart": "<JSONata>", "onDone": "<JSONata>" } }Computes what the node emits: an event and the final fields of a custom item, or message text. See Emitting items.
connection node values{ "$expr": "<JSONata>" } in args, connection, or subjectComputes an argument of a call, the connection a node runs on ($item in a fan-out), or a verify subject. See Connections.
wait filter{ "kind": "wait", "filter": "<JSONata>" }Picks the event that ends the wait.

A conditional route carries exactly one of match (a substring of the input) or expr. Routes of both kinds may mix in one node. The expr until predicate works inside any and all like any other predicate.

What an Expression Reads

Every expression runs against one value, its input:

  • A string that parses as JSON is read as that JSON value: '{"n":1}' is an object, '42' is a number.
  • Any other string is read as the string itself.
  • A value that is not a string, such as a decide node's answers, is read as is.

For a compute node, a route, or a connection node's $expr value, that is the node's input. For an until predicate, it is the output of the loop's last iteration. A bare path such as n reads the input's n field, and $ is the whole input.

A wait node's filter is the one exception: it reads { "event": <the event>, "input": <the node's input> }.

Bindings

Two bindings are available in every expression:

  • $steps -- the outputs of nodes that have already completed in the enclosing workflow, by node id, each read the same way as the input. $steps.watch is the output of the node with id watch, so a node can use any earlier result, not only the one just before it. Before anything has run, $steps is an empty object.
  • $refId(x) -- the id inside a connection ref, since JSONata paths cannot step into $-prefixed keys. It gives no value for anything that is not a ref.

Inside a fan-out over connections, $item is the name of the connection the path runs on.

{ "kind": "call", "id": "reply", "connection": "team", "op": "chat.reply",
  "args": { "thread": { "$expr": "$steps.post.thread" }, "text": { "$expr": "'Deployed ' & $refId($steps.post.message)" } } }

What an Expression Gives

compute output

ResultOutput
stringThe string itself.
nullThe empty string.
number, boolean, object, arrayIts JSON text.
no value (a path that matched nothing)Fails with EXPRESSION_FAILED.
functionFails with EXPRESSION_FAILED.

The next node reads JSON text back as structured data, so a chain of compute nodes, or a loop whose body is one, carries a state object from step to step. A string result that is itself valid JSON ("5", "true") reads back as that value at the next node, so keep strings inside an object to carry them as strings.

emitItems hooks

An emitItems hook reads the node's input (onStart) or output (onDone) and gives what the node emits. With an item, onStart gives one event of it and onDone its final fields. Without one, onDone gives message text, written the same way as compute output. Either way, no value emits nothing instead of failing: "n % 2 = 0 ? 'even: ' & $string(n)" emits on even iterations and stays quiet on odd ones.

Tests

A route expr and an until expr are tests. A test passes on true and fails on false or on no value, so done on an input without a done field reads as false. Any other result fails with EXPRESSION_FAILED. JSONata's looser truthiness does not apply here; write the comparison out:

{ "expr": "count > 0", "target": { "kind": "callModel", "id": "summarize", "instructions": "Summarize the results." } }

Routing on State

A conditional with expr routes picks a branch by an exact test on structured input:

{
  "kind": "conditional",
  "id": "triage",
  "routes": [
    { "expr": "severity = 'high' and $count(errors) > 0", "target": { "kind": "callModel", "id": "escalate", "instructions": "Write an incident report." } },
    { "expr": "$count(errors) > 0", "target": { "kind": "callModel", "id": "fix", "instructions": "Propose fixes for the errors." } }
  ],
  "default": { "kind": "compute", "id": "ok", "expr": "{ 'status': 'ok' }" }
}

Recursion

Named workflows may call themselves through a subflow node. This one computes the same sum as the quick example by recursion:

import { AgentHarness, parseAndRunWorkflow, WorkflowDocumentSchema } from '@noetic-tools/core';

const sum = WorkflowDocumentSchema.parse({
  version: 1,
  root: {
    kind: 'conditional',
    id: 'base',
    routes: [{ expr: 'n = 0', target: { kind: 'compute', id: 'done', expr: 'sum' } }],
    default: {
      kind: 'sequence',
      id: 'recurse',
      steps: [
        { kind: 'compute', id: 'step', expr: "{ 'n': n - 1, 'sum': sum + n }" },
        { kind: 'subflow', id: 'again', ref: 'sum' },
      ],
    },
  },
});

const harness = new AgentHarness({ name: 'runner', params: {} });
const result = await parseAndRunWorkflow({
  json: sum,
  harness,
  ctx: harness.createContext(),
  tools: [],
  workflows: new Map([['sum', sum]]),
  input: JSON.stringify({ n: 4, sum: 0 }),
});
// result: '10'

Recursion depth is capped by maxRecursionDepth (default 100); a deeper call fails with WORKFLOW_RECURSION_LIMIT.

Turing Completeness

An uncapped loop ("maxIterations": "infinity"), a compute body that carries state, and an expr test that halts on it make JSON workflows Turing complete: they can run any two-counter machine. One compute expression can run a whole machine whose program is data:

(
  $i := prog[$$.pc];
  $i.op = "inc"
    ? $merge([$$, { "pc": $i.next, $i.reg: $lookup($$, $i.reg) + 1 }])
    : $lookup($$, $i.reg) = 0
      ? $merge([$$, { "pc": $i.zero }])
      : $merge([$$, { "pc": $i.next, $i.reg: $lookup($$, $i.reg) - 1 }])
)

Use it as the loop body with { "kind": "expr", "expr": "pc < 0" } as the predicate, and give the loop a state such as { "pc": 0, "a": 3, "b": 2, "prog": [...] }.

Since whether such a workflow halts cannot be known in advance, bound the run instead: abort the context, add maxCost or maxDuration beside the expr predicate in an any, keep maxRecursionDepth finite, and rely on the expression limits below.

{
  "kind": "any",
  "predicates": [
    { "kind": "expr", "expr": "pc < 0" },
    { "kind": "maxDuration", "duration": 60000 }
  ]
}

Limits

Each evaluation runs under four limits:

LimitBoundsDefault
stepsSub-expressions evaluated, counting every repeat. Doesn't use the clock.1000000
depthHow deep evaluation nests. Tail calls don't nest.500
lengthItems in one sequence, including a range.1000000
timeoutWall-clock milliseconds for one evaluation.1000

JSONata also caps a range ([1..n]) at 10,000,000 items. Going past any limit fails with EXPRESSION_LIMIT_EXCEEDED. Each value is a positive integer or Infinity, which lifts that bound; anything else fails with INVALID_EXPRESSION_LIMITS.

steps is the limit that holds on Cloudflare Workers: Workers freeze the clock during pure CPU work, so timeout can't stop an endless tail-recursive expression there. Keep steps finite on Workers.

Override the limits per run with expressionLimits on parseAndRunWorkflow, dynamicWorkflow, or workflow(); unset fields keep their defaults, which DEFAULT_EXPRESSION_LIMITS holds:

import { AgentHarness, DEFAULT_EXPRESSION_LIMITS, parseAndRunWorkflow } from '@noetic-tools/core';

declare const workflow: unknown;

const harness = new AgentHarness({ name: 'runner', params: {} });
await parseAndRunWorkflow({
  json: workflow,
  harness,
  ctx: harness.createContext(),
  tools: [],
  expressionLimits: { timeout: DEFAULT_EXPRESSION_LIMITS.timeout * 5, depth: 1000 },
});

The limits bound one evaluation, not the whole run.

Errors

CodeWhen
WORKFLOW_VALIDATION_FAILEDAn expression does not parse. Syntax is checked when the document is validated, before anything runs, so a dynamicWorkflow planner gets the error back in its revision loop.
EXPRESSION_FAILEDJSONata raised an error, a compute gave no value or a function, an emitItems hook gave a function, or a test gave something other than a boolean.
EXPRESSION_LIMIT_EXCEEDEDAn evaluation went past a limit.
INVALID_EXPRESSION_LIMITSAn expressionLimits value isn't a positive integer or Infinity.

Each message names where the expression sits, such as compute node 'step' or route 0 of conditional node 'triage', and quotes JSONata's error code and position.

Runtimes

Expressions are parsed and interpreted, never turned into code with eval or new Function, so they run wherever core runs, Cloudflare Workers included. Core pins JSONata to an exact version (2.2.2), so a stored document means the same thing until a core release says otherwise. JSONata's $now(), $millis(), and $random() read the clock and a random source; avoid them where a run must repeat exactly.

On this page