Purpose: A production-ready, UI-first guide to ship new workflow-builder nodes by adding a folder—no internal persistence details, no deployment secrets, and no direct API references.
This guide explains how to add a new Node to the visual workflow builder so it can be dropped into any workflow, wired up with input/output edges, and configured from a form the UI generates for you. Nodes are added the same way AI Agents are: by adding a folder, this time under:
nodes/<category>/<node_code>/
It is intentionally implementation-agnostic:
- No DB / internal platform logic
- No environment variable names or deployment secrets beyond what's needed to explain the drop-in mount pattern
- No external paths
- No direct API endpoint references (configure/connect from the UI)
Companion doc: if you're building a new integration (Gmail-style, Slack-style, "call an external vendor API") rather than a workflow-graph building block, you want an AI Agent, not a Node — see the AI Agent Plugin Creation Guide instead. Nodes are for deterministic, no-LLM building blocks: transform this list, filter these items, split a batch, run this code, start on a schedule.
- What a Node is (and isn't)
- The two kinds of "node" in this system — read this first
- Folder contract (what must exist on disk)
- The
node.jsonmanifest - Create a new Node — step by step
- The
execute()contract in detail - Branching: the
route↔ edgeconditionconvention - Multi-pass / loop nodes (stateful across visits)
- Trigger nodes (starting a workflow on an event)
- Conditional config fields (no
param_optionsfor nodes) - Drop-in folder enablement checklist
- Safe update rules (production)
- Common causes of "node not loading"
- Copy-from-real-examples
- Node: one building block in a workflow graph — the user drags it onto the canvas, configures its fields, and wires its inputs/outputs to other nodes.
- Node folder:
nodes/<category>/<node_code>/containingnode.json+ Python code. node_id: the node's unique identity, derived from its own location on disk — it must equal"<category_folder>.<node_folder>"exactly (lowercased). You don't invent this separately; it's implied by where you put the folder.category: a free-form grouping label (e.g."Data Transformation","Flow","Trigger Nodes","Code") used purely to group nodes in the UI's node picker. It has no effect on how the node executes.outputPorts: the named output(s) a node can route to. Most nodes have exactly one; a branching/loop node declares more than one and picks between them at runtime.route: the string a node'sexecute()can return to say "send this run's output down the edge whoseconditionmatches this string" — the one mechanism every branching/multi-output node uses.
A Node is a deterministic, no-LLM unit of work inside a workflow graph: reshape a list, filter items, deduplicate, run a small script, split into batches, or start a workflow on a schedule. It receives plain data in, does one well-defined thing, and returns plain data out (plus, optionally, a routing decision). It never calls an LLM, never streams reasoning events, and never receives company_id/user_id/auth context directly — if a node needs an external account, that's handled by a companion trigger class (see Section 8), not the node's own execute().
If what you're building needs to reason over natural language, call a vendor API on behalf of a connected account, or make judgment calls about ambiguous input, you want an AI Agent (see the AI Agent Plugin Creation Guide), which a workflow can also call as a step — not a Node.
There are two genuinely different things both called "a node," and it's important to know which one you're building before you start:
Deterministic data/flow-control building blocks discovered from nodes/<category>/<node_code>/node.json, each backed by a Python class implementing one execute() method. This is the extensible system — dropping in a new folder is enough to make a new node type appear in the UI, no engine changes required. Today's real examples are all data-transformation, code-execution, batch-looping, and schedule-trigger nodes.
A small, closed set of node behaviors are hardcoded directly into the workflow execution engine and matched either by the node's graph-level "type" (e.g. trigger, end, fetch_data, for_each, condition) or by a special-cased tool name on an otherwise generic action node:
if_else_node— binary true/false branchswitch_node— multi-way branch by rulewait_node— pause the run and resume later on a scheduleend_workflow_node— mark the workflow as finished without erroringfetch_data(ntype) — the generic "run an AI Agent as a step" node typecondition(ntype) — the built-in true/false evaluatorfor_each(ntype) — the built-in loop-over-a-list controller
You cannot add a new node of these kinds by adding a folder. If your idea is genuinely a new flavor of "branch," "pause," or "end," that requires changing the execution engine itself, not this plugin system — flag that to whoever owns workflow_executor_service.py rather than trying to shoehorn it into nodes/. Everything else — a new data transform, a new code-runner variant, a new schedule/event trigger, a new batching strategy — fits the folder-based system in this guide comfortably.
UI-first rule: If something doesn't appear, don't debug from code first. Restart the running processes and hard-refresh the UI. Folder-based nodes only show up after reload.
Your deployment must be configured so the node registry loader reads the nodes/ root. After adding or changing folders/files, restart the relevant running processes and then refresh the UI.
Containerized/Docker deployments — external mount root: in addition to the repo-bundled nodes/ directory, a deployment can also mount an external node root outside the repo (configured per-deployment, same pattern as the AI Agent plugin system's external mount). Both roots are scanned and merged; on a node_id collision, the repo-bundled node always wins and the external one is skipped with a warning. This is the supported way to drop a new node into a running Docker deployment without rebuilding the image — copy it into the deployment's external mount, then restart/refresh as above.
nodes/
<category_folder>/
<node_code>/
node.json ← manifest (this exact filename)
entrypoint.py ← default filename; defines create_node()
<node_code>_node.py ← your BaseNode subclass
__init__.py ← empty, required for Python package import
icon.svg ← optional but recommended
Critical rule — node_id is derived from the path, not chosen freely: the folder path nodes/<category_folder>/<node_code>/ implies node_id must be exactly "<category_folder>.<node_folder>" (lowercased). Get the folder names right first; the manifest's node_id field must then match them exactly, or the whole node is rejected at load time.
A node folder can hold more Python modules than the minimum above (e.g. a separate module for a companion trigger class — see Section 8).
{
"node_id": "<category_folder>.<node_folder>",
"category": "Human-Readable Category",
"display_name": "Human Friendly Node Name",
"description": "One sentence: what this node does.",
"icon": "icon.svg",
"entrypoint": "entrypoint.py",
"config": {
"outputPorts": [
{ "portId": "output", "portName": "Output" }
],
"inputSchema": {
"type": "object",
"properties": {
"someField": { "type": "string", "description": "What this configures." }
},
"required": ["someField"]
},
"requiresRawInput": false
}
}Field reference:
| Field | Required | Notes |
|---|---|---|
node_id |
yes | Must exactly equal "<category_folder>.<node_folder>", lowercased. Mismatches fail validation — this is checked against the folder the manifest was actually found in, not just "some string." |
category |
yes | Free-form grouping label for the UI's node picker. No execution effect. |
display_name |
yes | Shown in the UI. |
description |
no | Defaults to empty string if omitted, but always write one — it's what a user sees when picking the node. |
icon |
no | Relative .svg filename in the same folder. |
entrypoint |
no | Defaults to entrypoint.py. Must point to a file that exists. |
config.outputPorts |
yes | Non-empty array of { "portId": "...", "portName": "..." }. Almost every node has exactly one port; declare more only if the node genuinely branches or loops (see Section 6/7). |
config.inputSchema |
yes | A JSON Schema object ("type": "object" + "properties") describing the node's configurable fields — this is what the UI renders as the node's settings form. |
config.requiresRawInput |
no | Boolean, default false. Set true when the node needs the raw predecessor output rather than a pre-normalized/flattened version (used by per-item nodes like a filter that needs to inspect each item individually). |
What's deliberately not supported (don't try to add these — they're not read by anything):
- No
is_activeflag — remove/rename the folder to disable a node instead. - No
output_schema— onlyoutputPorts(routing) andinputSchema(config form) exist; there's no declared shape for whatexecute()returns beyond theexecute()contract itself. - No
param_options/dependent-dropdown mechanism like AI Agents have — see Section 9 for the actual way to do conditional fields here.
Decide the category folder (reuse an existing one — e.g. data_transformation, flow, code, trigger — unless you're introducing a genuinely new grouping) and a short, stable node code (lowercase + underscores). Together they fix your node_id.
nodes/
data_transformation/
<node_code>/
node.json
{
"node_id": "data_transformation.<node_code>",
"category": "Data Transformation",
"display_name": "Human Friendly Name",
"description": "One sentence: what it does.",
"icon": "icon.svg",
"entrypoint": "entrypoint.py",
"config": {
"outputPorts": [{ "portId": "output", "portName": "Output" }],
"inputSchema": {
"type": "object",
"properties": {
"field": { "type": "string", "description": "..." }
},
"required": ["field"]
}
}
}Create nodes/<category>/<node_code>/<node_code>_node.py:
from __future__ import annotations
from typing import Any, Dict
from services.nodes.base_node import BaseNode
class <NodeClassName>(BaseNode):
def execute(
self,
node_outputs: Dict[str, Any],
previous_output: Any,
run_state: Dict[str, Any],
config: Dict[str, Any],
) -> Dict[str, Any]:
field_value = config.get("field")
items = previous_output if isinstance(previous_output, list) else [previous_output]
# Deterministic transform only — no LLM calls, no vendor API calls, no auth.
transformed = [item for item in items if item is not None]
return {"success": True, "result": transformed}See Section 5 for the full contract, including error handling and the optional route key.
from __future__ import annotations
from .<node_code>_node import <NodeClassName>
def create_node():
return <NodeClassName>()Rules:
- Keep the function name exactly
create_node, and keep it a plain sync function (not async). - Whatever it returns is strictly validated: must be an instance of
BaseNodewith a callableexecute()method, or the node fails to load. - Use relative imports inside the node folder.
Restart the relevant process, refresh the UI, and confirm the node appears in the workflow builder's node picker under the category you chose, with the fields from inputSchema rendered as its settings form.
BaseNode.execute(...) is a plain synchronous method, not an async generator and not a coroutine — unlike an AI Agent's run(), a node does its work and returns once. No event streaming.
Parameters:
node_outputs(Dict[str, Any]): every previously-executed node's output this run, keyed by node id. Use this if your node genuinely needs to look further back than its immediate predecessor (uncommon — most nodes only needprevious_output).previous_output(Any): the immediate predecessor node's output. This is what most nodes should actually operate on.run_state(Dict[str, Any]): a mutable dict that persists by identity across re-visits of this same node id within the same run — this is how a loop/batch node keeps track of "which page am I on" across iterations (see Section 7). For a simple one-shot node, ignore it.config(Dict[str, Any]): this node's resolved configuration, matching the fields declared innode.json'sinputSchema.
Return value — a dict:
{
"success": bool, # required
"result": Any, # required on success — normalized into the node's `output`/`items`
"error": Optional[str],# set on failure; becomes the node's error message
"route": Optional[str],# only for branching/multi-output nodes — see Section 6
# any other keys are merged into the stored output dict as extra fields
}- On
success: False, the node's stored output becomes{"output": None, "items": [], "error": <your message>}— the workflow's downstream error-handling picks this up the same way it would an AI Agent step's failure. Give a clear, non-technicalerrormessage. - On success,
resultis normalized to a list and stored as bothoutputanditemsso downstream nodes/AI Agent steps can reference it via the same list-indexing placeholder conventions used elsewhere in the platform (e.g. referencingitems[0]of this node's output from a later step). - No
company_id,user_id, or auth token reachexecute()— if your node needs those, it isn't a deterministic node in this sense; consider whether it should be an AI Agent tool step instead, or (for event-driven start-of-workflow behavior) a trigger node (see Section 8). - Never raise an unhandled exception — catch what you can and return
{"success": False, "error": "..."}instead, so the workflow gets a clean, actionable failure rather than crashing the run.
A node that needs to send its output down one of several outgoing edges (rather than all of them) does so with exactly one mechanism: return a route string from execute(), and the workflow engine follows whichever outgoing edge has a matching condition value.
def execute(self, node_outputs, previous_output, run_state, config) -> Dict[str, Any]:
if some_check(previous_output):
return {"success": True, "result": previous_output, "route": "matched"}
return {"success": True, "result": previous_output, "route": "unmatched"}Declare every possible route as an entry in node.json's outputPorts (for the UI to render as distinct connection points), and make sure the strings you return from execute() are exactly the port ids you declared:
"outputPorts": [
{ "portId": "matched", "portName": "Matched" },
{ "portId": "unmatched", "portName": "Unmatched" }
]Rules:
- If you omit
route(or returnNone), the node's output flows to every outgoing edge — this is the default, single-output behavior and is correct for the overwhelming majority of nodes. Only addroutelogic when the node genuinely needs to pick a path. - The matching is a plain string comparison against each outgoing edge's
conditionvalue — there's no fuzzy matching, so keep your route strings stable once a workflow depends on them (renaming a route string after users have wired up edges against it will silently break those workflows). - This is the same underlying mechanism the engine's own built-in
switch_nodeuses (it returns the winning rule's id, or the literal string"fallback"when nothing matches) — you're not inventing a new pattern, just reusing the one primitive every multi-output node in this system shares.
Some nodes need to run more than once per workflow execution — e.g. "take this list, emit it 50 items at a time, and keep looping back into the same downstream steps until the whole list is processed." This is where run_state matters: it's handed back to you, unchanged, every time the engine re-enters this exact node id during the same run.
Pattern:
def execute(self, node_outputs, previous_output, run_state, config) -> Dict[str, Any]:
if "items" not in run_state:
# First visit this run: initialize.
run_state["items"] = list(previous_output or [])
run_state["batch_size"] = int(config.get("batchSize", 10))
run_state["offset"] = 0
items = run_state["items"]
offset = run_state["offset"]
batch_size = run_state["batch_size"]
batch = items[offset: offset + batch_size]
run_state["offset"] = offset + batch_size
if run_state["offset"] >= len(items):
return {"success": True, "result": batch, "route": "done"}
return {"success": True, "result": batch, "route": "loop"}Declare both outgoing routes in outputPorts ("done" and "loop" in the example above), and wire the "loop" edge back into whatever step should run again per-batch, and the "done" edge into whatever should run once all batches are processed. This is exactly the shape a real batching node in this repo uses — see Section 13.
A trigger node starts a workflow when something happens externally (e.g. on a schedule, or on a vendor webhook) — conceptually similar to an AI Agent's trigger system, but wired in as a node folder under nodes/trigger/<node_code>/ rather than under an agent's own folder.
A trigger node folder typically contains two separate classes:
- A
BaseNodesubclass (e.g.<node_code>_node.py) — this is what runs inside the graph once the workflow has started, if the trigger's own output needs to be read/reshaped as a normal node step. It follows the exact sameexecute()contract as any other node. - A separate trigger-agent class (e.g.
<node_code>_trigger_agent.py) that implements thesubscribe(...)/execute_trigger(...)contract — the same pattern used by AI Agent triggers (see the AI Agent Plugin Creation Guide's triggers section). This is wherecompany_id/user_id/auth do apply, because starting a workflow on an event requires the platform to register a subscription and later resolve an inbound event back to a specific workflow.
Why the split matters: BaseNode.execute() deliberately has no auth/context parameters, because the node-registry system is only for deterministic, already-have-the-data transforms. Anything that needs to acquire data from an external source on a schedule or event needs the richer trigger contract, so that responsibility is factored into its own class rather than bent onto BaseNode.
If you're building a schedule-based trigger (e.g. "start this workflow every day at 9am" or "start this workflow once at a specific time"), follow the real example in Section 13 closely — it demonstrates both classes side by side in one folder.
Note on mid-workflow pausing: a trigger node starts a workflow. Pausing a workflow that's already running (e.g. "wait 2 hours, then continue") is a different, engine-native mechanism (
wait_node— see Section 1) and is not something you build as a folder-based node.
Unlike AI Agents, nodes have no param_options/dependent-dropdown mechanism — there's no way to say "fetch live options for this field from an API based on another field's value." If you need that kind of dynamic, data-backed dropdown, the capability belongs on an AI Agent, not a node.
What nodes do support is standard JSON Schema conditional validation inside inputSchema, using allOf + if/then, to show/require different static fields depending on another field's chosen value:
"inputSchema": {
"type": "object",
"properties": {
"scheduleType": { "type": "string", "enum": ["One Time", "Recurring"] },
"timeOfDay": { "type": "string" },
"frequency": { "type": "string", "enum": ["Daily", "Weekly"] },
"byDay": { "type": "array", "items": { "type": "string" } }
},
"required": ["scheduleType"],
"allOf": [
{
"if": { "properties": { "scheduleType": { "const": "One Time" } } },
"then": { "required": ["timeOfDay"] }
},
{
"if": { "properties": { "scheduleType": { "const": "Recurring" } } },
"then": { "required": ["frequency"] }
},
{
"if": { "properties": { "frequency": { "const": "Weekly" } } },
"then": { "properties": { "byDay": {} } }
}
]
}Use this whenever a node's settings form should reveal/require different static fields based on another field — it covers the large majority of "this field depends on that field" cases for nodes, just without the ability to populate a dropdown from a live external lookup.
- Copy
nodes/<category>/<node_code>/into the target instance'snodes/directory (or its external mount, for a Docker deployment that doesn't rebuild the image — see Section 2).
- The node should appear in the workflow builder's node picker, grouped under its
category. - Its
inputSchemafields should render as the node's settings form when added to a canvas. - If it declares more than one
outputPortsentry, the canvas should show that many connection points, and you should be able to wire different edges with differentconditionvalues to each.
- Add the node between two others, configure its fields, run the workflow, and confirm the output it produces is what downstream steps expect (check the shape under
output/items). - For a branching node, test every route your
execute()can return, and confirm each one's wired edge actually fires. - For a loop node, confirm it correctly stops looping (returns the "done" route) once its termination condition is met — an incorrectly-terminating loop node will run forever within that execution.
- Do not change
node_idafter workflows have been built using this node — it breaks every existing workflow that references it. Sincenode_idis derived from the folder path, this means: don't rename the category or node-code folders of a node already in use. - Do not remove or rename an
outputPortsentry'sportIdthat existing workflows already have edges wired against — those edges reference the port id, and a rename orphans them. - Do not change what
resultcontains in a backward-incompatible way (e.g. renaming a key downstream steps rely on) — treat this the same as changing an AI Agent's output shape: consumers and UI expectations need to move together. - Never call an external API or use an LLM inside
execute()— if the node's responsibility grows into needing either, it no longer belongs in this system; move that responsibility into an AI Agent (for LLM/vendor-API work) or a trigger-agent class (for event/schedule-driven work), and keepexecute()itself deterministic. - Never let
execute()raise — always catch and return{"success": False, "error": "..."}instead, so a bug in one node fails that node cleanly rather than crashing the whole run.
- Invalid JSON in
node.json(trailing commas, comments). node_iddoesn't match its folder path — must be exactly"<category_folder>.<node_folder>", lowercased. This is the single most common mistake when copying an existing node as a starting point and forgetting to update its manifest.- Missing or invalid
config.outputPorts— must be a non-empty array of{portId, portName}objects with non-empty strings. - Missing or invalid
config.inputSchema— must be an object with"type": "object"and a"properties"dict, even if that dict is empty. - Missing
entrypoint.py, or it doesn't define a plain (non-async)create_node()function. create_node()doesn't return aBaseNodewith a callableexecute()— the loader validates this strictly and skips the node (with a warning) if it fails.- Duplicate
node_idacross two folders (including one repo-bundled and one from an external mount) — the first one loaded wins; the duplicate is skipped with a warning, so if your new node silently doesn't appear, check whether an existing node already claims that id. - Import errors in your Python files — prefer relative imports inside the node folder, mirroring the AI Agent plugin convention.
If you want working reference folders to copy (with "how it works" notes), start with these:
- Basic list-limiting node — the smallest possible shape:
node.json+ one small_node.py+entrypoint.py, singleoutputPortsentry, no branching, no loop state. Best starting point for your first node. - Field-reshaping node (split a list into multiple output fields) — same simple shape, but with several related, non-conditional config fields — good for seeing a slightly richer
inputSchemathat still has noallOf/conditional logic.
- Filter-style node — sets
config.requiresRawInput: trueand reuses the same structured-conditions JSON-Schema shape (if_else_node_conditions) that the engine's built-inif_else_node/switch_nodeuse, so its settings form matches the platform's condition-builder UI exactly. Good template if your node needs to evaluate a boolean expression per item.
- Script-execution node — an enum field switches between "run once for all items" vs "run once per item" modes, plus a
"type": "code_editor"field for a multi-line script. Good template for any node that needs a richer, non-text-input config field.
- Batch-splitting node — declares two
outputPorts(done/loop), usesrun_stateacross repeated visits to track its position through a list, and returns"route"to alternate between looping back into the batch-processing branch and falling through to the completion branch once exhausted. This is the canonical template for Section 6 and Section 7.
- Schedule-trigger node — the only trigger-flavored node folder in the repo today. Contains a
BaseNodesubclass for in-graph use alongside a separate trigger-agent class implementingsubscribe(...)/execute_trigger(...), and a heavily conditionalinputSchema(allOf/if/thenswitching between one-time vs. recurring schedules, and between daily/weekly recurrence fields) — the best real template for both Section 8 and Section 9.
- Condition/if-else branching, multi-way switch branching, wait/pause, and end-of-workflow nodes are all engine-native, not folder-based. Don't go looking for (or try to add) a
nodes/folder for any of these — studyworkflow_executor_service.py's handling ofif_else_node,switch_node,wait_node, andend_workflow_nodeinstead if you need to understand or change that behavior, and treat it as a different, non-pluggable system from everything else in this guide.