Skip to content

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} with resolveRefs(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 call fail(message) — that routes to a wired error port if one exists, otherwise surfaces a 500.

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 host

The 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" }]
  }
}
FieldNotes
manifestVersionAlways 1.
idUnique, vendor/component. Must match the engine handler's component ID.
versionSemver.
nameDisplay name (also the toolbox label).
publisherVendor name.
entryHTML entry, resolved relative to the manifest URL.
permissionsSubset of project.read, project.write, network.declared, storage.read, storage.write.
networkOrigin allowlist for requestApiCall.
integrityOptional SRI hash for pinning the bundle (future).
nodeToolbox 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

  1. Run build-catalog in the plugin repo. It walks every serve/sandboxed/<id>/manifest.json, reads the node block, and bakes serve/components/flow/flowrunner-config.json — the toolbox list. Manifests without a node block are skipped.
  2. Register the manifest URL in the host. Add an entry to NODE_MANIFESTS (in src/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

FlowRunner — the no-code platform for small businesses.