Call existing Node-RED flows from a CLI or a Node.js host, like ordinary functions β using the real embedded Node-RED runtime, no flow mutation, no temporary nodes.
node-red-cli flows.json calculate --set x=4 --set y=5 < /dev/null9
This turns Node-RED from a visual automation tool into a reusable runtime building block for scripts, services, pipelines, and developer tooling. π§©
npm install -g @tbrandenburg/node-red-cliThis installs the node-red-cli command globally, ready to use against any
Node-RED flow file (see Usage below).
To build and run from a repo checkout instead (e.g. for contributing):
make install
make testmake install also wires up a pre-push git hook that runs make ci
(format, lint, and tests) automatically before every push. To use
node-red-cli as a regular command from a repo checkout, install it
globally from the local source:
make install-global- The idea
- Why node-red-cli?
- Project layout
- Usage
- Host API
- Technical approach
- Preflight and limitations
- Contributing
- Security
- License
An existing flow becomes a clean input/output interface:
stdin / CLI args
|
v
Node-RED runtime
|
v
link in: calculate -> any flow -> link out: return
|
v
stdout / Promise<Result>
The flow itself stays untouched. No extra CLI nodes, no copy-pasted logic, and no permanently deployed adapter structure. π«π§
- Reuse existing flows: business logic stays where it's already maintained β in Node-RED.
- Uses the real Node-RED runtime: core and contrib nodes don't need to be reimplemented.
- CLI-friendly I/O: JSON in, JSON out.
- Async support included: Node-RED flows keep working exactly as they normally do.
- Safely bounded calls: timeouts prevent a process from hanging forever.
- Clean separation: results go to
stdout, logs and errors go tostderr. - No flow mutation: the current implementation adds no temporary nodes and
never redeploys
flows.json. - Optional sandboxing:
--dockerre-executes a call inside a disposable, hardened container instead of the host process.
bin/ CLI entrypoint (node-red-cli)
src/ Host-side link-call adapter (library API)
test/unit/ Fast tests against a fake Node-RED runtime
test/integration/ Adapter tests against a real embedded runtime
test/fixtures/ Example Node-RED flow used as a test asset
Try the CLI directly against the example flow:
echo '{"payload":{"x":4,"y":5}}' | node-red-cli test/fixtures/flows.json calculate9
By default only the resulting payload is printed as plain text. Pass
--format=json to print the full result object as JSON instead (the
_msgid is generated by Node-RED and differs on every run):
node-red-cli test/fixtures/flows.json calculate \
--set x=4 --set y=5 --format=json < /dev/null{ "payload": 9, "_msgid": "..." }The target argument is optional; if the flow has exactly one link in
node, it is used automatically (with a warning on stderr if it also had to be
inferred across multiple tabs):
echo '{"payload":{"x":4,"y":5}}' | node-red-cli test/fixtures/single-link-in.flows.jsonInstead of building the whole JSON message yourself, individual payload
attributes can be set directly from CLI params with repeatable
--set <key>=<value> flags. Values are JSON-parsed when possible (so 4
becomes a number, true a boolean), otherwise kept as plain strings, and they
are applied on top of (and override) any payload read from stdin:
node-red-cli test/fixtures/flows.json calculate \
--set x=4 --set y=5 < /dev/null9
Instead of a <flows.json> file path, --flow-json <value> accepts the flow
definition directly, so in-memory callers (tests, another Node.js process, a
Node-RED editor "run this flow" action) never have to write a temp file just
to satisfy this CLI's file-based API. It is mutually exclusive with the
<flows.json> positional argument. <value> is one of:
- an inline JSON array:
--flow-json '[{"id":"a",...}]' -to read the flow JSON from stdin@<path>to read it from a file (equivalent to the positional argument)
The flow is never written to disk in any of these forms.
node-red-cli --flow-json @test/fixtures/flows.json calculate \
--set x=4 --set y=5 < /dev/null9
Since stdin is also used to read the msg payload, --flow-json - and the
stdin msg are mutually exclusive: when --flow-json - is used, stdin is
consumed by the flow definition instead, so msg must be built entirely from
--set params:
node-red-cli --flow-json - calculate --set x=4 --set y=5 \
< test/fixtures/flows.json9
By default the CLI creates a fresh, ephemeral Node-RED userDir per
invocation and deletes it afterwards, so only the node types bundled with
node-red itself are available to a flow. To use community/custom nodes
(e.g. node-red-contrib-something), two options work together:
--user-dir [path]makes theuserDirpersistent/reusable across runs instead of ephemeral. Pass a path to use a specific directory, or the bare flag to use a stable cache dir ($XDG_CACHE_HOME/node-red-cli, falling back to~/.cache/node-red-cli). Omitting--user-direntirely preserves today's ephemeral behavior unchanged.--node-modules <name[@version]>[,...]installs any of the given Node-RED node npm packages that are missing from<userDir>/node_modulesbefore the flow runs. Repeatable and/or comma-separated. Requires an explicit--user-dirβ using it with the default ephemeraluserDiris rejected with a clear error, since the installed module would be thrown away immediately and reinstalled from npm on every single invocation.
node-red-cli flows.json calculate \
--user-dir ~/.cache/node-red-cli \
--node-modules node-red-node-random \
--set x=4 --set y=5 < /dev/nullAlready-installed, version-matching modules are left untouched, so repeat
runs against a warm cache do not touch the network. No invocation ever
reaches out to npm unless --node-modules is explicitly passed.
--node-modules runs a real npm install, i.e.
arbitrary code execution from whatever npm registry is configured. Only
use it with trusted module names. A minimal built-in denylist blocks
obviously unsafe values (path traversal, URLs, whitespace); operators can
add exact names or *-glob patterns via the NODE_RED_CLI_DENY_MODULES
environment variable (comma-separated), e.g.
NODE_RED_CLI_DENY_MODULES="node-red-contrib-*-internal".
userDir caveat: a shared userDir accumulates
Node-RED runtime/state files (e.g. .config.runtime.json) across runs.
Delete the directory (or the default ~/.cache/node-red-cli) to clear the
cache and start fresh.
--docker [value] re-executes the entire invocation (flow resolution,
link call, and any --node-modules install) inside a disposable, hardened
Docker container instead of the host process β useful when --node-modules
installs untrusted community packages, since that's a real code-execution
surface. Works for both <flows.json> (from-file) and --flow-json modes,
with zero bind mounts and zero leftover host files: the resolved flow
and message are streamed over the container's stdin as a single JSON
envelope, never written to disk.
node-red-cli flows.json calculate --set x=4 --set y=5 --docker<value> is one of:
- omitted (bare flag): resolves/builds a locally-cached image tagged
node-red-cli-sandbox:<installed node-red-cli version>, built fromnode:24-slim+ a globalnpm installof this package from the public npm registry. Cached by Docker forever afterward (npm registry versions are immutable, so a version bump is the only thing that invalidates the tag) β later runs of the same version need no network access beyond the container's own sandboxed execution. <image[:tag]>: use an explicit image. If it already contains the sandbox entrypoint, it's used as-is; otherwisenode-red-cliis installed into a derived image (FROM <image>+ a global npm install) on first use, cached by image+version so the check/build only happens once per image.@<path>or an http(s) URL: build from a user-supplied Dockerfile (local file or fetched URL), cached by content hash so an unchanged Dockerfile isn't rebuilt every run.
Sandboxing defaults applied to every --docker run:
--rm -i(always disposable)--network none, unless--node-modulesis also given (needs registry access) or--networkis passed explicitly (enables network access for a flow that needs to call out, independent of installing any package) β narrowest network exposure by default--read-onlyroot filesystem + a/tmptmpfs mount--cap-drop=ALL--security-opt=no-new-privileges
Combined with --user-dir + --node-modules, persistence uses a
deterministic named Docker volume (derived from the --user-dir value)
mounted inside the container, never a host bind mount β so "no stray host
files" holds even for persistent installs.
Without an explicit --user-dir, --docker also auto-probes the
container's own /data for a userDir a community image already
pre-populated with its own Node-RED node packages (e.g. the motivating
ghcr.io/tbrandenburg/agentic-workflow-dev-env,
which sets NODE_RED_HOME=/data) β validated by scanning /data/node_modules
(including scoped @scope/* packages) for any package.json declaring a
"node-red" key. This is inherently best-effort: an unrelated /data that
happens to contain such a package is a (rare) false positive, and a real
userDir laid out differently is a false negative that silently falls back
to the ephemeral default. For a reliable, explicit alternative, pass
--docker-userdir <path> to name the in-container directory directly β
it takes precedence over the auto-probe (but is itself still overridden by
an explicit --user-dir). Whichever wins, that directory is used as
userDir and, like an explicit --user-dir, never deleted afterward.
Fails fast with a clear node-red-cli: docker unavailable: ... error if
the Docker CLI/daemon isn't reachable, or node-red-cli: docker build failed: ... if the image build fails (e.g. the local version isn't yet
published to npm β use --docker <image> or --docker @path as an
escape hatch in that case).
Node-RED nodes such as agent
(OpenCode, pi) turn a link in -> agent -> link out (return) flow into a
callable AI step, invoked like any other target β a JSON flow with an
inline multiline prompt, in one command:
echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
| node-red-cli --flow-json '[
{"id":"tab","type":"tab","label":"Agent"},
{"id":"ask","type":"link in","z":"tab","name":"ask","wires":[["agent"]]},
{"id":"agent","type":"agent","z":"tab","name":"opencode","agent":"opencode",
"runtime":"direct","prompt":"payload","promptType":"msg",
"cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
{"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
]' ask --node-modules @tbrandenburg/node-red-agents --user-dir --timeout=120000 --format=jsonThe same flow runs sandboxed via --docker <image> against an image that
already ships opencode + node-red-agents, e.g.
ghcr.io/tbrandenburg/agentic-workflow-dev-env,
which pre-installs its node packages into /data (NODE_RED_HOME=/data) β
exactly the layout the /data auto-probe discovers automatically, with
no --node-modules/--user-dir needed (--network is still required for
network access, since the agent calls out to its own API):
echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
| node-red-cli --flow-json '[
{"id":"tab","type":"tab","label":"Agent"},
{"id":"ask","type":"link in","z":"tab","name":"ask","wires":[["agent"]]},
{"id":"agent","type":"agent","z":"tab","name":"opencode","agent":"opencode",
"runtime":"direct","prompt":"payload","promptType":"msg",
"cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
{"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
]' ask --docker ghcr.io/tbrandenburg/agentic-workflow-dev-env:latest \
--network --timeout=120000 --format=jsonThe core interface is intentionally small:
const { createHostLinkCaller } = require("./src/link-call");
const caller = createHostLinkCaller(RED);
const result = await caller.call(
"calculate",
{ payload: { x: 4, y: 5 } },
{ flow: "calculator", timeout: 5000 }
);
console.log(result.payload); // 9
caller.close();flow accepts either the tab ID or the unique tab label. If omitted, the only
existing workspace tab is selected automatically.
target (the link in node) is also optional. If omitted, the only link in
node in the resolved flow is used automatically. If no flow is given and
several tabs exist, but only one link in node is present overall, that node
(and its tab) is inferred and a warning is reported via the optional
onWarning callback β pass one to caller.call(...) to observe it:
const result = await caller.call(
undefined,
{ payload: { x: 4, y: 5 } },
{
onWarning: (warning) => console.error(warning)
}
);If either the flow or the target remains ambiguous (more than one candidate),
call() rejects with a preflight validation error naming what must be
specified explicitly.
Node-RED's link-call semantics use _linkSource to make the origin of a call
available to a return link. This adapter sets the required stack entry on the
host side and registers a targeted onReceive hook. The returned message
resolves the Promise before the link-out node needs to resolve the caller via
RED.nodes.getNode(...).
This is a lightweight compatibility layer for Node-RED 5.0.x, not a public
runtime API. The internal semantics are therefore encapsulated behind
createHostLinkCaller(RED) and should be integration-tested separately for
each supported Node-RED version.
Before a call, validateTarget(RED, targetId) checks:
- target ID and target type
link in - instantiation of the target node
- missing wire targets and duplicate IDs
- at least one reachable
link outwithmode: "return" - instantiation of reachable return nodes
- availability of the required runtime hooks
Validation does not prove that a flow terminates semantically or replies exactly once. A runtime timeout remains necessary for that.
Contributions are welcome β see CONTRIBUTING.md.
Please report vulnerabilities responsibly β see SECURITY.md.
MIT β see LICENSE.