MiniGraph command grammar¶
At a glance
- This is the source of truth for the MiniGraph command language — the rules, not a tour. Tutorials teach by example; this page states the grammar so you (or an AI agent) generate commands deterministically, without inferring.
- A machine-readable form of this grammar lives at
minigraph-commands.json(ingest it; validate against it).- Driving the Playground from an agent? See the AI agent guide.
Lexical rules¶
| Element | Rule | Example |
|---|---|---|
| Node name | lowercase letters, digits and hyphen | person-name, mdm-profile, fetcher-1 |
| Node type | a descriptive label; shipped examples capitalize structural types | Root, End, Provider, Dictionary, Fetcher, Island |
| Reserved names | the root node must be named root; the end node must be end |
— |
| Property | key=value; keys may be composite (dot-bracket) |
url=http://..., mapping[]=a -> b |
| List property | a key[]=entry line appends one entry to the list key |
repeat mapping[]=... per entry |
| Multi-line value | wrap the value in triple single quotes | statement[]=''' … ''' |
| Constant | type(value) — the closed set in Constants |
text(hello), int(100), boolean(true) |
| Mapping operator | source -> target (left = source, right = target) |
input.body.id -> person_id |
| Variable substitution | {namespace.key} inside COMPUTE/IF expressions |
{book.price}, {input.body.discount} |
Namespaces¶
The per-execution state machine is addressed through these namespaces (sources and targets in mappings):
| Namespace | Meaning | Read | Write |
|---|---|---|---|
input.body / input.header |
the incoming request | ✓ | (seeded at instantiate) |
model.* |
intermediate working state | ✓ | ✓ |
output.body / output.header |
the response returned to the caller | ✓ | ✓ |
{node-name} |
a node's own properties | ✓ | ✓ |
{node-name}.result |
a skill's output | ✓ | (set by the skill) |
{node-name}.status / .error |
a skill's execution status | ✓ | (set by the engine) |
response.* |
a data Provider's raw HTTP response — used in a Dictionary node's output[] |
✓ (in a dictionary mapping) | (set by the fetch) |
result.* |
a Dictionary/Fetcher result set | ✓ | (set by the skill) |
Composite keys & arrays. Every mapping source and target addresses nested data with the dot-bracket convention — including numeric list indices, on the target side too. A numeric index in a target creates/sets that list slot, which is the natural idiom for assembling a JSON list deterministically (e.g. after a parallel fork/join):
mapping[]=fetch-one.result.profile -> output.body.profile[0]
mapping[]=fetch-two.result.profile -> output.body.profile[1]
An empty index appends: [] in a target adds one element to the end of the list — and
creates the list with that first element when it does not yet exist:
output[]=model.fetcher-one -> output.body.profile[]
output[]=model.fetcher-two -> output.body.profile[]
Data mapping is thread-safe (state-machine operations are serialized), so concurrent []
appends from parallel branches carry no racing risk — but their element order follows
completion order, which is undetermined across parallel branches. Use [] when order does not
matter; use numeric indices (above) when the order must be deterministic.
A non-leaf (interior) path maps the entire subtree, not just scalars: a source like
fetch-one.result.profile above carries the whole profile object, and response.accounts in a
Dictionary mapping carries the whole array.
An unresolvable source skips the entry — when a mapping's source key does not exist, the
target is left untouched (not nulled). Two idioms for defaults follow from this:
f:defaultValue(input.body.flag, boolean(false)) -> model.flag, or default-then-overlay
(boolean(false) -> model.flag followed by input.body.flag -> model.flag).
Constants¶
A constant is valid wherever a mapping source is (a mapping[]/input[]/output[] source, an
instantiate graph seed line). This is the closed set — no other constant or coercion form
exists (in particular, the legacy :type suffix — "simple type matching" — is deprecated; never
generate it. A : inside a Dictionary node's input[] entry is a default value, nothing
else — see Provider & Dictionary):
text(hello world)- string (verbatim, no quoting needed — spaces are preserved exactly, including leading/trailing ones inside the parentheses:
text(Hello )keeps its trailing space) int(100)/long(10000000000)- integer (non-numeric input →
-1; a decimal part is dropped) float(1.5)/double(1.5)- floating-point number
boolean(true)- boolean —
trueonly for case-insensitivetrue; anything else isfalse map(k1=v1, k2=v2)- inline map literal (values are strings)
map(config.key)- the value of an application-configuration key
file(text:/tmp/f.txt)/file(json:…)/file(binary:…)- file content as text / parsed JSON / bytes — read at mapping-evaluation time (each execution), so a changed file is picked up by the next run
classpath(text:/data/f.txt)- like
file(), resolved against the app's resource roots
Beyond constants, two further non-constant source forms are valid in mappings (the graph skills share Event Script's mapping engine):
f:plugin(args…)- a simple-plugin invocation — the modern replacement for the deprecated
:typesuffixes. Arguments accept any mapping source: constants and any state-machine path —model.*,input.*,output.*, and node namespaces such as{node}.result.{key}(engine-verified). Examples:f:concat(model.a, text(!)), generatorsf:uuid()andf:now(text(local))(current date-time at execution:iso/local/ms), arithmeticf:add(...), logicf:ternary(...), and list/map reshapersf:removeKey(list, text(key))(strip fields from every map in a list),f:listOfMap(...)(maps-of-lists → list-of-maps, order-preserving) andf:length(...)(length by type: list → element count, string → character count, bytes → byte count, null → 0). Full catalog in the Event Script syntax page $.…- a JSONPath expression over the state machine (prefer plain dot-bracket keys; JSONPath only when the query needs it)
Commands¶
Each command's exact form. Lines shown stacked are a single multi-line command (enter as one block); one-line commands are self-contained.
create node / update node¶
Multi-line. update node has the identical shape and replaces a node's definition.
with propertiesand the key lines are optional (properties act as defaults).- A node has zero or one skill, set with
skill={route}. {name}is lowercase letters, digits and hyphen (root/endreserved);{type}is a descriptive label, conventionally Capitalized (Root,Fetcher,Module— see lexical).
connect¶
One-line. Directional — connect a to b differs from connect b to a.
The {relation} is a descriptive label (e.g. done, fetch, provider) — free-form, not
interpreted for skill routing. For data-entity nodes, meaningful relationship names capture
enterprise knowledge.
A node may have multiple outgoing connections — traversal forks into parallel branches, one
per connection, and the branches execute concurrently. Synchronize them with a
graph.join barrier node; without one, traversal proceeds as each
branch completes. Data mapping is thread-safe, but parallel branches should not write the
same scalar key (the last writer wins, nondeterministically) — write to disjoint keys (e.g.
per-branch model.* variables), or append to a shared list with [], which is race-free
(element order then follows completion order — see
Composite keys & arrays).
delete¶
instantiate graph¶
Multi-line. Creates a runnable instance with optional seeded mock input. Required before
run, execute, or inspect. Alias: start.
instantiate graph
{constant} -> input.body.{key}
{constant} -> input.header.{key}
{constant} -> model.{key}
instantiate graph
int(100) -> input.body.profile_id
text(application/json) -> input.header.content-type
Seed keys may be composite (dot-bracket), so nested mock payloads seed directly:
instantiate graph
text(Peter) -> input.body.profile.name
text(100 World Blvd) -> input.body.profile.address1
Issuing instantiate graph again replaces the current instance — a fresh state machine and
cleared run marks — which is the standard idiom for a second dry-run with different input.
run / execute / inspect¶
run # traverse from root to end
execute {node} # run a single node (after instantiate)
inspect {namespace.key} # read a value from the state machine
inspect output # a whole namespace: input | output | model
inspect output.body.name # a specific composite key
Placeholder convention:
{…}in the syntax lines above (e.g.{node},{namespace.key}) marks a value you substitute — do not type the braces. Writeinspect output.body.name, notinspect {output.body.name}(a literal{output.body}is treated as the key{output→body}and resolves to nothing).
describe / list / seen¶
describe graph
describe graph {graph-id} # DISCOVERY: a deployed model's contract view
describe node {name}
describe connection {node-a} and {node-b}
describe skill {skill.route} # prints the shipped help for a skill
list nodes
list connections
list graphs # DISCOVERY: deployable graph models (extension= targets)
list flows # DISCOVERY: Event Script flows (extension=flow:// targets)
seen # nodes visited in the last run
Discovery (read-only). list graphs enumerates the graph models a
graph.extension node can delegate to — the compiled registry
united with the deployed location's models — each with its root node's purpose, so the listing
reads as living documentation. list flows enumerates the Event Script flows for
extension=flow://{flow-id}. describe graph {graph-id} then shows a deployed model's
contract view — its purpose, size, and the input.* / output.* data surface derived
from the model's own mappings — so an agent can wire the delegation's input[]/output[]
without out-of-band knowledge or trial execution.
export / import¶
exportwrites JSON tolocation.graph.temp; it addsname={name}to the root node and fails if any node is an orphan (every node must connect to ≥1 other).- The export reply includes
Described in /api/graph/model/{name}/{token}— a read-only HTTP view of the exported model.
session¶
session # show this session id + subscribers
session subscribe {id} # mirror another (primary) session's commands into yours
session unsubscribe
session reset
The topology subcommands (subscribe/unsubscribe/reset) work only from a
WebSocket-connected session. The companion REST endpoints reject them (a companion is an
assistant to a session, not a session of its own) — only the read-only session status query is
available there.
help¶
One-line. Prints the engine's shipped help page for a command, a concept, or a skill — the same
content describe skill {route} returns for a skill. Useful for in-band self-service when a
detail is not in this grammar (and file an issue when that happens — this page is meant to be
sufficient).
help # overview
help {command} # e.g. help connect, help instantiate
help {topic} # e.g. help data-dictionary, help session
help {skill-topic} # hyphenated skill form: help graph-api-fetcher, help graph-math
- Aliases:
help start→help instantiate;help clear→help delete. - An unknown topic returns "not found" — topic names are lowercase (commands by name, skills as
graph-…hyphenated).
Node types¶
root and end are structural (entry/exit). All other types are descriptive labels
validated by the node's skill, if any. Common conventional types:
| Type | Role | Typical skill |
|---|---|---|
Root / End |
entry / exit | — / often graph.data.mapper; a skill-less End is valid when an upstream node already shaped output.body |
| data-entity | passive data holder | none |
Dictionary |
external attribute definition | none (config) |
Provider |
external endpoint definition | none (config) |
Island |
the knowledge layer: links config/data-entity nodes into an entity-relationship view (isolated from traversal, never executed) — see Island | graph.island |
| (active node) | does work during traversal | a graph.* skill |
Skill → property matrix¶
Which properties each skill accepts. See the skills reference for semantics and examples; this is the at-a-glance contract.
Skill (skill=) |
Required | Optional |
|---|---|---|
graph.data.mapper |
mapping[] |
— |
graph.math |
statement[] (COMPUTE/IF/MAPPING/EXECUTE/RESET) |
for_each[], NEXT:, DELAY:, BEGIN/END |
graph.js |
statement[] (same statement model as graph.math; expressions run as full JavaScript on GraalVM) |
for_each[], NEXT:, DELAY:, BEGIN/END |
graph.api.fetcher |
dictionary[] (+ input[] whenever its dictionaries declare parameters — the usual case) |
output[] (the result set always lands at {node}.result for a later mapper), for_each[] (see Iterative fetching), concurrency (1–30, def 3), exception |
graph.extension |
extension ({graph-id} or flow://{flow-id}), input[] |
output[], for_each[], concurrency, exception |
graph.task |
task (a composable function's route name) |
input[], output[], for_each[], concurrency, exception |
graph.join |
— | — |
graph.island |
— | — |
Configuration nodes used by graph.api.fetcher — full authoring rules in
Provider & Dictionary:
| Node | Properties |
|---|---|
| Provider | url, method, feature[], input[] (targets: header.*, query.*, path_parameter.*, body.*) |
| Dictionary | provider, input[] (bare parameter names), output[] (response.* → result.*) |
Provider & Dictionary — the data-dictionary method¶
graph.api.fetcher never holds a URL itself. It names one or more Dictionary nodes (data
attributes); each Dictionary names the Provider node (endpoint definition) that supplies it.
Both are config nodes: they never execute and are referenced by name — but they must
not be left floating. Wire them into the knowledge structure under a graph.island node
(see Island — the knowledge layer): the island subgraph is the graph's
entity-relationship diagram, and no node is left unconnected.
Provider — defines the HTTP call:
create node {name}
with type Provider
with properties
purpose={description}
url={target url}
method={GET | POST | PUT | PATCH | DELETE | HEAD}
feature[]={feature flag}
input[]={source} -> {target}
- The
urlmay embed{name}path placeholders — each one is filled by aninput[]line targetingpath_parameter.{name}. Standard${config.key:default}substitution also applies (e.g.url=http://127.0.0.1:${rest.server.port:8080}/api/mdm/profile/{id}). input[]source: a constant, a Dictionary parameter name (bare), or a state-machine value (model.*). Target:header.{name},query.{name},path_parameter.{name},body.{key}— or the wholebody(e.g. to send a string or array as the request body).feature[]entries declare capabilities the calling fetcher must support (e.g. an auth mechanism). Built-ins:log-request-headers/log-response-headers— the fetcher logs the request/response headers into its node'sheadersection. An unsupported feature produces a warning fromgraph.api.fetcher(a custom fetcher may enforce it).
Dictionary — defines one data attribute retrievable through a Provider:
create node {name}
with type Dictionary
with properties
purpose={description}
provider={provider-node-name}
input[]={parameter}
input[]={parameter}:{default}
output[]=response.{path} -> result.{key}
input[]entries are bare parameter names, notsource -> targetmappings (the one exception to the mapping rule). An optional:{default}suffix supplies a default value (input[]=detail:true) — that is the only meaning of:here.output[]maps the Provider's raw HTTP response body (theresponse.*namespace) into the result set (result.{key}). The result set is what a fetcher exposes: as theresult.*source inside its ownoutput[]mappings, and as{fetcher-node}.resultto later nodes. The source path may be a leaf or an interior node — an interior path maps the whole subtree:response.profile.name -> result.nameextracts one field, whileresponse.profile -> result.profilecaptures the entire profile object andresponse.accounts -> result.account_numbersan entire array. The namespace root itself is also valid:response -> result.{key}captures the entire raw body — the way to keep a non-JSON body (an HTML page, plain text) whole.response.*addresses the response body only. The HTTP status of every executed fetch is recorded at{fetcher-node}.status(success included — a200or a301is readable there, not just failures); response headers are captured on request withfeature[]=log-response-headerson the Provider, landing at{fetcher-node}.header.response.{name}(request headers:log-request-headers→{fetcher-node}.header.request.{name}).- The fetcher never follows redirects — one Provider call is exactly one HTTP request. A
3xxanswer is a non-failure: its status and body are captured like any other response and traversal proceeds (only ≥ 400 triggers failure routing). To land on the redirect target, point the Providerurlat it directly. - A fetcher's
input[]targets must match the dictionary parameter names exactly, or execution fails. Several Dictionary nodes may share one Provider; identical calls (same provider + same input values) are deduplicated into a single HTTP request.
Worked example — fetch a person's profile by id (path parameter + JSON accept header), then expose name and address:
create node mdm-profile
with type Provider
with properties
purpose=MDM profile endpoint
url=http://127.0.0.1:${rest.server.port:8080}/api/mdm/profile/{id}
method=GET
input[]=text(application/json) -> header.accept
input[]=person_id -> path_parameter.id
create node person-profile
with type Dictionary
with properties
purpose=full profile record of a person
provider=mdm-profile
input[]=person_id
output[]=response.profile.name -> result.name
output[]=response.profile.address -> result.address
create node fetcher
with type Fetcher
with properties
skill=graph.api.fetcher
dictionary[]=person-profile
input[]=input.body.person_id -> person_id
output[]=result.name -> output.body.name
output[]=result.address -> output.body.address
Second worked example — a POST Provider: body.{key} targets build the JSON request body
(set content-type; there is no URL placeholder — the parameters travel in the body):
create node account-api
with type Provider
with properties
purpose=account management endpoint
url=http://127.0.0.1:${rest.server.port:8080}/api/account/details
method=POST
input[]=text(application/json) -> header.accept
input[]=text(application/json) -> header.content-type
input[]=person_id -> body.person_id
input[]=account_id -> body.account_id
Iterative fetching — for_each¶
A graph.api.fetcher node can execute once per element of a runtime array — the mechanism for
"fetch details for each item in a list obtained from a previous call":
create node accounts-fetcher
with type Fetcher
with properties
skill=graph.api.fetcher
dictionary[]=account-detail
for_each[]=profile-fetcher.result.accounts -> model.account_id
concurrency=3
input[]=input.body.person_id -> person_id
input[]=model.account_id -> account_id
output[]=result.detail -> model.account_details
for_each[]={array-source} -> model.{var}— the source must resolve to a list; it is typically a prior fetcher's result ({fetcher}.result.{key}— the cross-node.resultnamespace) or anymodel.*array. Multiplefor_each[]lines iterate multiple parameters in lock-step.- Wire the current element into each call with an ordinary input mapping:
input[]=model.{var} -> {dictionary-parameter}. Non-iterated inputs (likeperson_idabove) are passed unchanged to every call. concurrencybounds the parallel fan-out (1–30, default 3): the calls run in batches of that size.- Aggregation (guaranteed): each iteration's
result.{key}values are appended into a single array on this node's result set — after N iterations,result.detailabove is an array of N. Order is deterministic: batches execute in input-list order and responses join in request order, so the aggregated array preserves the source array's order regardless ofconcurrency. - Identical requests are still deduplicated into one HTTP call.
Failure routing — exception=¶
graph.api.fetcher, graph.task, and graph.extension accept an optional
exception={handler-node} property. On a failed call (HTTP status ≥ 400, or a task error):
- the node's
{node}.statusand{node}.errorare set (the engine's error record); - the node's
output[]mappings are skipped; - traversal jumps to the named handler node instead of aborting. Without
exception=, the run aborts on failure.
Wire the handler back explicitly (e.g. connect error-handler to fetcher with retry) — no node
left unconnected. The canonical bounded-retry pattern combines the pieces (see the
statement grammar):
create node error-handler
with type Decision
with properties
skill=graph.math
statement[]=RESET: fetcher, error-handler
statement[]=MAPPING: f:defaultValue(model.attempts, int(0)) -> model.attempts
statement[]=MAPPING: f:add(model.attempts, int(1)) -> model.attempts
statement[]='''
IF: {model.attempts} >= 3
THEN: recovery-node
ELSE: next
'''
statement[]=NEXT: fetcher
statement[]=DELAY: 50
The handler resets the fetcher and itself first (the placement rule above — it then runs on
every path, including the recovery jump), counts attempts (f:defaultValue + f:add on the
model.* namespace, which RESET never touches), exits to a recovery node at the bound (a taken
IF jump ends the list), otherwise jumps back and paces the retry with a delay — staying under
the engine's loop guard. If the handler also carries a defensive check on the failed node's
status, that check must come before the RESET (it reads state the reset wipes).
Island — the knowledge layer (required)¶
A graph.island node is isolated from graph traversal — it executes only to sink (the run
log shows one Executed … with skill graph.island line), so traversal never continues through it.
It is not optional decoration: the island subgraph is the graph's
entity-relationship diagram. Connecting Dictionary, Provider, data-entity, and reusable
module nodes under an island turns the graph into living documentation of enterprise
knowledge — a new joiner (or an agent) reads the connected dictionaries, entities, and modules
to discover the domain model, not just the execution path.
Convention: leave no node unconnected. The island is required whenever the graph has off-path nodes — config (Dictionary, Provider), data-entity, or reusable module nodes — wire every one of them into the knowledge structure. For a graph with none (e.g. a pure transformation), an island is encouraged: adding data-entity nodes that document the domain model (entities, fields, which fields are internal-only) turns even a small graph into discoverable enterprise knowledge.
connect root to dictionary with contains
connect dictionary to person-profile with data
connect dictionary to account-detail with data
connect person-profile to mdm-profile with provider
connect account-detail to account-api with provider
The relation labels are descriptive (free-form) — choose names that capture the real-world
relationship; contains / data / provider are the shipped conventions. Traversal is
unaffected: the island sinks, so the execution path never enters the knowledge layer.
graph.math / graph.js statement grammar¶
graph.jsshares this statement model, but its expressions run as full JavaScript on GraalVM (slower; capped at 50 instances — prefergraph.mathunless you need real JavaScript).graph.math's own expression dialect is a narrow JS-like subset — arithmetic/comparison/boolean operators only, no bitwise ops, no function calls, no variables;COMPUTEyields a double (integers serialize as e.g.8.0).
A graph.math node runs an ordered list of statement[] lines. Five statement types:
COMPUTE—COMPUTE: {var} -> {expr}- evaluate a JS-like math/boolean expression; the result is stored in this node's
resultnamespace — read it back as{this-node}.result.{var}or move it withMAPPING IF— multi-line (see below)- a boolean decision that redirects traversal to a named node
MAPPING—MAPPING: source -> target- data mapping, identical to
graph.data.mapper(no{}around source/target) EXECUTE—EXECUTE: {node-name}- run another
graph.mathnode's statements inline, in the calling node's context — anyCOMPUTEresults land in the invoking node's result namespace ({invoker}.result.{var}); the executed module's own namespace stays empty. This is the module-reuse mechanism: author a formula once in an off-path module node, and any executing node borrows it (see the note below) RESET—RESET: {node-name}- forget a node completely — guard, completion mark, state — so it can execute again (advanced; see the rules below)
Expressions use {namespace.key} substitution ({input.body.a}, {book.price}, {model.x}) —
the {…} substitution syntax is robust to hyphenated names ({unit-price} is the value of
unit-price, never a subtraction), so use communicative hyphenated names freely. A node
with only MAPPING statements is rejected — use graph.data.mapper instead. Statements run in order.
Reusable modules. A graph.math node can serve as a governed library module: author the
formula once, reading neutral model.* operands, keep the node off the execution path, and
let any traversal node borrow it with EXECUTE. The caller marshals inputs into the module's
expected model.* keys, executes, then maps its own result out:
create node addition # the library — authored once, not traversed
with type Module
with properties
skill=graph.math
statement[]=COMPUTE: sum -> {model.a} + {model.b}
create node compute # the execution-path caller
with type Compute
with properties
skill=graph.math
statement[]=MAPPING: input.body.a -> model.a
statement[]=MAPPING: input.body.b -> model.b
statement[]=EXECUTE: addition
statement[]=MAPPING: compute.result.sum -> output.body.sum
Note compute.result.sum — not addition.result.sum: the caller borrows the logic, so the
result belongs to the caller. Hang the module under the Island knowledge layer
(island -[module]-> addition) so it is documented and no node is left unconnected.
IF is a multi-line statement — this is the decision construct. An IF must be paired with
THEN: and ELSE:, or the engine aborts the run (node {name} does not have if:, then: or else:):
THEN:/ELSE:each name the node to jump to, or the keywordnext(fall through to the natural graph traversal / next statement).- A taken node-jump ends the statement list immediately — statements after it do not run. A
branch that resolves to
nextfalls through: processing continues with the following statements. Order the list accordingly (e.g. an early-exit check first; the retry logic after). - Append the whole triad as one multi-line value with
'''…'''(see lexical).
Worked example — compute the sum, then branch on a comparison so each branch fills the response:
skill=graph.math
statement[]=COMPUTE: sum -> {input.body.a} + {input.body.b}
statement[]='''
IF: {input.body.a} >= {input.body.b}
THEN: ge-path
ELSE: lt-path
'''
Traversal-control keywords — these are ordinary statement[] lines (e.g.
statement[]=NEXT: fetcher, statement[]=DELAY: 500), conventionally placed last:
NEXT: {node-name}— unconditionally jump to a node by name (it takes a node name, not a connection/relation label). Unlike a takenIFjump,NEXT:does not stop processing: the remaining statements still run, and the jump is applied after the whole list completes (the lastNEXT:wins).RESET: {node-name}[, {node-name} …]— clear the run-once guard, the completion mark, and the state of one or more nodes (comma/space-separated list). The completion mark matters for join barriers: a reset (retrying) branch stops satisfying the barrier until it re-executes successfully — and a branch that failed into itsexception=route never satisfies it in the first place (completion is success-only). Resetting a never-executed node is a safe no-op. A node may reset itself — the run-once mark is set before execution, so a self-reset survives and the node can run again. Placement rule: putRESETfirst among the action statements — it then runs on every path (a later takenIFjump would skip it) and everything the node stores afterwards (such asDELAY:'s pending pause) survives the self-wipe. The one exception: keep it after any statement that reads state it would wipe — anIFon a just-wiped variable (e.g.{fetcher.status}afterRESET: fetcher) aborts the run, so a defensive status check goes before theRESET. This enables retry loops — see Failure routing — but mind the engine's loop guard: a node executed too frequently (default >10 visits/second) aborts the traversal, so bound every retry loop and pace it withDELAY:.BEGIN/END— delimit the loop body forfor_each[]iterative execution (they are notIF-block braces) — see for_each.DELAY: {milliseconds}— pause after this node completes, deferring the walk to the next node (paces retries; simulates a slow service).
for_each[] — iterate a statement block over lists¶
The optional node property for_each[] turns part of the statement list into a loop. Each
entry has the mapping form source -> model.{var} — the RHS must be a model.* key:
- a list-valued source becomes an iteration array: its
model.{var}is rebound to element i on each pass. Multiple list entries advance in lockstep (parallel arrays) and must all have the same length (engine error otherwise). At least one entry must resolve to a list, or the node aborts ("No data mapping resolved from 'for_each' entries. LHS must be a list."). - a scalar source binds its
model.{var}once, at resolution time — before the loop runs, even when the lists are empty. - an unresolvable source removes the
model.{var}key (not left stale, not nulled).
BEGIN / END lines split statement[] into three blocks:
statement[]=… ← pre-block: runs ONCE, before the loop
statement[]=BEGIN
statement[]=… ← each-block: runs once PER ELEMENT
statement[]=END
statement[]=… ← post-block: runs ONCE, after the loop
Rules (engine-verified):
- No
BEGIN⇒ the whole statement list is the loop body. Seed accumulators in a pre-block, or the seeding re-runs on every iteration. - Iteration is strictly sequential, in list order — element 0 completes before element 1
starts. (Contrast: the fetcher's
for_eachfans HTTP calls out concurrently and only aggregates in order.) The whole loop is one node execution, so a long list does not trip the traversal loop guard. - A taken
IFjump breaks the loop: it ends the current iteration immediately, skips the remaining elements and the post-block, and routes traversal to the named node. In the pre-block it skips the loop and post-block the same way. (ANEXT:exits too, after its block completes.) AnELSE: nextfalls through to the rest of the iteration. - Empty lists are fine: the each-block runs zero times; the pre- and post-blocks still run.
- Number dialect:
COMPUTEyields doubles; thef:add/f:subtract/… simple plugins use numeric promotion — all-whole-number inputs keep exact long arithmetic (including integer division and the classic integer counters of Failure routing), while any floating-point argument promotes the whole computation to a double. Sof:addcomposes directly withCOMPUTEresults and decimal API data; accumulate with eitherf:addor a pure-COMPUTEread-back (both shown below). Tame floating-point artifacts withf:round(value, int(2))— half-up rounding applied to the number's decimal representation (1.005→1.01at 2 places). EXECUTE:inlining happens before the blocks are split, so an executed module's statements land at theEXECUTE:position and may contribute to (or delimit) the loop body.- Without
for_each[],BEGIN/ENDlines are accepted and ignored.
Worked example (engine-verified) — line totals with a running sum:
create node totaler
with type Loop
with properties
skill=graph.math
for_each[]=input.body.prices -> model.price
for_each[]=input.body.quantities -> model.qty
statement[]=MAPPING: int(0) -> model.total
statement[]=BEGIN
statement[]=COMPUTE: total -> {model.total} + {model.price} * {model.qty}
statement[]=MAPPING: totaler.result.total -> model.total
statement[]=END
statement[]=MAPPING: model.total -> output.body.total
With prices=[10,20,30] and quantities=[7,8,9] the run yields total: 500.0: the pre-block
seeds the accumulator once, each pass computes total + price*qty and writes it back to
model.total, and the post-block maps the final value out. The plugin form is equivalent —
COMPUTE: line -> {model.price} * {model.qty} then
MAPPING: f:add(model.total, totaler.result.line) -> model.total — numeric promotion carries
the COMPUTE doubles through f:add (both forms are engine-verified).
Invariants¶
Hard rules the engine enforces — violate them and generation fails:
- The root node is named
root; the end node is namedend. - A node has 0 or 1 skill.
- Node names are lowercase letters, digits and hyphen (
root/endreserved). Node types are descriptive labels — shipped examples capitalize structural types (see lexical). - Every node in the traversal path must connect to ≥1 node, or
exportfails.DictionaryandProviderconfiguration nodes are referenced by name (dictionary[]=,provider=) and are not traversed — but the convention is still no node left unconnected: wire them under agraph.islandnode (root -[contains]-> island -[data]-> dictionary -[provider]-> provider) so the graph carries the entity-relationship knowledge — see Island. - A node is executed once per run (loop guard); a
graph.mathRESETstatement is the only escape, for advanced re-execution. instantiate graphmust precederun/execute/inspect.
See also¶
minigraph-commands.json— the machine-readable form of this grammar.- AI agent guide — driving the Playground via the companion endpoint.
- Built-in skills reference — per-skill semantics and examples.
- The mapping syntax (
source -> target) and constant set are shared with Event Script; this page is self-contained — the constants above are the full set. - Event Script flow grammar — authoring the flows a
graph.extensionnode calls viaflow://{flow-id}; function AI agent guide — the composable functions agraph.tasknode calls.