Handlebars
{{ }} resolves against the run context, which is everything the run knows: this node’s
settings, its credentials, the outputs of the nodes before it, and who is asking. Registered helpers work anywhere Handlebars does,
so conditional prompt text is an ordinary {{#if}}:
eq, contains, filter and toJSON.
There is no {{input.*}} root. A wrong path resolves to empty, silently, which is the
single most common reason a call arrives with a field missing. Array elements and object
keys are dot segments, never brackets: records.0.Name.
identity.<document> reads the org’s identity documents. The org a run works as
decides whose documents these are, so one node serves every org. {{identity.organisation}}
renders the whole document as labelled text. {{identity.brand.promise}} renders one field.
{{#each identity.brand.rules}} loops a list. Identity is the guide.
prompt.<blockName> is why a manifest never hard-codes instruction text. Blocks live in
blocks/**/*.md at the root of your org repo and are camelCased from the filename, so
markdown-guidelines.md becomes {{prompt.markdownGuidelines}}. A block’s words copied
into a node is a fork that stops tracking the block.
Expressions
A string starting withreturn is a sandboxed expression, evaluated at any depth. Reach
for one when a value’s shape depends on the run: an array member that is only sometimes
present, or a key whose name varies by model.
Security is by absence. There is no process, require, fetch, eval, new,
assignment or constructor to reach, because the interpreter never implements them.
Available: member access, indexing, literals, spread, backtick strings, operators,
ternaries, arrow callbacks, and JSON, Math, Number, String, Boolean, parseInt,
parseFloat, encodeURIComponent, Object.* and Array.*. The platform adds three more,
because without them a node could not be a manifest at all:
- Every model call carries today’s date. A model has no clock: asked for “the latest” or
“the next six months” it guesses a today from the documents it was shown. A node that calls a
model puts
'Today is ' + Date.iso(Date.now()).split('T')[0] + '.'at the end of its instructions, after the role and anything cached, so the prefix stays cached and only the tail changes, once a day. Date.now()andDate.iso(ms). Half the APIs a node calls take a date range and want an ISO string, soDate.iso(Date.now() - 30 * 86400000).split('T')[0]is thirty days ago asYYYY-MM-DD. There is nonew Date(...).sha256(value). A stable id derived from content, which downstream dedup joins on.toBase64(value). Plain text as UTF-8 bytes in base64, the form bytes take on the way to anencoding: binarybody.
.at(-1) rather than .pop(), and .toSorted() rather than
.sort(). The array you would be sorting is a live upstream output, so sorting it in place
reorders it for every other node reading the same value.
Named helpers
An expression is one string, which is fine forreturn response.data and bad for a row
projection. When one grows past a few lines, declare it as a helper in any shared/*.yaml
file. Helpers are collected across the package and callable from every expression in it:
shared/helpers.yaml
api/service.yaml
config, credentials or
the scope of whatever called it. Same sandbox, no extra authority, and a broken body fails
when the package loads rather than on the first request that reaches it.
Declare helpers next to the call they shape rather than in one large file. Two files
declaring the same helper name is an error rather than a merge.
The run context
What your calls can see, and where each piece comes from.scope.debug keeps tests from piling up. A canvas test is not a conversation: every test
on one canvas would otherwise join one thread that never ends, re-read in full on every question.
A node that keeps anything between runs checks it, and keeps nothing for a test run: the OpenAI
Agent’s conversation memory is off on the canvas whatever its toggle says (2026-09-14).
user is identity, and nothing that authenticates as them. Email, id and name, never
the caller’s access token. A token is the user against our own services, so a node holding
one could send it to any host its package allows, while an email authenticates nothing.
It is a first-class root because “who is asking” is the join key for every CRM, support and
account node. Reading identity out of the request instead would let a caller fetch somebody
else’s record.
calls.<name> is how a second call uses the first. A call skipped by its when leaves
no key at all, so calls.x is also how you ask whether it ran.
Workflow-level values
Set on the workflow and shared by every node in it:workflow.variables, plus
workflow.id, workflow.name, workflow.runId, workflow.userId and
workflow.conversationId. Inside a loop, loop carries the current item, and saved
carries outputs other nodes chose to keep.
These resolve before your node runs, while its settings are being prepared, so they
belong in a config.yaml field and your calls read the result as {{ config.<field> }}:
config.yaml
Next steps
Config schema
The settings form these values resolve into.
api/run.yaml
Every field a call takes, generated from the schema.

