Skip to content

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 componentBuilt-in node
RunsSandboxed, no I/O of its ownIn the engine, trusted
Ships inIts own published directoryThe flowrunner repo
Good forAnything that talks to an HTTP APIControl flow, pure computation, or work needing a driver (Mongo, Postgres)
Who can add oneAnyone with a publisher keyCore 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} 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 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 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.