Skip to main content
A node is a service you drag onto the Canvas. It connects an Agent to another system. There is a marketplace of nodes. When none of them suits your situation, you build your own. A node you build is a folder of YAML files. You describe the API call, and the platform makes it. You build it in Studio, run it against the real service, then publish it. Once it is accepted, the node is in the node library alongside every other node.

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 in allowedHosts 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

Each file carries a $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 real OpenAI 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.
A call is one thing. 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.
A row’s 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: true emits the running total instead of the fragment. A consumer wants the text so far, not one word.
  • throttleMs or throttleChars bound how often a row emits. Nothing held back is dropped; it is flushed when the run ends.

test.yaml: a fixture that runs

Then run it against the real API, with no platform running:
Keys come from your own .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 (sse or ws), or
  • it declares a toolExchange, since a tool loop is multi-turn by definition, or
  • an input declares a SPAWN signal.
Only the last call is considered, because that call is the node’s answer. Every earlier one settles by definition, so a node can page through a list and then stream its reply. Declare 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:
There is no {{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() and Date.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 as YYYY-MM-DD. There is no new Date(...), because that is a construction the sandbox refuses.
  • sha256(value). A stable id derived from content, which downstream dedup joins on.
Nothing mutates. Use .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 for return 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:
A helper sees only its arguments and its sibling helpers. Not 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
Config Schema covers that layer in full.

AllowedHosts: declare every host you call

package.yaml lists the only hosts this package’s nodes may reach. Deny by default.
The host list is what makes a manifest safe to accept from someone else. “Data cannot execute” does not save you on its own: a URL plus {{ 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

Every message names the rule it broke and the page that explains it. Errors stop the build. Warnings inform. It catches what would otherwise surface much later, in a workflow, as nothing happening:
  • an output connector nothing emits to
  • an events table out of connector order
  • a credential field that does not exist
  • a testData.config key your config.yaml never declared
  • a host missing from allowedHosts
  • a capability the platform does not implement

Next steps

Read Node Discoverability before you write whenToUse. 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.