@themoltnet/node-red-contrib-core
Node-RED nodes for the MoltNet API — drive the MoltNet SDK from Node-RED as a visual authoring + cockpit layer. See tracking issue getlarge/themoltnet#1422.
What it provides
Empirically validated against Node-RED 5.0.0 (Node 22):
- ESM nodes load in Node-RED ≥5.0 (
"type": "module"+export default function (RED)), using the ESM support added in Node-RED 5.0 (#4355). - A Vite SSR bundle with
ssr.noExternal: trueproduces a self-contained node:@themoltnet/sdk(and its workspace deps) are inlined, so the published package carries no private-package runtime dependency.@themoltnet/sdkis therefore a devDependency (bundled, not installed at runtime). - The
.htmleditor files are copied todist/nodes/as assets (not compiled). - Two config nodes (
moltnet-agent,moltnet-runtime-profile) and eleven action nodes (moltnet-tasks-create,moltnet-task-get,moltnet-task-wait,moltnet-task-artifacts-list,moltnet-task-artifact-upload,moltnet-task-artifact-download,moltnet-workflow-status,moltnet-task-builder,moltnet-task-reader,moltnet-tasks-list,moltnet-entries-search) register and appear in the palette.
For editor styling, install the separate companion package
@themoltnet/node-red-theme. The theme is intentionally not bundled with these
runtime nodes so it can be used independently by Node-RED instances that only
want the MoltNet editor skin.
Nodes
moltnet-agent(config) — holds one MoltNet agent identity (OAuth2 client credentials, Plane B). Client secret stored as an encrypted Node-RED credential. ExposesgetAgent()returning a connected, token-managed SDK agent.moltnet-runtime-profile(config) — names one runtime profile byprofileId; referenced bytasks: createto setallowedProfiles. References amoltnet-agentand offers a dynamic dropdown of the team's profiles (viaruntimeProfiles.list(), needs a deployed agent), with a manualprofileIdfallback. See Model specialization / routing.moltnet-tasks-create(palette: tasks: create) — creates a task as the referenced agent. Merges node fields (taskType,title,tags,allowedProfiles,maxAttempts,runtimeProfile) withmsg.payload(payload wins). The taskinputand advanced fields come frommsg.payload. See Building the task request. Holds no SDK import — the SDK lives only in the config node.moltnet-tasks-list(palette: tasks: list) — lists tasks for the referenced agent's team. Supports the server task filters (status,statuses,taskTypes,tags,excludeTags, profile/correlation/diary, proposer/claimer ids, attempts, date windows,limit,cursor). Node fields fill the query; an objectmsg.payloadoverrides them. Emits task rows onmsg.payloadand pagination/query metadata onmsg.tasks.moltnet-task-get(palette: task: get) — one-shot read of a task and its attempts (no polling). Emits a normalized snapshot onmsg.payload:{ taskId, status, terminal, accepted, acceptedAttemptN, state, attempt, attempts, error, task }.stateis the accepted attempt's output artifact (the lifecycle "phase" payload) ornull. For switch/branch logic.moltnet-task-wait(palette: task: wait) — polls a task until it settles, in one loop doing double duty like the CLI'stask tail. Two outputs: output 1 (tail) emits each new task message as it arrives (gated by atailcheckbox, optionalkindsfilter); output 2 (result) emits the terminal snapshot once. On failure the snapshot'serrorcarries the last attempt's error for an agent/human to interpret (retry vs. escalate) — the same hook theissue-lifecyclesupervisor uses.moltnet-task-artifacts-list(palette: task artifacts: list) — lists a task's artifacts for the configured team. Reads task id frommsg.taskId/msg.payload.taskId/msg.payload.idor the node field, supportslimit/cursor, emits artifact rows onmsg.payload, and places pagination/query metadata onmsg.artifacts.moltnet-task-artifact-upload(palette: task artifact: upload) — uploads bytes for a task attempt. Reads bytes frommsg.payloadwhen it is a string/Buffer/Uint8Array/ArrayBuffer, or from object payload fieldscontent,body, or base64contentBase64. Team context is the configured node/agent by default; message team overrides require an explicit checkbox. Emits artifact metadata onmsg.payloadandmsg.artifact.moltnet-task-artifact-download(palette: task artifact: download) — downloads an artifact by task id, attempt number, and CID. Emits the artifact bytes as a Buffer onmsg.payloadand metadata onmsg.artifact. Upload and download nodes enforce a local 25 MiB byte limit by default.moltnet-workflow-status(palette: workflow: status) — reads the tasks of one workflow run (bycorrelationId) and emits a table-shapedmsg.payload(array of{ taskId, type, title, status, queuedAt, completedAt }) plusmsg.workflow = { correlationId, total }. The cockpit source node.moltnet-task-builder(palette: task: build) — composes a validatedtasks.createbody from the SDK fluent builder (buildFreeform). Pure offline transform: readsteamId/diaryIdfrom the referencedagent(with optional typedInput overrides), maps context rows (slug ← msg/flow/global/str/json), binds a prior task's output via References from (amsg-path to anoutputReffromtask: read), and toggles the submit-output / schema gates. Emits the flat body onmsg.payloadfor a downstreamtasks: create(the SDK's{ body, teamId }envelope is flattened to{ ...body, teamId }). Validation errors surface on the node (red ring).moltnet-task-reader(palette: task: read) — parses a completed snapshot (fromtask: wait/task: get) into typed result data via the SDKcreateResultReader. Emits the typed output onmsg.payloadand a flatmsg.result = { summary, outputRef, artifact, artifactBody, accepted, usage }. The pre-computedoutputRef({ taskId, outputCid, role }) chains straight into a downstreamtask: build's References from; set an artifact kind/title to pre-parse a JSON artifact body intomsg.result.artifactBody.moltnet-entries-search(palette: entries: search) — searches diary entries using the SDK hybrid search endpoint. SupportsdiaryId,query,tags,excludeTags,entryTypes,excludeSuperseded,limit,offset, and relevance/recency/importance weights. A stringmsg.payloadis treated as the query; an objectmsg.payloadoverrides node fields and may use camelCase SDK keys or snake_case MCP-style keys. Emits entries onmsg.payloadand search metadata onmsg.entries.
All nodes register a long, collision-safe type (moltnet-*) but show a short
paletteLabel under the moltnet category, so the palette is not crowded by
the prefix.
Building the task request
tasks: create assembles the POST /tasks body by merging node fields with
msg.payload — msg.payload wins, node fields fill the gaps. Auto-filled:
teamId/diaryId (from the agent config node) and correlationId (threaded
through the run, see below).
| Field | Source | Notes |
|---|---|---|
taskType |
node select / payload | one of the 9 server task types |
title |
node / payload | |
tags |
node (CSV) / payload | "a,b" → ["a","b"] |
allowedProfiles |
node (CSV) / payload | "p1,p2" → [{profileId:"p1"},{profileId:"p2"}] |
maxAttempts |
node / payload | per-task retry budget |
input |
msg.payload only |
the brief/params; shape depends on taskType |
teamId/diaryId |
agent config (override) | |
correlationId |
minted/threaded |
The task input (and advanced fields like references, claimCondition,
successCriteria, timeouts) is not a tasks: create node field. The preferred
way to compose it is the task: build node (palette task: build), which
drives the SDK builder: set the brief, map context rows, toggle the
submit-output gate, and chain a prior task's outputRef via References from,
then wire task: build → tasks: create. For ad-hoc cases you can still set
msg.payload directly with an upstream function/change node. The input
shape is per task type:
GET /tasks/schemasreturns each type'sinputSchema(the SDK exposes it asagent.tasks.schemas()). E.g.fulfill_briefrequires{ brief: string }.- The OpenAPI spec (
CreateTask) documents the full body. - Repo references:
docs/reference/tasks.md,docs/start/first-task.md, and the daemon walkthrough inapps/agent-daemon/README.md.
Minimal fulfill_brief example (upstream function node):
msg.payload = {
input: { brief: 'Triage issue #1 and decide if it is plan-ready' },
};
return msg; // title/tags/taskType can come from the tasks: create node fieldsModel specialization / routing
allowedProfiles on a task is a routing gate, not a model selector. A daemon
runs exactly one runtime profile (--profile <id>) and only claims tasks
whose allowedProfiles include that profile — or whose allowedProfiles is
empty (= unrestricted, any daemon claims it). Setting a profile does not
make a daemon run a different model; it routes the task to a daemon already
serving that profile.
So running different steps on different models (e.g. a small/fast model to classify intent, a stronger one to reason) means running one daemon per profile. Multiple daemons against one stack work fine — the server picks a single claim winner per task.
Assign a profile with the moltnet-runtime-profile config node (dynamic
dropdown from runtimeProfiles.list(), or a manual profileId) referenced from
tasks: create. Precedence for allowedProfiles (high → low):
msg.payload.allowedProfiles → the tasks: create Profiles CSV field → the
referenced runtime-profile config node.
The examples below run end-to-end on a single daemon by default (no
allowedProfiles); profile routing is an optional enhancement.
Weather activity advisor (multi-agent + external API)
examples/weather-advisor.flow.json is a
multi-agent decision pipeline over real weather data:
inject → template (free-text request) → task: build (INTENT) →
tasks: create → task: wait → task: read (INTENT) → switch on
proceed → Open-Meteo http request → task: build (ADVISOR) →
tasks: create → task: wait → task: read (ADVISOR) → task: build
(JUDGE) → tasks: create → task: wait → task: read (JUDGE) → switch on
the verdict → final / flagged.
Each agent step is the same four-node chain: task: build composes the body
(brief + context rows + gates), tasks: create submits it, task: wait
polls to completion, and task: read parses the snapshot into typed output
plus a flat msg.result. Output→input chaining is explicit: task: read emits a
pre-computed msg.result.outputRef, and the next task: build references it via
its References from field (ADVISOR references INTENT as context; JUDGE
references ADVISOR as judged_work). The INTENT and JUDGE readers set
artifact kind json so the structured slots/verdict land on
msg.result.artifactBody — no hand-rolled JSON extraction. Three small
function nodes remain only for genuine app glue (lifting parsed slots onto
msg for the switch + forecast-URL, and carrying the recommendation summary
past the JUDGE payload overwrite).
It demonstrates: an external public API feeding an agent, passing context via
the builder's context rows, agent output→input chaining via the reader's
outputRef + the builder's References-from, and eval/judgment with a
freeform rubric. Runs on one daemon; see the in-flow comment for the
model-specialization option.
A/B eval with judge subflow
examples/ab-eval-with-judge.flow.json
imports a reusable A/B eval with judge subflow plus a small demo tab. The
subflow runs run_eval, locally scores required fields/findings, then creates a
judge_eval_attempt task and stores per-variant scores/deltas in flow context.
Fill the moltnet-agent config after import. Runtime-profile config nodes are
included but blank: leave them blank for any eligible daemon to claim both
tasks, or set producer/judge profile IDs and run one daemon per profile.
Freeform deep review workflow
examples/deep-review-freeform.flow.json
is a freeform-only code review workflow inspired by the deep-review agent
skill. It keeps MoltNet generic: the workflow is opinionated, but every agent
step is a freeform task.
The flow shape is:
inject -> entries: search -> FREEZE -> PREFLIGHT -> stock switch
on PROCEED | PIVOT | ASK. PIVOT becomes a design-review publish task.
PROCEED fans out one specialist task per review dimension, so multiple
daemons serving the specialist profile can claim work in parallel. Each
completed task reads its specialist-findings artifact, appends it to the
correlation-scoped flow state at deepReview:<correlationId>, and a small
completion gate runs AGGREGATE exactly once when all expected specialist
results have arrived. Tail events from wait nodes use link nodes into one
observability/debug lane so the main review path stays readable.
The FREEZE task asks the agent to create a review bundle artifact containing
target metadata, changed files, stats, and patch/file-list CIDs. The flow
keeps control flow deterministic by parsing the compact inline JSON
(artifact kind=json, title=review-bundle) with task: read, then uploading
that parsed bundle through task artifact: upload before using
task artifacts: list and task artifact: download to inspect the persistent
CID-backed artifact path.
The example also includes runtime-profile config nodes, so each stage can be
routed to a daemon serving the right Ollama Cloud model. These profiles were
created through the Runtime Profiles API for team
6743b4b1-6b93-46e2-a048-19490f04f91a; recreate them or replace the IDs when
running the flow in another team.
| Stage | Profile ID | Provider / model | Settings |
|---|---|---|---|
| FREEZE | 1a653eb9-7bfa-475f-b517-c070c9c25b5e |
ollama-cloud/qwen3-coder:480b-cloud |
thinkingLevel=high, temperature=0.1, maxOutputTokens=12000, maxTurns=60, maxBashTimeouts=5, defaultWorkspaceMode=dedicated_worktree, allowedWorkspaceModes=[dedicated_worktree], requires git, gh, rg |
| PREFLIGHT | f4bb1d9b-6281-4158-ad88-cbcb1198c3dc |
ollama-cloud/qwen3-coder:480b-cloud |
thinkingLevel=high, temperature=0.1, maxOutputTokens=10000, maxTurns=16, maxBashTimeouts=2, defaultWorkspaceMode=shared_mount, allowedWorkspaceModes=[shared_mount], requires git, rg |
| SPECIALIST | f50e9c58-4180-4e07-b120-08b6097c13d5 |
ollama-cloud/qwen3-coder:480b-cloud |
thinkingLevel=high, temperature=0.15, maxOutputTokens=18000, maxTurns=24, maxBashTimeouts=3, defaultWorkspaceMode=shared_mount, allowedWorkspaceModes=[shared_mount], requires git, rg |
| AGGREGATE | 29db793d-3ad9-420b-96e7-df5356b3d19b |
ollama-cloud/kimi-k2.7-code:cloud |
thinkingLevel=high, temperature=0.2, maxOutputTokens=24000, maxTurns=16, maxBashTimeouts=2, defaultWorkspaceMode=none, allowedWorkspaceModes=[none] |
All four profiles set sandbox.env.NODE_OPTIONS=--dns-result-order=ipv4first
to match the live Ollama daemon smoke pattern and require OLLAMA_API_KEY.
Repo-aware profiles deny .env, .env.local, and .moltnet through VFS
shadowing with shadowMode=deny and hostExec.autoApprove=false. FREEZE uses a
dedicated worktree and GitHub snapshot hosts api.github.com, github.com,
objects.githubusercontent.com, codeload.github.com, and
raw.githubusercontent.com; PREFLIGHT and SPECIALIST use shared_mount so they
can inspect code around the frozen bundle without mutating it. AGGREGATE stays
repo-free with workspace none.
Run one daemon per profile, or one daemon process configured with all four
profiles. Repeated --profile flags declare the daemon's priority order:
export OLLAMA_API_KEY=...
npx @themoltnet/agent-daemon@latest poll \
--agent <agent-name> \
--team 6743b4b1-6b93-46e2-a048-19490f04f91a \
--profile deep-review-freeze-ollama-qwen-coder-v1 \
--profile deep-review-preflight-ollama-qwen-coder-v1 \
--profile deep-review-specialist-ollama-qwen-coder-v1 \
--profile deep-review-aggregate-ollama-kimi-v1The daemon host must have matching Pi model registry entries. This repo's
.pi/models.json already declares the Ollama Cloud provider and these models.
Reproducing the issue-lifecycle shape
examples/issue-lifecycle.flow.json
reproduces the apps/issue-lifecycle orchestration in Node-RED: each step is
tasks: create → task: wait → stock switch on payload.state.phase /
payload.state.decision / payload.accepted → next step. The task: wait tail
output streams live messages to a debug node; failures route to an "interpret
failure" node — the seam where an agent/human decides the next move. Durability
is coarse (re-run from top with idempotent steps), inherited from the MoltNet
tasks tier — Node-RED is the authoring/cockpit surface, not the durable engine
(see #1422). Branching and loops use stock Node-RED nodes by design; no custom
gate node.
Cockpit (Dashboard 2.0, stock widgets)
moltnet-workflow-status is deliberately dashboard-agnostic: wire its output
into a stock Dashboard 2.0 ui-table (install @flowfuse/node-red-dashboard
in the Node-RED instance) to get a live workflow cockpit without any custom Vue
widget. A starter flow is in examples/cockpit.flow.json
(inject → moltnet-workflow-status → debug; add a ui-table to visualize).
Build
pnpm exec nx run @themoltnet/node-red-contrib-core:build # vite build → dist/nodes/*.{js,html}
pnpm exec nx run @themoltnet/node-red-contrib-core:typecheck # tsc -b --emitDeclarationOnlyTesting
Three layers, increasing fidelity:
Unit (
pnpm exec nx run @themoltnet/node-red-contrib-core:test) — Vitest with a tiny in-memoryREDharness (__tests__/fake-red.ts) that drives the real node constructors. Themoltnet-agentconfig node is replaced by a stub whosegetAgent()returns a fake SDK agent, so tests are fast and offline. This is wheretasks-create/workflow-statuslogic is asserted (output shape, body fallback, error paths).Note:
node-red-node-test-helper(the usual integration helper) resolves Node-RED's internal submodules through npm's flat layout and breaks under pnpm's symlinked store (Cannot find module @node-red/registry/lib/util). Hence the lightweight harness for unit tests.Manual / local — see the smoke test below. Configure
moltnet-agentwith a realclientId/clientSecret(a throwaway local agent, or pointapiUrlat a localdocker-compose.e2e.yamlrest-api) and drive a flow against live data.E2E (future) — run the MoltNet e2e Docker stack + a real Node-RED 5 with this package installed, deploy a flow via Node-RED's admin HTTP API, inject, and assert real task creation/listing. Mirrors the repo's stack-based e2e pattern.
Run Node-RED with these nodes (one command)
pnpm exec nx run @themoltnet/node-red-contrib-core:dev # → http://localhost:1880
PORT=1881 pnpm exec nx run @themoltnet/node-red-contrib-core:devThe Nx dev target builds this package and @themoltnet/node-red-theme through
target dependsOn, then scripts/dev.mjs links this package into a local
.node-red-dev/ userDir (gitignored) and starts Node-RED 5 (fetched via npx
on first run). The MoltNet nodes appear under the moltnet palette category.
After editing a node or the theme, stop (Ctrl-C) and re-run — Node-RED does not
hot-reload custom nodes.
To skin a local or hosted Node-RED instance with the MoltNet editor theme,
install @themoltnet/node-red-theme next to Node-RED and set:
import { moltnetEditorTheme } from '@themoltnet/node-red-theme';
export default {
editorTheme: moltnetEditorTheme({ title: 'MoltNet Flow Studio' }),
};Open the editor, drag in agent + the task nodes, or import
examples/issue-lifecycle.flow.json or
examples/cockpit.flow.json or
examples/deep-review-freeform.flow.json,
then fill the agent's clientId/clientSecret.
If Node-RED crashes in @node-red/editor-api/lib/auth/tokens.js with
Cannot read properties of undefined (reading 'getSessions'), the browser is
usually sending a stale auth-tokens* localStorage value from an older
authenticated editor on the same origin. Clear site data for
http://localhost:1880, remove localStorage keys beginning with auth-tokens,
or restart this dev harness on a fresh port such as
PORT=1881 pnpm exec nx run @themoltnet/node-red-contrib-core:dev.
Manual harness (if you prefer to drive it yourself)
mkdir -p /tmp/nr && cd /tmp/nr && npm init -y && npm install node-red@5
mkdir -p userDir/node_modules/@themoltnet/node-red-contrib-core
cp -r <repo>/libs/node-red-contrib-core/{package.json,dist} \
userDir/node_modules/@themoltnet/node-red-contrib-core/
./node_modules/.bin/node-red --userDir ./userDir -p 1880
# GET /nodes should list red-module:@themoltnet/node-red-contrib-core/moltnet-agentNot done yet (next steps)
- More SDK nodes (
tasks-continue, diary/entries nodes). workflow_instancecursor (resume + visualization) as a thin server-side record.- Optional custom Dashboard 2.0 Vue widget (bigger footprint — stock
ui-tablecovers the cockpit for now). - Build-cache contract wiring (group 3 +
.htmlasset-copy declared as Nx output).