Custom Python Nodes
Write your own action nodes in Python and have them appear in the palette alongside the built-in ones.
Use cases:
- Glue logic that’s awkward to express by wiring built-in nodes
- One-off integrations with internal tools, REST APIs, or local CLIs
- Studio-specific helpers that don’t belong in the shipped node library
- Rapid prototyping of node ideas before requesting a built-in version
- Wrapping a Python library you already rely on (
numpy,Pillow,requests, …) as a graph-friendly node
Custom nodes are actions only for now — authoring custom triggers is not supported.
Enabling Custom Nodes
Open Help → Settings and scroll to Custom Python Nodes.
- Enable Custom Nodes — toggle on. The first time you flip it, the application probes the Python interpreter; an inline error appears if it can’t run
python3 --version. Settings won’t save until a working interpreter is detected. - Custom Nodes Directory — where the application looks for
.pyfiles. Defaults to~/.project-succession/custom-nodes/. Sub-folders are scanned recursively. - Python Interpreter — defaults to
python3. Set an explicit path (for example/opt/homebrew/bin/python3.12) if yourPATHdoesn’t expose the version you want. For Windows you might want to usepythonas an interpreter. - Execution Timeout (seconds) — how long a single
execute()call is allowed to run before the subprocess is killed. Defaults to 30 seconds. Raise this for long-running batch nodes; lower it if you want runaway loops to fail fast.
Settings changes take effect immediately — saving the dialog tells the service to re-read the configuration and rescan, no application restart needed.
Once enabled, the service scans the directory at startup, registers every valid .py file, and installs a file watcher so subsequent edits hot-reload without a restart. If for whatever reason the changes should not be detected, pressing Refresh Nodes in the Settings will force a manual update.
Quick Start
Create ~/.project-succession/custom-nodes/examples/greeting.py:
NODE_ID = "Greeting"
NODE_NAME = "Greeting"
NODE_CATEGORY = "Custom/Examples"
NODE_DESCRIPTION = "Formats a greeting from a name input and a prefix parameter."
PARAMETERS = [
{"name": "prefix", "label": "Prefix", "type": "string", "defaultValue": "Hello", "required": False},
{"name": "mode", "label": "Mode", "type": "enum", "options": ["greet", "shout"], "defaultValue": "greet", "required": True},
{"name": "repeat", "label": "Repeat", "type": "integer", "defaultValue": 1, "required": False},
]
INPUTS = [
{"name": "input0", "label": "Execute", "type": "object"},
]
OUTPUTS = [
{"name": "output0", "label": "On Success", "type": "object", "identifier": "greeting"},
]
def execute(config, inputs, secrets):
prefix = config.get("prefix", "Hello")
mode = config.get("mode", "greet")
repeat = config.get("repeat", 1)
name = inputs.get("input0")
if not name:
return {
"status": "failure",
"message": "No name provided on input0.",
}
greeting = f"{prefix}, {name}!"
if mode == "shout":
greeting = greeting.upper()
result = " ".join([greeting] * repeat)
return {
"status": "success",
"message": f"Generated greeting: {result}",
"output_data": {"output0": {"identifier": "greeting", "type": "string", "value": result}},
"firing_outputs": ["output0"],
}
Save the file. The Greeting node appears under Actions in the palette with a small purple “CUSTOM” badge. Drag it onto the canvas, wire an upstream node to input0, run the graph, and the greeting flows out of output0.
File Anatomy
A custom node is a single .py file. Module-level constants declare the node’s metadata; a single execute() function runs when the node fires. You do not import anything from Project Succession.
Required module-level constants
| Constant | Type | Purpose |
|---|---|---|
NODE_ID | str | Unique identifier within the file’s directory |
NODE_NAME | str | Display name shown in the palette and on the node card |
NODE_CATEGORY | str | Palette category. |
NODE_DESCRIPTION | str | One- or two-line description shown in the palette tooltip and on the node card |
PARAMETERS | list[dict] | Parameter declarations (the input fields on the node card) |
INPUTS | list[dict] | Input handle declarations (connection points on the left) |
OUTPUTS | list[dict] | Output handle declarations (connection points on the right) |
If any required constant is missing, the file appears in the Help → Settings and scroll to Load Errors list and is not registered.
Parameters
Parameters appear as input fields on the node card. Each entry in PARAMETERS is a dict:
{"name": "my_param", "label": "My Parameter", "type": "string", "required": True, "defaultValue": ""}
| Field | Required | Purpose |
|---|---|---|
name | yes | Internal identifier used in config.get(...) |
label | yes | Label rendered next to the field |
type | yes | One of the types below |
required | yes | True to mark visually as required (currently advisory; not enforced at runtime) |
defaultValue | no | Pre-filled when the node is dropped on the canvas |
options | only for enum | List of string options |
Parameter types
| Type | UI rendering | What config.get() returns |
|---|---|---|
string | Single-line text input | str |
textarea | Multi-line text area | str |
integer | Number input (integers only) | int |
float | Number input (decimals allowed) | float |
boolean | Checkbox | bool |
enum | Dropdown — options list is required | str (one of the option values) |
file_picker | Text field plus a “Browse” button opening the OS file picker | str (path) |
Parameter scripting
Custom node parameters support the same expression language as built-in nodes. A user can right-click a parameter field, choose Set Expression, and enter an expression like @upstream.output_path + "/result.png". The expression is evaluated at run time and the resolved value is what your execute() function sees in config.
See the Parameter Scripting Guide for details.
Inputs and Outputs
INPUTS = [
{"name": "input0", "label": "Execute", "type": "object"},
{"name": "input1", "label": "Name", "type": "string"},
]
OUTPUTS = [
{"name": "output0", "label": "On Success", "type": "object", "identifier": "greeting"},
{"name": "output1", "label": "On Failure", "type": "object", "identifier": "error"},
]
| Field | Required | Purpose |
|---|---|---|
name | yes | Handle identifier. Use the convention input0, input1, …, output0, output1, … |
label | yes | Label rendered next to the handle |
type | yes | Type hint (object, string, integer, float, boolean, array, …). Used by the editor to suggest compatible connections. |
identifier | output only | Semantic tag attached to the value flowing out of this output. Helps downstream nodes (and you, when inspecting traces) understand what the value represents. |
By convention input0 is the primary execution input and output0 is the primary success output. Subsequent outputs (output1, output2, …) are typically used for “On Failure” / branching paths.
You can declare as many inputs and outputs as you need. There is no fixed maximum.
The execute() function
def execute(config, inputs, secrets):
...
Three arguments:
| Argument | Shape | Contents |
|---|---|---|
config | dict | Parameters keyed by their declared name (after any expression evaluation) |
inputs | dict | Input values keyed by handle ("input0", "input1", …) |
secrets | dict | Configured secrets keyed by KEY_NAME (see below) |
Reading parameters
prefix = config.get("prefix", "Hello") # honour your defaultValue manually
mode = config["mode"] # if you're certain it's set
Reading inputs
inputs is always a dict keyed by handle name — even when only one input is declared or only one upstream connection exists. The engine normalises both cases for you, so you don’t have to deal with shape differences:
name = inputs.get("input1") # None if no upstream connection
data = inputs.get("input0")
Values are passed through as-is from upstream nodes. They may be raw Python primitives (str, int, float, bool, list, dict) or wrapped data objects of the form {"identifier": ..., "type": ..., "value": ...} depending on what the upstream emitted.
Reading secrets
Secrets are configured globally in Settings → Secrets. To use one in a custom node:
-
Add the key (e.g.
MY_API_KEY) in Settings → Secrets. The value is stored in the system keychain — never on disk in plain text and never inside graph files. -
Reference it by name in your custom node:
api_key = secrets.get("MY_API_KEY") if not api_key: return {"status": "failure", "message": "MY_API_KEY is not configured.", "output_data": None, "firing_outputs": None}
A secret returns None when it isn’t configured.
Return Value
execute() must return a dict with this shape:
{
"status": "success" | "failure", # required
"message": str, # optional — shown in the log and on the node card
"output_data": { ... } | None, # described below
"firing_outputs": [ "output0", ... ] | None,
}
status
Anything other than "success" is treated as failure. The node card flashes red, the message appears in the log, and the graph stops at this node: no output fires, whatever firing_outputs says. To handle an error and keep the graph going, return "success" and fire an error output instead (see firing_outputs below).
message
Free-form string surfaced in the log and on the node card after execution. Use it to describe what happened — both for success cases ("Wrote 32 KB to /tmp/foo") and failures ("API returned 500: server overloaded").
output_data
A dict keyed by output handle name. Each value is itself a dict with identifier, type, and value:
"output_data": {
"output0": {"identifier": "greeting", "type": "string", "value": "Hello, world"},
"output1": {"identifier": "error", "type": "string", "value": "no input"},
}
identifier— semantic tag (typically matching what you declared inOUTPUTS)type— informational type labelvalue— the actual data sent downstream (any JSON-serialisable Python value)
You only need to populate handles you’re actually emitting on. A handle missing from output_data simply has no value flowing out of it.
Setting output_data to None (or omitting the key entirely) passes a null object to whatever is connected downstream.
firing_outputs
Controls which execution outputs fire — i.e. which downstream branches the graph follows after this node:
- A list of handle names (e.g.
["output0"]) — only those outputs fire. None(or omit the key) — all declared outputs fire.
The most common pattern is to fire output0 on success and an error output such as output1 when something went wrong. Both are "success" results, because a "failure" stops the graph before any output fires:
return {"status": "success", ..., "firing_outputs": ["output0"]}
# vs. continue on the error branch:
return {"status": "success", ..., "firing_outputs": ["output1"]}
Execution Model
Each time the node fires, the service spawns a fresh Python subprocess to run execute(). This has consequences:
- State does not persist between invocations. Module-level variables, open files, network connections — all reset on every call. If you need cached state, persist it to disk (or some other external store) and reload it.
- Imports happen every time. Heavy imports (
tensorflow,pandas, …) pay their cost on every execution. For pipelines that fire many times per second, prefer lighter alternatives or batch work into a single firing. - Concurrency is handled by the engine, not by you. Multiple instances of the same node firing in parallel get separate subprocesses; you don’t need locks or thread-safety inside
execute(). sys.stdoutis reserved. The service reads the JSON returned byexecute()from the subprocess’s stdout. Don’tprint(...)to stdout — it corrupts the result. Usesys.stderr.write(...)for ad-hoc logging instead.
Returning stack traces on failure
If execute() raises an exception, the Python interpreter writes the traceback to stderr and the subprocess exits non-zero. The service captures that and surfaces the last line of the traceback as the failure reason on the node card and in the log. Most of the time that’s exactly what you want (NameError: name 'foo' is not defined, etc.).
For more controlled error reporting, catch the exception yourself. Return "failure" to stop the graph with your own message, or "success" with an error output to continue on an error branch:
try:
result = do_thing()
except SomethingSpecific as e:
# Stop the graph here:
return {"status": "failure", "message": f"do_thing failed: {e}"}
except SomethingRecoverable as e:
# Continue on the error branch wired to output1:
return {
"status": "success",
"message": f"do_thing failed: {e}",
"output_data": {"output1": {"identifier": "error", "type": "string", "value": str(e)}},
"firing_outputs": ["output1"],
}
Hot Reload
Saving a .py file in the custom nodes directory triggers a rescan automatically — no application restart needed.
- Edits to
execute()only take effect on the next time the node runs. Already-placed instances stay where they are. - Schema changes (adding/removing parameters, inputs, outputs; renaming
NODE_ID) refresh the palette card and any placed instances. Edges referencing handles that no longer exist are pruned with a toast notification telling you how many were removed. - Broken Python (syntax error, missing import) shows up as a red toast in the work area with the last line of the traceback, and as an entry in Settings → Custom Python Nodes → Load Errors. Once you fix the file, a green toast confirms it loaded successfully.
A placed node whose source file is temporarily broken stays on the canvas — it just won’t run cleanly until the file is fixed. Once the file parses, executions resume with the new code.
The watcher coalesces editor save events (some editors emit several file-system events per save), so you won’t see duplicate reloads from a single Ctrl-S.
Node Identity and Namespacing
Each custom node gets a fully qualified type key derived from its location:
| File location | Effective type key |
|---|---|
<dir>/greeting.py | custom/Greeting |
<dir>/examples/greeting.py | custom/examples/Greeting |
<dir>/my-pack/v2/greeting.py | custom/my-pack/v2/Greeting |
Sub-folders give you automatic namespacing. Two different sub-folders can each declare NODE_ID = "Greeting" without colliding. A duplicate within the same effective path is rejected with a load error.
Renaming NODE_ID or moving the file changes the type key. Any placed instances of the old type key become orphans — they remain on the canvas (rendering with the last known schema) but the new node has a different type. If you intend to rename, plan to update existing graphs accordingly.
Using Custom Nodes in Graphs
Custom nodes behave like any other action node:
- Drag from the palette to the canvas. The “Custom” badge tells you the node is user-provided.
- Wire data and execution connections as you would for built-in nodes. Type hints on handles guide compatible connections.
- Multiple inputs are supported. Wire several upstream outputs into different inputs (
input0,input1,input2, …) — the engine fans them in andexecute()receives them all in theinputsdict. - Use them inside subgraphs. Custom nodes can be placed in subgraphs and called from a parent graph just like built-in actions.
- Parameter expressions work — right-click a parameter field to use
@-references and arithmetic. See Parameter Scripting. - Disabled nodes (Studio plan and up) work — flagging a custom node as disabled skips it during execution.
Installing from Git
Share a collection of custom nodes by publishing them as a Git repository, and install them through the UI:
- Open Help → Settings → Custom Python Nodes.
- Click Install from Git.
- Paste the repository URL and click Install.
The repository is cloned into a sub-folder of your custom nodes directory (named after the repo). The scan picks up everything inside automatically — no further configuration needed. Because the install lands in its own sub-folder, the nodes are namespaced under custom/<repo-name>/... and can coexist with same-named nodes from other sources.
To update an installed pack, navigate to its directory and git pull — the watcher picks up the changes immediately.
Settings Reference
The Custom Python Nodes section of Settings contains:
| Control | Effect |
|---|---|
| Enable Custom Nodes | Master switch. When off, no .py files are scanned; existing custom nodes vanish from the palette. |
| Custom Nodes Directory | Path to the directory the service watches. Sub-folders are scanned recursively. |
| Python Interpreter | Executable used to load and run custom nodes. On blur the service runs <interpreter> --version and shows the result inline. |
| Load Errors | List of files that failed to load with the relevant error message. Updates live while the panel is open. |
| Install from Git | Opens the install dialog. |
Where Files Live
| Location | Purpose | Editable |
|---|---|---|
Custom nodes directory (default ~/.project-succession/custom-nodes/) | Your .py files. | Yes — this is your workspace. |
Scripts cache (~/Library/Application Support/project_succession/scripts/ on macOS, equivalent on Windows/Linux) | Bundled Python loader/executor used by the service. Re-extracted on startup. | No — managed by the application. |
Settings file (~/.project-succession/settings.json) | Stores the toggle, directory path, and interpreter setting. | Through the Settings dialog. |
| Secrets | System keychain. | Through Settings → Secrets. |
Security
Custom nodes execute arbitrary Python in the context of the application’s user account. There is no sandboxing.
- Only install custom nodes from sources you trust.
- Review code in shared/installed repositories before enabling them.
- The purple “Custom” badge on a palette card is a reminder that the node’s behaviour is determined by external code, not the shipped node library.
- Custom nodes have full filesystem and network access through the Python interpreter. If a node needs a secret, prefer reading it from
secrets(system keychain) over hard-coding it.
Limitations
- Triggers cannot be authored as custom nodes yet. You can react to built-in triggers and call out to anything from a custom action.
- No bundled dependency management. Whatever your
Python Interpretersetting can import is what’s available. If you need third-party packages, install them into that interpreter (e.g.pip install ...). - No type stubs / SDK. Everything you need is passed into
execute(); there’s nothing to import from Project Succession. required: Trueon a parameter is advisory. It’s intended for future enforcement; today you should validate withinexecute().- Each invocation pays the Python startup cost. For fire-frequent nodes, prefer doing more work per call over many small calls.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Node not appearing in palette | Custom nodes disabled in Settings, or a load error — check Settings → Custom Python Nodes → Load Errors |
| Red toast “Failed to load …” | Python error parsing your file. Last line of the toast is usually SyntaxError: … / NameError: … |
| ”Custom node executor script path is not set” | The service hasn’t initialised custom nodes — most likely because Enable Custom Nodes is off. Toggle it on and save. |
Empty inputs dict | The upstream connection isn’t wired, or it’s wired to a different handle than you expect. Check inputs.get("inputN") matches the handle name in INPUTS. |
Node fires but secrets are empty | The key isn’t configured in Settings → Secrets, or the name doesn’t match. Secrets are case-sensitive. |
| ”Invalid JSON from executor” | Something in your code (or an import) wrote to stdout. Direct logging to stderr instead: sys.stderr.write(...). |
| Node disappeared from canvas after editing | The schema-change cleanup pruned its placed instance because the file failed to load. Fix the file and the node reappears with its schema. |
Two files with the same NODE_ID collide | Move one into a sub-folder — sub-folders namespace the type key automatically. The collision shows up in Load Errors. |
Next Steps
- Settings Guide — broader Settings reference
- Parameter Scripting —
@-references and expressions - Working with Nodes — once your custom node is in the palette, it behaves like any other action