Appearance
Authoring a node addon
There are two ways to add a node, and the right one depends on whether the execution logic belongs in the core.
| Installable component | Built-in node | |
|---|---|---|
| Runs | Sandboxed, no I/O of its own | In the engine, trusted |
| Ships in | Its own published directory | The flowrunner repo |
| Good for | Anything that talks to an HTTP API | Control flow, pure computation, or work needing a driver (Mongo, Postgres) |
| Who can add one | Anyone with a publisher key | Core contributors |
Start with a component. It needs no engine change, it's installable per workspace, and the sandbox means a bug in it can't reach your data. See Installable components for that path — the rest of this page covers the built-in route, and the sandboxed config UI, which both kinds share.
A built-in is justified when the node can't work through the component contract: it needs a native driver, it's part of the engine's own vocabulary (If, ForEach, Set), or it must run without a network call at all.
The two repos
flowrunner— the host app. Holds the engine handler (what the node does) and the registry that points at the plugin. This code is first-party and trusted: it runs on the server with access to secrets.flowrunner-remote-components— the plugin host. Holds each node's sandboxed config UI (the little settings form). This code is untrusted and runs in an isolated browser iframe.
NOTE
This used to be the only option: execution logic had to land in the first-party repo because there was no server-side sandbox for it. There is now — see Installable components. A built-in handler is still trusted server code with access to secrets, which is exactly why most nodes should be components instead.
Step 1 — the engine handler (host)
A handler is an entry in the engine's HANDLERS table (packages/engine/src/engine.ts), keyed on the node's component ID. It reads the node's saved config, does its work, writes any output into the flow's shared refs so downstream nodes can reference it, and returns the outgoing port to follow (or calls fail(...) to route to the node's error branch).
The built-in Set node is a compact example:
ts
if (node.componentId === 'flowrunner/set') {
const c = node.config ?? {};
const varName = (typeof c.var === 'string' && c.var.trim()) || 'value';
const source = typeof c.source === 'string' ? c.source : '';
// A dotted `a.b` source copies from flow refs; anything else is a
// literal with ${env.X} / ${secrets.X} resolution.
const dot = source.indexOf('.');
let val: unknown;
if (dot >= 0 && refs[source.slice(0, dot)] !== undefined) {
val = refs[source.slice(0, dot)]?.[source.slice(dot + 1)];
} else {
val = resolveRefs(source, secrets); // resolves ${secrets.X}
}
refs[varName] = val && typeof val === 'object' && !Array.isArray(val)
? (val as Record<string, unknown>)
: { value: val };
return finish('next'); // follow the "next" port
}The key contracts:
- Inputs come from
node.config. Never trust its shape — narrow every field. - Read upstream values from
refs(keyed by upstream node slug); resolve${env.X}/${secrets.X}withresolveRefs(value, secrets). - Write your output to
refs[<yourVarName>]as an object, so templates and later nodes can reference<var>.<field>. - Return the port to continue on (
'next'), or callfail(message)— that routes to a wirederrorport if one exists, otherwise surfaces a500.
Step 2 — a test (host)
Cover the handler with a Vitest test in packages/engine/src/__tests__/engine.test.ts, driving it through executeFlow exactly as the Run API does:
ts
it('copies an upstream value', async () => {
const set: FlowNode = {
id: 's', componentId: 'flowrunner/set',
config: { var: 'chosen', source: 'start.email' },
};
const f = flow([start, set, end],
[{ from: 'start', to: 's' }, { from: 's', to: 'end' }]);
const result = await executeFlow(f, start, { email: 'a@b.com' }, {});
expect(result.refs.chosen).toEqual({ value: 'a@b.com' });
});Step 3 — the config UI (plugin repo)
Create serve/sandboxed/<id>/ with three files (copy an existing node like set/ as a template):
serve/sandboxed/<id>/
├── manifest.json # identity, permissions, and toolbox appearance
├── index.html # the form, links the shared runtime stylesheet
└── <id>.ts # loaded as <id>.js — wires inputs to the hostThe script uses the shared runtime (_runtime/runtime.js) to talk to the editor. onInit hands you the saved config; persister debounces saves back to the host over the sandbox RPC:
ts
import { onInit, persister } from "../_runtime/runtime.js";
let current: Record<string, unknown> = {};
onInit((ctx) => {
current = { ...(ctx.initialData ?? {}) };
const el = document.getElementById("var") as HTMLInputElement;
el.value = (current.var as string) ?? "";
const save = persister({ getData: () => current });
el.addEventListener("input", () => {
current.var = el.value;
save({ var: el.value }); // → setProjectData RPC (needs project.write)
});
});manifest.json schema
json
{
"manifestVersion": 1,
"id": "flowrunner/set",
"version": "1.0.0",
"name": "Set",
"publisher": "FlowRunner",
"entry": "index.html",
"permissions": ["project.read", "project.write"],
"network": [],
"node": {
"category": "Logic",
"color": "#64748b",
"icon": "/assets/flow-set.svg",
"input": { "id": "in", "side": "left", "alignment": 50, "color": "#64748b", "name": "In" },
"output": [{ "id": "next", "side": "right", "alignment": 50, "color": "#22c55e", "name": "Next" }]
}
}| Field | Notes |
|---|---|
manifestVersion | Always 1. |
id | Unique, vendor/component. Must match the engine handler's component ID. |
version | Semver. |
name | Display name (also the toolbox label). |
publisher | Vendor name. |
entry | HTML entry, resolved relative to the manifest URL. |
permissions | Subset of project.read, project.write, network.declared, storage.read, storage.write. |
network | Origin allowlist for requestApiCall. |
integrity | Optional SRI hash for pinning the bundle (future). |
node | Toolbox catalog block: category, color, icon, one input port, and an output port array (each id, side, alignment, color, name, optional description). |
Step 4 — build the catalog & register
- Run build-catalog in the plugin repo. It walks every
serve/sandboxed/<id>/manifest.json, reads thenodeblock, and bakesserve/components/flow/flowrunner-config.json— the toolbox list. Manifests without anodeblock are skipped. - Register the manifest URL in the host. Add an entry to
NODE_MANIFESTS(insrc/app/project/[projectId]/page.tsx), keyed by the node's display name:
ts
const NODE_MANIFESTS: Record<string, string> = {
// ...
Set: `${sandboxOrigin('set')}/manifest.json`,
};Hard-refresh the editor and the new node appears in the toolbox, with its config UI loading in a sandboxed iframe when selected.
See also
- Sandbox protocol — the RPC and permissions the config UI uses
- Nodes overview — the built-in nodes to model yours on
- The Run API — how the engine handler's output becomes a response
- Errors & try/catch — what
fail(...)does at run time