What belongs to you, and what belongs to the platform
Computation over the request belongs to the platform. Description of the service belongs to you.Auth schemes, retries, SSE framing, template resolution: the platform’s job, written once. Base URL, method, parameters, credentials, what comes out: yours, written as data. You name a capability and the platform performs it.
unoverse node lint fails on a
capability that does not exist, so you find out while you write rather than at run time.
Build it in Studio, then publish it
You build a node in Studio, on your own machine. Studio reads your files straight off disk, so there is no server to start and no database to connect to while you work. That is why it works offline. Publishing is a separate act pointed at a separate place. It writes your node into a universe as a record. There is no commit, no package to build and no image to push. Where you keep your files before that is your business.A published node waits to be accepted
A node is the only thing you publish that holds a URL and a credential, so it is the only one somebody reviews. Your node arrives pending. Whoever runs the universe sees what it is asking for before it can run: the hosts it wants to call, the credential types it needs, and what changed since the last version. Accepting it makes it live. After that, you are not interrupted. Fix a prompt, change a mapping, correct an expression, and publishing takes effect straight away. Publishing stops for acceptance again only when the node reaches for something new, such as another host or another credential type. So the list inallowedHosts is not paperwork. It is the thing somebody says yes to, and it
is why they can say yes quickly.
Publishing from Studio is not available yet. Today you write, check and run nodes locally with the two commands on this page.
One folder is one node
$schema pointer, so your editor autocompletes every field and shows
errors as you type. The schema descriptions are the field reference, so they cannot drift
from the format.
The split is by rate of change
interface.yaml is its own file for a different reason than the rest: it answers the
question asked most about any node, “what can I connect to this?”, and that should never
mean scrolling past a logo URL.
Every section except api may instead be inlined into node.yaml, so a simple node can
be one file. Defining a section in two places is an error, never a merge.
The five files
Taken from the realOpenAI node, trimmed of its comments.
node.yaml: what it is
auth is compulsory on every node, and required: false is the usual answer. It says your
node adds no requirement of its own, so the run reaches it as whoever the trigger admitted.
It does not mean public. Who Can Run It covers the other half,
which the person building the workflow sets.
whenToUse is not documentation. The catalog embeds it and ranks it against what a
workflow-building agent is trying to do, so it decides whether your node is ever
offered. Read node-discoverability.md before you
write it.
interface.yaml: what it connects to
config.yaml: the settings form
Canvas renders the form from this, and the executor resolves {{ config.* }} against the
saved values.
description is the help text a person reads under the field. Say what the setting
does, keep it short, and don’t restate the label.
ui:field: template is what makes a field wirable from an upstream node.
api/run.yaml: the calls it makes
A list, always, even when there is one call. Each entry is named for what it fetches.
transport, terminator and error sit inside the call, because
whether a reply arrives as one body or as a stream is decided by the request you make. Ask
for stream: true and you get a stream.
How the reply arrives
xml is for the services that never moved, and it parses to the same shape as JSON so an
events row reads it identically.
encoding is a second axis. transport says how the reply is framed, encoding says how
the values inside it are spelled. dynamodbJson is the one to know: DynamoDB carries
{ name: { S: "Ada" } } where you want { name: "Ada" }, and the platform translates both
ways so a node never writes type tags.
It is a list because one fact often takes more than one call. Resolving a contact is a
search by email, then a second call built from the first reply. Later calls read earlier
ones as calls.<name>, which is why each entry is named. A node that grows a second call
does not change shape.
A list covers different calls in order. Where one call is really many, four capabilities
cover it: paginate to walk pages, chunk to write a collection in batches, poll to wait
on a job, and state to remember between runs. See
Beyond One Request.
error matters more than it looks. An API that returns HTTP 200 with an error in the
body will otherwise read as success and hand nonsense downstream.
api/events.yaml: everything that leaves the node
One row per output connector, in the same order interface.yaml declares them. Read
this one file and you know the node’s entire outward behaviour. Lint enforces the coverage
and the order, so it stays true after edits.
from says where it fires:
from: tool exists because a tool’s result is never in the HTTP stream. The tool loop
produced it.
For a streaming node, two controls matter:
accumulate: trueemits the running total instead of the fragment. A consumer wants the text so far, not one word.throttleMsorthrottleCharsbound how often a row emits. Nothing held back is dropped; it is flushed when the run ends.
test.yaml: a fixture that runs
.env as <CREDENTIAL>_<FIELD> in upper snake case, so
openAICredential.apiKey reads OPENAICREDENTIAL_APIKEY. They are read for that one run
and stored nowhere. This is deliberate: you test with your key, never with a universe’s
stored credentials, which your manifest has no way to reach.
This catches the class of mistake no static check can. A real example: Handlebars always
produces a string, so max_output_tokens: "{{ config.maxTokens }}" once sent "2048" and
the API rejected it. Only running it showed that.
kind is declared, and verified
State PromiseNode or CallbackNode in node.yaml. It could be inferred, but it is the
first thing anyone wants to know about a node.
Lint checks the declaration instead of trusting it. A node is a CallbackNode when:
- its last call’s transport streams (
sseorws), or - it declares a
toolExchange, since a tool loop is multi-turn by definition, or - an input declares a
SPAWNsignal.
PromiseNode while doing any of them and lint names the one that contradicts you.
Templates and expressions
Two syntaxes, and which one applies is decided by the field, never by the node.{{ }} is a Handlebars template, resolved against the run context below.
Registered helpers work anywhere a template does: eq, contains, filter, toJSON.
So conditional prompt text is an ordinary {{#if}}, including inside a System Prompt a
user typed:
{{input.*}} root. A wrong path resolves to empty silently. Array
elements and object keys are dot segments, never brackets: records.0.Name.
prompt.<blockName> is why a manifest should never hard-code instruction text. Blocks live
in prompts/blocks/**/*.md, are toggleable, and are camelCased from the filename
(markdown-guidelines.md becomes {{prompt.markdownGuidelines}}). A copy of a block’s
words baked into a node is a fork that silently stops tracking the block.
A string starting with return is a sandboxed expression, evaluated at any depth. Use
it 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. It is the same evaluator config template fields
already use, so it is not a second language to learn.
Security is by absence. There is no process, require, fetch, eval, new,
assignment or constructor for an expression to reach, because the interpreter never
implements them.
Available: member access, indexing, literals, spread, template strings, operators,
ternaries, arrow callbacks, and JSON, Math, Number, String, Boolean, parseInt,
parseFloat, encodeURIComponent, Object.* and Array.*. Plus two the platform adds,
because without them a node could not be a manifest at all:
Date.now()andDate.iso(ms). Half the APIs a node calls take a date range and want an ISO string.Date.iso(Date.now() - 30 * 86400000).split('T')[0]is thirty days ago asYYYY-MM-DD. There is nonew Date(...), because that is a construction the sandbox refuses.sha256(value). A stable id derived from content, which downstream dedup joins on.
.at(-1) and never .pop(), .toSorted() and never .sort(). The
array you would be sorting is a live upstream output, so sorting it in place would reorder
it for every other node reading the same value.
Give a long expression a name
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:
config, not
credentials, not the scope of whatever called it: a named function whose answer depends on
state it never named is the thing worth avoiding, and credentials is in scope at most call
sites. Same sandbox, no extra authority, and a broken body fails when the package loads
rather than on the first request that reaches it.
Declare them next to the call they shape rather than in one big file. Two files declaring the
same helper name is an error, not a merge.
The run context
What your calls can see, and where each piece comes from.Your node’s own surroundings
The run it is part of
user is identity, and nothing that authenticates as them. Email, id and name, never
the caller’s access token. That is deliberate: a token is the user against our own
services, so a node holding one could send it to any host its package allows. 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
AllowedHosts: declare every host you call
package.yaml lists the only hosts this package’s nodes may reach. Deny by default.
{{ credentials.x.apiKey }} is
exfiltration in six lines with nothing to sandbox. So the capability is restricted instead.
Enforced twice: statically by lint, and at run time after templating, because a host
can itself be templated. Non-https is refused outright, since a credential must never
travel in clear text. *.example.com matches exactly one subdomain level.
Check it before you run it
- an output connector nothing emits to
- an events table out of connector order
- a credential field that does not exist
- a
testData.configkey yourconfig.yamlnever declared - a host missing from
allowedHosts - a capability the platform does not implement
Next steps
Read Node Discoverability before you writewhenToUse. It
decides whether the AI workflow builder ever offers your node.
Config Schema covers every field type your settings form can hold,
and Credentials covers authenticating against a real service.
