Appearance
Authoring a node addon
A new node type is two pieces in two repositories: a first-party engine handler that runs when the flow executes, and a sandboxed config UI that lets people set the node up on the canvas. This page walks the full recipe.
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.
WARNING
Because the engine handler is trusted server code, a new node's execution logic currently has to be added to the first-party flowrunner repo. Running a genuinely third-party execution handler you don't control would need a server-side sandbox that doesn't exist yet — only the config UI is sandboxed today. The sandbox protocol page explains where that boundary sits.
Step 1 — the engine handler (host)
A handler is a branch in the flow engine's runNode function (src/lib/flow/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 src/lib/flow/__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