Built-in skills reference¶
At a glance
- What — the ten skills shipped with the engine. Attach one to a node (
skill=<route>) to make it active: it runs when traversal reaches the node.- They share — the
source -> targetmapping syntax with its constant set, and the same state-machine namespaces (input.*,model.*,output.*,{node}.result).- One skill per node. A node returns a decision to the engine —
next(follow the connection), a node name (jump), or.sink(pause this path).
| Skill | Use it to… |
|---|---|
graph.data.mapper |
copy/transform data between namespaces |
graph.math |
compute and branch with fast inline math/boolean |
graph.js |
DEPRECATED — backward compatibility only; use graph.math + graph.task |
graph.api.fetcher |
call external HTTP APIs declaratively |
graph.task |
invoke a composable function through its route name |
graph.extension |
delegate to a sub-graph or an Event Script flow |
graph.suspend |
persist workflow state at a human checkpoint and complete the run |
graph.resume |
restore persisted state and continue past the checkpoint without re-executing it |
graph.join |
synchronize parallel paths |
graph.island |
link the knowledge layer (dictionaries, providers, data entities) — isolated from traversal |
graph.data.mapper¶
Copies and transforms data between state-machine namespaces. The workhorse for shaping inputs and building the response.
Sources/targets use input.*, model.*, output.*, or a node name (its properties); text(...),
int(...) etc. inject constants, and f: simple plugins compute a value — a mapper is also the
natural decision node for a static decision table held on a skill-less node
(mapping[]=f:lookup(state-rules, input.body.state, text(unknown)) -> output.body.rule; see
the graph.task recipe). mapping[] entries apply in order within the node, so a
later entry may read an earlier entry's target — the chain idiom (ingest → transform → publish
inside one mapper). Example:
create node my-mapper
with properties
skill=graph.data.mapper
mapping[]=input.body.hr_id -> employee.id
mapping[]=input.body.join_date -> employee.join_date
Targets take numeric list indices too — the idiom for assembling a JSON list deterministically
(e.g. an end mapper after a fork/join):
mapping[]=fetch-one.result.profile -> output.body.profile[0]
mapping[]=fetch-two.result.profile -> output.body.profile[1]
A source may compose its key or its text from the state machine — census-2020.{model.state},
input.body.items[{model.i}], text(Hello {model.name}!) — and the value goes in verbatim, never
quoted (dynamic keys).
Range tables — a band per rule¶
When a rule is a band rather than a list of values — a loyalty tier by annual spend, an eligibility
threshold, a processing cut-off by time — the table node holds one range per band and a mapper resolves
it with f:rangeLookup(table, value, default), the first band in keys order whose range contains the
value. A range is written as text: min..max runs from min inclusive up to but excluding max, and an
omitted end is open, so adjacent bands tile a scale with no gap and no overlap. A bound is one of three kinds, and the table
decides the comparison: a canonical number makes a numeric band — the value is compared exactly as a decimal, and
a non-numeric value fails by name, as graph.math does; text in single or double quotes ('0020',
"2026-11-15") makes a quoted band, compared as written and case-sensitively — the quotes keep a zero-padded
code as text, and a quoted bound may hold the separator and the other quote character ("O'Brien"), never its
own; unquoted text (16:00, h) compares without case, which is how alphabetic bands work. Text orders by Unicode
code point on both engines. The value must be a number or text — a boolean, a list or a map fails by name, because
a band is a scale and none of them sits on one. A table that mixes kinds is refused, and so is a band given as a
list. Text compares as written, so a value must be shaped like the bounds: dates and times in ISO-8601 order and
zero-padded (2026-10-11T15:00:00Z or 2026-10-11 15:00:00), the value carrying the same parts — 9:30 is not
09:30, and a time-only band does not place a full timestamp — and one UTC offset and one precision throughout,
UTC by preference: 17:00:00+02:00 sorts after 16:00:00Z though it is the earlier instant, and 15:00:00.5Z
sorts before 15:00:00Z.
create node loyalty-tiers
with type DecisionTable
with properties
purpose=Credit card loyalty tier by annual spend
keys=[ "platinum", "gold", "silver", "standard" ]
platinum=25000..
gold=10000..25000
silver=2500..10000
standard=..2500
create node cut-off
with type DecisionTable
with properties
purpose=Same-day processing cut-off by the time of the instruction
keys=[ "same_day", "next_day" ]
same_day=00:00..16:00
next_day=16:00..
create node classify
with type Decision
with properties
skill=graph.data.mapper
mapping[]=f:rangeLookup(loyalty-tiers, input.body.annual_spend, text(unknown)) -> output.body.tier
mapping[]=f:rangeLookup(cut-off, input.body.instructed_at) -> output.body.processing
9999.99 is silver and 10000 is gold; 16:00 itself is next day. A band that ends at a value is
written with the next value as its upper bound (poor=300..580, fair=580..670) and the top band is left
open (excellent=800..). A null value or a miss takes the default, so an absent request field never
aborts the run.
The product owner reads the bands on the graph, and a new threshold is a graph change, never code.
The bounds of a band are data too. f:minRange(range, default) and f:maxRange(range, default) read one
range in the same notation and return its lower or upper bound, typed by the notation — a number written
without a decimal point is a long, any other number is its exact decimal as text (the high-precision rule of
DECIMAL), a text bound is text as written, a quoted one without its quotes — and an open end answers the
optional default, or null when there is none, so the null-source rule leaves the target unset. A dynamic key reads
the band the lookup chose:
create node locate
with type Decision
with properties
skill=graph.data.mapper
mapping[]=f:rangeLookup(loyalty-tiers, input.body.annual_spend, text(unknown)) -> model.tier
mapping[]=f:minRange(loyalty-tiers.{model.tier}, int(0)) -> output.body.tier_from
mapping[]=f:maxRange(loyalty-tiers.{model.tier}) -> output.body.tier_to
mapping[]=f:maxRange(cut-off.same_day) -> output.body.same_day_until
tier_from is 10000 for gold and the default 0 for standard (the lower end of ..2500 is open);
tier_to is 25000 for gold and stays unset for platinum (25000.. has no upper bound); same_day_until
is the text 16:00. On a miss — no spend in the request — model.tier is the default unknown, loyalty-tiers.unknown
is nothing, and a null range answers null, never the default, so tier_from and tier_to stay unset and the run
never aborts (an unknown tier never reads as "from 0"). A graph.math node reads a bound the same way (MAPPING: f:maxRange(loyalty-tiers.{model.tier})
-> model.tier_to) and computes with it (DECIMAL: to_next_tier -> {model.tier_to} - {input.body.annual_spend}).
A range is refused exactly when rangeLookup would refuse the band — malformed (a quoted bound holding its own
quote character included), a list, mixing kinds, or empty: 3..3 holds nothing, since the upper bound is excluded — and the lookup checks every
band when it reads a table, whatever the value.
A decimal bound stays text so that nothing is rounded. When a floating-point number is wanted in the output, convert it in a second mapping, since a plugin argument is never another plugin:
create node fee-bands
with type DecisionTable
with properties
keys=[ "low", "mid", "high" ]
low=0..2.5
mid=2.5..7.5
high=7.5..
mapping[]=f:maxRange(fee-bands.mid) -> model.mid_to
mapping[]=f:double(model.mid_to) -> output.body.mid_to
model.mid_to is the text 7.5 and output.body.mid_to the number 7.5. A zero-padded code table is written
with quotes — legacy='0000'..'1000', current='1000'.. — so 0450 is placed as text and
f:minRange(product-codes.current) is the text 1000, never the number.
Keyed tables — a value per key¶
f:lookup answers which rule lists a value. When the answer is a value per key instead — a
population by state, a rate by code — the table node holds one KEY=value line per key, and a mapper
reads it with a dynamic key:
create node census-2020
with type DataTable
with properties
purpose=Resident population of each state on April 1, 2020
source=U.S. Census Bureau, 2020 Census apportionment results
CA=39538223
NY=20201249
TX=29145505
create node lookup-census
with type Lookup
with properties
skill=graph.data.mapper
mapping[]=f:long(census-2020.{model.state}) -> model.population
Like a decision table, the node is graph data that the product owner certifies, wired under the
island. Three properties of the read to design around: the composed key is case-sensitive
(f:lookup is not), a key the table does not hold resolves to null, and a property value is
text ("39538223"). A numeric conversion turns null into -1, not an error (f:long(null) is
-1), so refuse an unknown key before converting. A graph.math gate does it in two statements,
and its else branch returns a 404 from a refusal mapper (int(404) -> output.status):
statement[]=MAPPING: f:notNull(census-2020.{input.body.state}) -> model.known
statement[]='''
IF: {model.known}
THEN: lookup-census
ELSE: not-found
'''
graph.math¶
Fast inline math and boolean evaluation for computation and decision-making. This is the skill
for inline compute/branch (never reach for the deprecated graph.js — pair graph.math
with graph.task for anything the dialect cannot express). Statements run in order;
seven types:
| Statement | Purpose |
|---|---|
COMPUTE |
evaluate a math expression → the node's result (a number; an expression with a comparison or boolean operator yields a boolean) |
DECIMAL |
the high-precision COMPUTE: evaluate a math expression with exact decimal arithmetic → the node's result as a canonical decimal string (details) |
CONDITION |
evaluate a boolean expression → the node's result, declared as a boolean whatever operators it carries (CONDITION: ok -> {model.a} < {model.b}, CONDITION: same -> {model.flag}) |
IF |
boolean decision → jump to a node (THEN/ELSE) |
MAPPING |
data-map source → target (no curly braces) |
EXECUTE |
run another graph.math node inline — results land on the caller ({invoker}.result.*), making this the module-reuse mechanism (details) |
RESET |
forget a node completely (guard, completion mark, state) so it can run again |
skill=graph.math
statement[]=COMPUTE: amount -> (1 - {input.body.discount}) * {book.price}
statement[]='''
IF: (1 - {input.body.discount}) * {book.price} > 5000
THEN: high-price
ELSE: low-price
'''
{variable} resolves a value from input.*, model.*, or a node property into the expression.
An IF returning a node name overrides natural traversal; returning next keeps it.
NEXT:/DELAY: control flow and timing. Every statement command resolves
{dynamic variables} — jump targets (NEXT:, THEN:/ELSE:), RESET: entries and DELAY:
values included — which is what makes the generic error handler possible
(NEXT: {error.source} retries whichever node routed here;
failure routing).
Iterating lists (for_each[]): each source -> model.{var} entry whose source is a list
becomes an iteration array (parallel lists advance in lockstep and must agree on length; scalars
bind once; an unresolvable source removes the key). BEGIN/END split the statements into
pre-block (once) / each-block (per element) / post-block (once) — without BEGIN the whole
list is the loop body. Iteration is strictly sequential in list order, inside one node
execution; a taken IF jump inside the loop body ends the whole walk — the current iteration,
every remaining element and the post-block — and routes traversal to the target, because an IF
is a traversal jump, not a per-row branch. Write per-row rules as arithmetic gates
(flag = predicate; hit = open * flag; bucket += amount * hit) and keep IF for the decision
that follows the loop. Numeric accumulators work with either f:add (numeric promotion:
all-whole stays exact long, any decimal promotes to double) or a pure-COMPUTE read-back.
A MAPPING onto model.x[] appends; the list is not cleared between executions of the same
instance, so a walk that accumulates a list reseeds it in the pre-block
(MAPPING: f:json(text([])) -> model.codes). Full rules + worked example:
for_each.
The expression dialect¶
Everything a COMPUTE, CONDITION or IF expression may contain (a DECIMAL statement takes a narrower set). The engine parses it with its
own evaluator — a narrow JS-like subset, not a JavaScript runtime — so the dialect is a closed
set. This list is the whole dialect: an operator, function or constant not listed here is rejected
by name, never silently accepted.
- Literals — numbers
42,3.14,.5,1e-5; strings'text'or"text"(escapes\',\",\\,\n,\t); booleanstrue,false. - Variables —
{namespace.key}substitutions only:{input.body.qty},{model.total},{book.price},{check.result.eligible}. They are resolved into the text before it is parsed: a number or boolean as itself; a text value as a quoted string literal in a boolean context (IF,CONDITION, or aCOMPUTEcarrying a comparison or boolean operator), so{model.state} == 'CA'compares text. An unresolved selector fails by name before evaluation (Unknown identifier: model.threshold (unresolved variable in …)). There is no assignment and no user-defined variable. - Operators, tightest-binding first:
**exponent — right-associative (2 ** 3 ** 2is512); the strict JavaScript rule applies, so a unary operand needs parentheses:-(2 ** 2), never-2 ** 2(a parse error).- unary
+,-,!—!negates truthiness. *,/,%— multiply, divide, remainder; a division by zero fails by name.+,-— add, subtract;+concatenates when either side is a string ('id-' + 7isid-7).<,<=,>,>=— two numbers, or two strings compared lexically (ISO-8601 timestamps compare correctly). A string that is a canonical number compares as a number — plain notation: an optional minus sign, digits without leading zeros, an optional fraction — so'9.5' < '10.25'compares 9.5 with 10.25, exactly, at any length.==,!=— same type on both sides, with the same exception:'200' == 200,200 == '200'and'200' == '200'are the same comparison, and'1.0' == '1'is true. Text that is not a canonical number ('007','1e3','abc') keeps the strict rules: two strings compare as text, and200 == 'abc'fails (Type mismatch for equality).&&, then||— short-circuit; a number or string operand is truthy the JavaScript way (0and''are false).test ? a : b— the ternary, lowest precedence;( … )groups.
- Functions — one argument:
sin,cos,tan,asin,acos,atan,sqrt,abs,floor,ceil,round,log(natural),log10,exp; two:pow(x, y); any number:min(a, b, …),max(a, b, …); none:random(). Every function is also reachable underMath.(Math.pow(2, 3)). A wrong arity fails by name (Function pow expects 2 args, got 1); an unlisted name failsUnknown function: hypot. - Constants —
PI,E(alsoMath.PI,Math.E). - Not in the dialect — bitwise and shift operators (
&,|,^,~,<<), assignment (=), user identifiers, user-defined functions, arrays, objects, string methods. Each is a parse error or anUnknown identifier/Unknown functionfailure; anything richer than this list belongs in agraph.taskfunction.
Numbers and booleans. The dialect's rules, each enforced by a named failure rather than a silent value:
- A boolean is not a number. A boolean where arithmetic, a
</>comparison or a function argument needs a number fails naming the selector (Boolean operand: model.flag (true) in '{model.flag} + 1' …), and so does aCOMPUTEwhose whole result is a boolean variable. JSONtruein a numeric slot therefore never computes as1. Equality type-checks its two sides (a string that is a canonical number counts as a number). Assert a slot's type withf:validate(input.body.x, text(x; Double; required; evaluate))when the request is untrusted; store a decision withCONDITION. COMPUTEyields a boolean when the expression carries a comparison or boolean operator (<,>,==,!,&&,||) — it evaluates the question you wrote. UseCONDITIONto say so in the statement, and keepCOMPUTEfor amounts.- A misspelled or unsupported function fails by name (
Unknown function: mn). - Arithmetic is IEEE double. An overflow to infinity, a division by zero and a NaN each fail
naming the operator (
Arithmetic overflow in '*' (result Infinity),Division by zero or arithmetic overflow in '/') instead of traveling on; integers beyond 2^53 lose precision, androundis half up, away from zero (round(-2.5)is-3), the same asf:round.COMPUTEreturns a double, so an integer result serializes as e.g.8.0. Money that needs exact decimal arithmetic or a stated rounding mode belongs in aDECIMALstatement, the high-precisionCOMPUTE;COMPUTEstays floating point.
Gotchas: a node runs once (guard against loops) unless you RESET it — an advanced,
use-with-care feature; a node may not contain only MAPPING statements (use the data mapper).
For anything richer than the dialect, use graph.task (a composable function).
The DECIMAL statement¶
DECIMAL is the high-precision COMPUTE: exact decimal arithmetic whose result is a canonical decimal string, and COMPUTE is untouched. COMPUTE still computes in binary floating point, and a graph that never says DECIMAL keeps its arithmetic; two rules reach it all the same, and both are READ items at release: a string that is a canonical number now compares as a number in COMPUTE, IF and CONDITION ('9.5' > '10.25' is false, '1.0' == '1' is true), and round is half up, away from zero (round(-2.5) is -3). Use DECIMAL for money, rates and anything a double would round.
skill=graph.math
statement[]=DECIMAL: fee -> {input.body.amount} * {input.body.rate}
statement[]=DECIMAL: rounded -> round({price.result.fee}, 2, HALF_UP)
statement[]=DECIMAL: total -> {input.body.qty} * {price.result.rounded}
- The result is a canonical decimal string, stored at
{node}.result.{var}: plain notation, never scientific, the computed scale kept (round(10.5, 2, HALF_UP)is"10.50"), and a zero of any scale written"0". It is a string on purpose. The state machine is saved bygraph.suspendand restored bygraph.resume, and every event hop serializes it; a string is the same after as before, where aBigDecimalwould come back a string and a smallLonganInteger. It is a JSON string in the response too, so no parser turns it into a double. - Numbers or strings: a conscious decision. A decimal may arrive as a string (
"0.0375") or as a JSON number (0.0375), and both give the same answer. A JSON number is a double, andDECIMALconverts it through the shortest decimal text it prints as, at its minimal scale (5.0E-4becomes0.0005,100.0becomes100). That is exact over the text it received, but a double that was already computed in floating point is only as exact as that computation:COMPUTE: 1.005 * 100is100.49999999999999, and rounding that in aDECIMALstatement gives100where the exact100.5gives101; and a JSON number longer than a double holds (about 15 to 17 digits) was rounded by the parser before any statement ran. So send money as strings, keep aCOMPUTEresult out of aDECIMALstatement, and use a number only for a value you trust to be a typed decimal, such as a rate in a request. To insist on strings, assert the type:f:validate(input.body.rate, text(rate; String; required)). Whole numbers are exact either way. - Arithmetic.
+ - *are exact (a sum keeps the larger scale, a product adds the scales);/never truncates — the exact quotient when it terminates, otherwise 34 significant digits rounded half-even;%is the remainder;**andpow(x, n)take a whole-number exponent from -999 to 999.abs,floor,ceil,minandmaxare exact (minandmaxreturn the first of equal values).+always adds. - Rounding is always explicit:
round(x, scale, mode)with modeHALF_UP,HALF_EVEN,HALF_DOWN,UP,DOWN,CEILINGorFLOOR, written bare or quoted.round(x)andround(x, scale)fail by name. - What cannot be exact is refused by name:
sqrt,log,log10,exp,sin,cos,tan,asin,acos,atan,random()and the constantsPIandEfail in aDECIMALstatement. Keep that step in aCOMPUTE, or use agraph.taskfunction. - A
DECIMALstatement computes a number. A comparison is allowed only inside a ternary test ({model.total} > 100 ? 100 : {model.total}); a boolean result fails by name — useCONDITION. - Comparing decimals in
IFandCONDITIONneeds nothing special: a string that is a canonical number compares as a number, soIF: {price.result.rounded} > 100andIF: {price.result.rounded} > '99.5'both compare numbers (see the dialect). - In an Event Script flow the same arithmetic is the
f:decimalAdd,f:decimalSubtract,f:decimalMultiply,f:decimalDiv,f:decimalMod,f:decimalRoundandf:decimalCompareplugins, which answer the same canonical strings (see the flow schema reference). COMPUTEon a decimal string computes in binary floating point, exactly as it does today, and aCOMPUTEresult is a double. Nothing stops you; useDECIMALfor money.
Transaction patterns¶
What settles a transaction is written on the graph, with no function. The worked loop — line totals, an exact running sum, rounding at the currency scale — is in the command reference.
| Pattern | Where it lives |
|---|---|
| Extend a line, sum lines, tax, discount, FX, cap, floor, threshold | DECIMAL with for_each, min/max, IF |
| Equal split, the residual to one named account | DECIMAL: round(amount / n, 2, HALF_DOWN), then amount - share * n |
| A fixed schedule of marginal bands | DECIMAL, unrolled with min and max, one line per band, so the product owner reads the schedule on the graph |
Simple interest, when days is already a number |
DECIMAL: round(principal * rate * days / basis, scale, HALF_UP) |
| Actual/360, 30/360 or business days | a graph.task function computes days; the interest formula is then DECIMAL |
Bands that arrive with each request, "the residual to the last line", IRR, NPV, fractional pow, exp, log |
a graph.task function — the result is not exact, or the data is not graph data |
- Rates: a percent is
rate / 100, a basis point israte / 10000, and a tax-inclusive amount isround(gross * rate / (1 + rate), scale, HALF_UP). The scale can itself be data (0,2,3). - A per-line exception that must not break the loop is a
CONDITION, thenDECIMAL: tax -> {node.result.taxable} ? round(...) : 0. A takenIFinside afor_eachends the walk, so use it for the decision that stops the walk, never for a per-line branch. - A zero is
"0", whatever its scale:round(0.004, 2, HALF_UP),1.50 - 1.50and0.00all store"0", and"0" + "1.50"is"1.50": the next scaled addend restores the scale. A final balance of zero is"0"; showing"0.00"is a presentation step, not a second stored scale. Seed an accumulator withtext(0);int(0)works too, because a whole number is exact. - The remainder follows the dividend's sign:
-7 % 3is"-1". An allocation that wants a non-negative remainder says so explicitly. - The decimal plugins work in a mapping as well:
MAPPING: f:decimalAdd(model.total, text(0.10)) -> output.body.xin agraph.mathnode, or the same line in agraph.data.mapper. - Inputs: send money and rates as strings; keep a
COMPUTEresult out of aDECIMALstatement; assert the type withf:validate(input.body.rate, text(rate; String; required))when the caller must. A JSON number longer than a double holds, and a value already computed in floating point (COMPUTE: 1.005 * 100is100.49999999999999), is not repaired byDECIMAL.
graph.js¶
DEPRECATED (2026-09) — do not use in new graphs; AI agents must not use it at all. It is kept for backward compatibility while existing field applications refactor off it. Two reasons: (1) security — evaluating JavaScript at runtime is code injection by another name and fails enterprise security review; (2) a reported silent-correctness defect — a field agent exercise showed equality comparisons with quoted string literals evaluating to
falsewithout error ("{input.body.planType}" === "ROTH_401K"→falsewhile.indexOf(...) >= 0→true), so anIFsilently falls through to the wrong branch. The replacement pattern: express decisions ingraph.math(safe expression dialect, readableIFnodes) and delegate anything the dialect cannot express tograph.task(a composable function). The field rebuild that produced this report ended up with clearer graphs: rule order became visible nodes instead of a script.
Same statement model as graph.math — the full
statement grammar applies — but expressions run as
full JavaScript on GraalVM: more flexible, slower.
Deadline: a script is cancelled at a hard execution deadline (GraalVM context cancellation),
failing the node with a 408 script exceeded the N ms execution deadline. Unlike
graph.api.fetcher's call deadline, this is a run-level error — it is not
exception=-routable: a dry-run ends with the canonical terminal carrying the 408 reason
(Graph traversal aborted: script exceeded the N ms execution deadline), and a deployed run fails
the whole request with HTTP 408. The default is a tight
5 seconds — graph.js (like graph.math) is meant for very simple computation or
IF-THEN-ELSE, so it deliberately does not inherit the typically longer model.ttl; an
optional node ttl (duration syntax, e.g. 10s) overrides it. An endless loop can no longer
pin a kernel thread forever.
Gotchas: capped at 50 instances per deployment (it uses kernel threads); reach for
graph.math unless you need real JavaScript.
graph.api.fetcher¶
Calls external HTTP APIs declaratively, driven by Dictionary and Provider config nodes — the
full authoring rules (Provider URL {name} placeholders, the Dictionary's bare input[]
parameters with :default, response.* -> result.* output mapping) are in
Provider & Dictionary. Supports response deduplication
and bounded fork-join concurrency.
skill=graph.api.fetcher
dictionary[]=<data-dictionary-node> # one or more (required)
input[]=input.body.person_id -> person_id
output[]=result.name -> output.body.name # optional: result always lands at {node}.result
for_each[]=<array-source> -> model.<var> # optional: iterate a runtime list (see below)
concurrency=3 # optional: 1–30, default 3
ttl=8s # optional: per-call deadline override (see below)
exception=<error-handler-node> # optional
Iterating a runtime list (for_each): the array source is typically a prior fetcher's
result ({fetcher}.result.{key}); wire the current element into each call with
input[]=model.<var> -> {dictionary-parameter}. Each iteration's result.{key} values are
appended into one array on this node's result set, and the order deterministically follows
the source list (batches of concurrency run in order; responses join in request order). Full
rules: Iterative fetching.
Worked example (fetch a person's name and address):
create node fetcher
with type Fetcher
with properties
skill=graph.api.fetcher
dictionary[]=person-name
dictionary[]=person-address
input[]=input.body.person_id -> person_id
output[]=result.name -> output.body.name
output[]=result.address -> output.body.address
The result lands at {node}.result. Gotchas: identical requests (same provider + input
parameters) are deduplicated within the graph instance — the cache holds successful
responses only (a failed call is never cached, so a retry after RESET: makes a real call;
an identical successful call reuses the cached response); the input[] targets must match
the dictionary parameter names exactly, or execution fails. The dictionary/provider setup this
skill depends on is specified in Provider & Dictionary.
HTTP semantics: one Provider call is exactly one HTTP request — redirects are never
followed (a 3xx is a non-failure: status and body are captured and traversal proceeds).
{node}.status always carries the HTTP status of the fetch, success included.
response.* in Dictionary output[] addresses the body only (the bare root
response -> result.{key} captures a whole non-JSON body); response headers are available
via feature[]=log-response-headers at {node}.header.response.{name}.
Failure routing: with exception={handler-node}, a failed call (HTTP ≥ 400) sets
{node}.status/{node}.error (and {node}.stack when the failure carries one), skips the
output[] mappings, and jumps to the handler instead of aborting — the building block for
bounded retry loops (full pattern). Without it, the run
aborts. The jump also stages the generic exception context — error.source (the failing
node's alias), error.code, error.message and error.stack when available — so one
island-anchored handler can serve every node's exception= route without naming any failing
node in its data mapping (error is a reserved alias; inspect error shows the context in a
dry-run session). A shared handler is entered at most once per run unless RESET; deliberate
re-entry loops use the bounded-retry idiom.
Deadline: each call is bounded by the propagated model.ttl (default 30 s); the optional node
ttl (duration syntax <digits> + s/m/h/d, e.g. 8s) overrides it for this node's calls
only. A deadline shorter than the graph's own budget makes a slow provider time out first, so
exception= routing handles the timeout instead of the whole run aborting on model.ttl — the
time-boxed half of a bounded retry loop. The same effective deadline is stamped as the outbound
x-ttl request header, aligning the HTTP client's wire-level read timeout (deadline + a one-second
grace) with the graph-side deadline, so a hung upstream's socket self-cancels instead of lingering
after the 408. When the target is another Mercury application, its ingress honors the inbound
x-ttl over the endpoint's configured timeout — the caller's deadline propagates end-to-end.
Details and gotchas: graph.task.
graph.extension¶
Delegates to another graph model or an Event Script flow, so you can compose larger
capabilities and reuse logic. Discover the valid targets with list graphs / list flows
(discovery commands) — no out-of-band brief needed.
skill=graph.extension
extension=<graph-id> # a sub-graph …
extension=flow://<flow-id> # … or an Event Script flow (note the flow:// prefix)
input[]=input.body.person_id -> person_id
output[]=result -> output.body
Sub-graph example (reuse a deployed graph):
create node performance-evaluator
with type Extension
with properties
skill=graph.extension
extension=evaluate-sales-performance
input[]=input.body.department_id -> id
output[]=result.sales_performance -> output.body.sales_performance
The delegation contract (rules, not just the example):
extension={graph-id}resolves among the deployed graph models (compiled at startup from the app'sresources/graphfolder — the same ids callable atPOST /api/graph/{graph-id}). A session draft is not addressable — export and deploy it first. A missing id fails the node fast at run time.- Each
input[]target is a bare key that becomes the sub-graph'sinput.body.{key}(e.g.input[]=input.body.person_id -> person_idfeeds the sub-graph'sinput.body.person_id). There is no whole-body*target ongraph.extension— map named keys (the*merge idiom isgraph.task-only). - The node's
result.*namespace is the sub-graph'soutput.body:result(bare) is the whole response body;result.{key}a field of it. - The same contract applies to a flow target (
extension=flow://{flow-id}): the named keys feed the flow'sinput.body, andresult.*is the flow'soutput.body. - The optional node
ttl(duration syntax, e.g.10s) overrides the propagatedmodel.ttlas the deadline for the delegated call — a shorter child deadline lets the sub-graph or flow time out first, so this node'sexception=route catches the timeout and can retry within the parent graph's remaining budget (same parameter asgraph.api.fetcherandgraph.task). - The delegated graph or flow inherits the caller's business correlation ID (
model.cid), the same way an Event Script sub-flow does — so a sub-graph that suspends persists its record under the shared cid scoped by its own graph id, and re-invoking with the same cid resumes it (the orchestrator pattern). - A missing
flow://target is NOTexception=-routable: an unknown flow id throws before the delegated call is made, and a skill-thrown error aborts the whole run. An unknown graph id, by contrast, reaches the graph executor and comes back as a404reply of the call itself — which IS staged intoerror.*and routed to theexception=handler like any other 4xx/5xx reply, task error, or timeout. flow://targets resolve at call time — the CompileGraph deployment gate validates graph structure and mapping syntax but does not check that a referenced flow id exists; uselist flowswhen authoring, and cover the node with a dry-run.- On a handled failure,
error.codeis the delegated call's response status (the child's 4xx/5xx, or the RPC timeout status408when the child's deadline fires).
This is the seam between the semantic layer and the composable (Event Script) layer beneath it — authoring the target flow: Event Script AI agent guide + flow grammar.
graph.task¶
Invokes a composable function — a TypedLambdaFunction registered with @PreLoad — through its
route name. The lightweight way to plug a small piece of custom business logic into a graph: your
own function becomes, in effect, a custom skill.
skill=graph.task
task=<function-route>
input[]=input.body -> * # '*' merges the mapped value into the request body
input[]=text(minigraph) -> header.x-app # 'header.{name}' sets a request header
input[]=input.body.id -> model.id # 'model.{key}' stages a state-machine variable
output[]=result -> output.body
Worked example (tutorial-13 — any registered route is callable, so async.http.request turns
the node into an HTTP client by configuration):
create node hello-task
with type Task
with properties
skill=graph.task
task=async.http.request
input[]=input.body.person_id -> model.person_id
input[]=text(http://127.0.0.1:${rest.server.port:8080}) -> host
input[]=text(/api/mdm/profile/{model.person_id}) -> url
input[]=text(GET) -> method
input[]=text(application/json) -> headers.accept
input[]=text(5000) -> headers.x-ttl
output[]=result -> output.body
input[] entries apply in order, so field mappings after a * merge into the request body,
and the body auto-converts when the function declares a PoJo input. A model.{key} target stages
a state-machine variable instead of a body field, and later entries can reference it as a
dynamic variable — {model.person_id} above resolves inside the text(...) constant, the
same idiom as Event Script. The ${rest.server.port:8080} reference is environment/config
substitution, resolved when the model is loaded (at deployment compile, and at instantiate
graph for a dry-run) while the authored and exported model keeps the placeholder. The result lands at
{node}.result and response headers at {node}.header — in output[] mappings, result
(bare) is the function's whole result and result.{key} a field of it (same rule as
graph.extension). An output[] source is a constant, a simple-plugin call (f:…), result/result.{key},
model.* or this node's own {node}.* — input.* is valid only on the input side; to echo a request value, stage it at
a mapper node (input.body.id -> model.id) and map model.id out. Any other source fails the node
with an error that names the entry. Optional for_each[] with concurrency
(1–30, default 3) iterates with bounded fork-join; exception=<node> routes failures with the
same generic error.source/code/message/stack context as
graph.api.fetcher, so one handler serves them all
(failure routing).
A static decision table is graph data, not function code. A lookup table that changes with
legislation rather than with each request — a restriction rule by state, a rate by band — belongs on
a skill-less node. At instantiation the engine copies every node's properties into the state
machine, so the node's alias is a mapping source ({node-name} in the
namespaces table): a graph.data.mapper decision node resolves
the rule with the lookup simple plugin (the common case, no function at all), and one input[]
entry hands the whole table to a generic function when the ruling needs more than a lookup. Two
reasons to prefer it. Readability: the product owner reads and
certifies the rules on the graph, in the business vocabulary, and a new table ships as a new graph
version (v2026-08-prime-rates), never as a code change. Simplicity: one table replaces a ladder
of IF-THEN-ELSE — a chain of graph.math decision nodes, or conditionals inside a composable
function — that grows a branch per rule and buries the ruling in control flow. Do not hard-code the
table in graph.math statements or in a function bundled with the graph: neither is reusable, and
neither is certifiable from the graph.
The table node, shared by both examples below — keys names the rules in priority order and each
rule lists the values that select it, every value a JSON array written as text so the node reads as
a table in the Playground:
create node state-rules
with type DecisionTable
with properties
keys=[ "community-property", "separate-property" ]
community-property=[ "CA", "TX" ]
separate-property=[ "NY" ]
Common case — the lookup simple plugin, no function. A graph.data.mapper
node is the decision node: f:lookup(table, value, default) returns the name of the first rule that
lists the value (compared as text, case-insensitively), or the optional third argument on a miss
(null when it is omitted):
create node select-rule
with type Decision
with properties
skill=graph.data.mapper
mapping[]=f:lookup(state-rules, input.body.state, text(unknown)) -> output.body.rule
When the ruling needs more than a lookup — a composable function. One input[] entry hands the
whole table to the function; the function stays generic by reading the rule names from table.keys
and reconstructing the JSON-text lists (Java: SimpleMapper.getInstance().getMapper().readValue(text,
List.class); Rust: serde_json::from_str), ignoring any other property on the node (a purpose, a
source). In the field, even complex rulings generalize into a small number of such decision-table
functions:
create node select-rule
with type Task
with properties
skill=graph.task
task=v1.decision.table
input[]=state-rules -> table
input[]=input.body.state -> key
output[]=result.rule -> output.body.rule
Two variations of the table node: key[]=member lines build a real list property (one row per
member in the Playground; lookup and the function accept both), and a nested table can be one
JSON text property (a multi-line '''…''' value) that f:json
parses at mapping time (input[]=f:json(state-rules.table) -> table) — f:lookup also takes the
table as JSON text directly. Wire the table node under the graph's island
(connect knowledge to state-rules with table) so that no node is left unconnected.
Pinned on both engines by unit-test-lookup-1 (the plugin path: TX, ny and a miss defaulting to
unknown) and unit-test-task-9 (the function path, incl. the f:json variation; an unknown key
returns the function's own 404 as the graph output). When the answer is a value per key rather
than a rule name, the table is a keyed table read with a dynamic key.
Gotchas: the task route must exist at runtime or the node fails fast; a call is bounded by
model.ttl (default 30 s) — or by the node's optional ttl property (duration syntax, e.g.
10s), which overrides the propagated value for this node only, the same deadline override as
graph.api.fetcher and graph.extension. That deadline bounds the
event call only — it cannot reach inside a generic function, so a function with its own
downstream timeout contract takes it from the input mapping: the AsyncHttpClient reads
headers.x-ttl in milliseconds as its HTTP timeout (default 30 s when absent) and propagates
it on the wire as the X-TTL header. For multi-step
orchestration, prefer graph.extension — graph.task is for a single function
call. Writing the function itself:
function AI agent guide (#[preload] + ComposableFunction).
ttl — one grammar, three meanings. On the three calling skills (graph.task,
graph.api.fetcher, graph.extension) the node ttl is a child-call deadline; on
graph.js it is the script execution deadline (default 5s, not model.ttl); on the
suspend node the same <digits> + s/m/h/d grammar sets the store-record
expiry — a persistence timer, not a deadline. On any other skill the property is rejected by
the CompileGraph gate and the playground pre-run check. Note also that model metadata
(model.cid/instance/flow/ttl/trace/parent/root/none/run) is engine-managed and
immutable — a data mapping that writes to it is rejected at compile time (the CompileGraph
gate and the pre-run check) and again at runtime in both walker lanes. The per-node ttl is the
sanctioned deadline mechanism, not rewriting model.ttl.
graph.suspend¶
Persists the workflow state of the running graph to an external state store and lets the run
complete — the transaction resumes later through graph.resume with the same business
correlation ID. A superset of graph.task: the task property names the pluggable store
function, but the persistence envelope ({cid, graph, node, ttl, model, seen, run}) is assembled
by the skill itself — no input/output mapping on the node. The record is scoped by
graph + cid (the Redis reference keys it graph:{graph_id}:{cid}), so one business
transaction may suspend independently in each domain's graph and in each subgraph. When the
graph runs as one iteration of a parent's for_each fan-out, the iteration index is appended
as a third segment (graph:{graph_id}:{cid}:{index}) so concurrent iterations do not collide
— see Workflow suspension.
The node carrying this skill must be named suspend — a reserved alias like root/end.
Two patterns reach it, named after the node that pauses: a checkpoint node — a working node
with a drawn edge to suspend — pauses when its skill completes (the edge is the declaration; a
continuation edge is mandatory and the node is never re-executed on resume), and a decision
node pauses by returning suspend from its IF-THEN-ELSE (the decision re-executes against the
new input on every resume; it must not draw an edge to suspend). The ADRs call these shapes
edge mode and jump mode. The retired suspend=true property is ignored (deprecation
WARN). ttl is mandatory with no default (duration syntax 20s/5m/2h/2d) — it becomes the
store record's expiry. Unless the graph staged its own output.*, the caller of the suspended
run receives {"type": "suspended", "cid": ...}.
Gotchas: the store must acknowledge (2xx) before the graph completes — a failed persist fails
the node (exception= routes it); a suspension point must be the sole active branch (never
between a fan-out and its join); only model.* survives — map what later steps need into the
model before the checkpoint. Full story: Workflow Suspension.
graph.resume¶
Restores the workflow state persisted by graph.suspend and continues traversal at
the recorded suspension point — past a checkpoint node without re-executing it, or by
re-executing a decision node against the new request input. Also a superset of
graph.task — the task property names the store function (type=get, body {cid, graph} —
the graph id scopes the lookup to this graph's own records),
restoration is encapsulated, no mapping on the node.
Place it early — conventionally named resume, right after root or after setup nodes. Found:
the persisted model merges into the state machine (the current run's reserved keys always win),
traversal bookkeeping is restored (downstream joins still see pre-suspension branches), and the
walker jumps past the checkpoint onto its normal path. Not found — a fresh transaction (the
normal first-run case) or an expired record: traversal continues along the node's own forward
path. Either way the skill sets model.run to resume or fresh; the engine does not
distinguish absent from expired, so handling that condition is application logic — gate the
forward path with a graph.math IF-THEN-ELSE on model.run (or a graph.task) to reject,
advise the UI, or jump to a recovery node.
Gotchas: the record is consumed on retrieval (a duplicate resume behaves as a fresh run, never
a double execution); model.cid is the retrieval key, so resume-bearing endpoints deserve
rest.yaml authentication. Full story: Workflow Suspension.
graph.join¶
A synchronization barrier for parallel branches. It returns next only when all connected
upstream nodes have completed, and .sink (pause) until then. Completion is success-only and
current: a branch that failed into its exception= route does not count while it retries, and
a RESET node stops counting until it re-executes successfully — so a retry loop feeding a join
holds the barrier instead of firing it prematurely. A chained upstream join counts only once
it actually fired (an evaluation that sank does not count), so multi-stage joins compose
safely.
connect fetch-name to join with done
connect fetch-address to join with done
connect join to combine with proceed
Gotchas: needs at least two predecessors to be meaningful; it is the explicit fork-join
mechanism — without it, traversal proceeds as branches complete. The fork side needs no special
node: multiple outgoing connections from one node run their branches in parallel (see
connect). Data mapping is thread-safe, but branches should not
overwrite the same scalar key (last writer wins) — use per-branch model.* keys, or the
race-free [] list append (element order then follows completion order; use numeric indices
after the join when order must be deterministic).
graph.island¶
Marks an isolated node: it always returns .sink, so traversal does not continue through it.
That isolation is the point — an island is not executable, but it is required knowledge
structure: linking Dictionary, Provider, and data-entity nodes under it gives the graph its
entity-relationship diagram. The graph is living documentation of enterprise knowledge — a
new joiner (or an agent) discovers the domain model by reading the connected dictionaries and
entities, not just the execution path.
Convention (required): leave no node unconnected — wire every config node into the island structure (see Island — the knowledge layer):
connect root to dictionary with contains
connect dictionary to person-name with data
connect dictionary to person-address with data
connect person-name to mdm-profile with provider
connect person-address to mdm-profile with provider
See also¶
- MiniGraph command grammar — the full command language, the constant set, and Provider & Dictionary authoring.
- AI agent guide — driving the Playground via the companion endpoint.
minigraph-commands.json— the machine-readable command catalog.