Skip to main content
Jeremias Meister - Tools & Pipeline

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.

  1. 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.
  2. Custom Nodes Directory — where the application looks for .py files. Defaults to ~/.project-succession/custom-nodes/. Sub-folders are scanned recursively.
  3. Python Interpreter — defaults to python3. Set an explicit path (for example /opt/homebrew/bin/python3.12) if your PATH doesn’t expose the version you want. For Windows you might want to use python as an interpreter.
  4. 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

ConstantTypePurpose
NODE_IDstrUnique identifier within the file’s directory
NODE_NAMEstrDisplay name shown in the palette and on the node card
NODE_CATEGORYstrPalette category.
NODE_DESCRIPTIONstrOne- or two-line description shown in the palette tooltip and on the node card
PARAMETERSlist[dict]Parameter declarations (the input fields on the node card)
INPUTSlist[dict]Input handle declarations (connection points on the left)
OUTPUTSlist[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": ""}
FieldRequiredPurpose
nameyesInternal identifier used in config.get(...)
labelyesLabel rendered next to the field
typeyesOne of the types below
requiredyesTrue to mark visually as required (currently advisory; not enforced at runtime)
defaultValuenoPre-filled when the node is dropped on the canvas
optionsonly for enumList of string options

Parameter types

TypeUI renderingWhat config.get() returns
stringSingle-line text inputstr
textareaMulti-line text areastr
integerNumber input (integers only)int
floatNumber input (decimals allowed)float
booleanCheckboxbool
enumDropdown — options list is requiredstr (one of the option values)
file_pickerText field plus a “Browse” button opening the OS file pickerstr (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"},
]
FieldRequiredPurpose
nameyesHandle identifier. Use the convention input0, input1, …, output0, output1, …
labelyesLabel rendered next to the handle
typeyesType hint (object, string, integer, float, boolean, array, …). Used by the editor to suggest compatible connections.
identifieroutput onlySemantic 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:

ArgumentShapeContents
configdictParameters keyed by their declared name (after any expression evaluation)
inputsdictInput values keyed by handle ("input0", "input1", …)
secretsdictConfigured 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:

  1. 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.

  2. 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 in OUTPUTS)
  • type — informational type label
  • value — 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.stdout is reserved. The service reads the JSON returned by execute() from the subprocess’s stdout. Don’t print(...) to stdout — it corrupts the result. Use sys.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 locationEffective type key
<dir>/greeting.pycustom/Greeting
<dir>/examples/greeting.pycustom/examples/Greeting
<dir>/my-pack/v2/greeting.pycustom/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 and execute() receives them all in the inputs dict.
  • 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:

  1. Open Help → Settings → Custom Python Nodes.
  2. Click Install from Git.
  3. 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:

ControlEffect
Enable Custom NodesMaster switch. When off, no .py files are scanned; existing custom nodes vanish from the palette.
Custom Nodes DirectoryPath to the directory the service watches. Sub-folders are scanned recursively.
Python InterpreterExecutable used to load and run custom nodes. On blur the service runs <interpreter> --version and shows the result inline.
Load ErrorsList of files that failed to load with the relevant error message. Updates live while the panel is open.
Install from GitOpens the install dialog.

Where Files Live

LocationPurposeEditable
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.
SecretsSystem 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 Interpreter setting 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: True on a parameter is advisory. It’s intended for future enforcement; today you should validate within execute().
  • Each invocation pays the Python startup cost. For fire-frequent nodes, prefer doing more work per call over many small calls.

Troubleshooting

SymptomLikely cause
Node not appearing in paletteCustom 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 dictThe 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 emptyThe 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 editingThe 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 collideMove one into a sub-folder — sub-folders namespace the type key automatically. The collision shows up in Load Errors.

Next Steps