Custom Nodes
CodefyUI is backend-authoritative: a node's ports, parameters, and category all come from its Python definition, and the UI renders it automatically. To add new behavior, drop a .py file into backend/app/custom_nodes/ that extends BaseNode. To keep custom nodes outside the installation, set CODEFYUI_CUSTOM_NODES_DIR to another directory before starting the server. That directory replaces backend/app/custom_nodes/ rather than adding to it, and it applies to every project the installation opens. Files in it import each other relatively (from .helpers import x): from app.custom_nodes.helpers import x looks in backend/app/custom_nodes/ instead.
:::tip For a few lines of code, try the canvas first If what you need is a short transform or a statistic over what a graph already produced, the PythonScript node runs Python you type straight onto the canvas -- no file, no restart. Come back here when the code outgrows it, or when it needs files, the network, or a dependency outside the script allowlist. :::
Minimal example
from app.core.node_base import BaseNode, DataType, PortDefinition
class MyNode(BaseNode):
NODE_NAME = "MyNode"
CATEGORY = "Custom"
DESCRIPTION = "Does something"
@classmethod
def define_inputs(cls):
return [PortDefinition(name="input", data_type=DataType.TENSOR)]
@classmethod
def define_outputs(cls):
return [PortDefinition(name="output", data_type=DataType.TENSOR)]
def execute(self, inputs, params):
return {"output": inputs["input"]}
Hot reload
After adding or editing a custom node, reload without restarting the server:
- click Reload Nodes in the toolbar. It re-registers the nodes on the server and refetches them, so the palette updates at once; or
- send
POST /api/nodes/reload. This does the server half only: an editor that is already open keeps its old palette until you click Reload Nodes or reload the page.
To upload a file instead of copying it into the directory, use the Custom Node Manager; it reloads node definitions after each action.
Uploading through the Custom Node Manager
Open the manager with the toolbar's Custom Nodes button, or with the Manage... button in the Custom Nodes section of the sidebar's Custom & Plugins tab; Esc closes it. It lists each file in custom_nodes/, the node names defined by that file, and three actions:
- Upload .py sends one file to
POST /api/custom-nodes/upload. The file must have a.pyextension and cannot exceedCODEFYUI_MAX_UPLOAD_SIZE(500 MB). Its name cannot start with__or contain a character Windows does not allow in file names (see File names). The server scans it with the plugin AST gate at Tier 0. Custom nodes cannot declare capabilities, so imports such asrequestsorosproduce a400response with the gate's message. Put a node that needs imports outside Tier 0 in a plugin pack with a[security]section. Files copied directly into the custom nodes directory (backend/app/custom_nodes/, orCODEFYUI_CUSTOM_NODES_DIRwhen it is set) are loaded at the next reload without this scan. - Enable / Disable renames the file between
name.pyandname.py.disabled; a disabled file stays on disk and is skipped by discovery. - Delete removes the file (names starting with
__are protected).
After each action, the server rediscovers custom nodes, plugin packs, and presets. The palette reflects the result when the request completes; no separate reload is required.
Anatomy of a node
| Member | Purpose |
|---|---|
NODE_NAME | Unique identifier used in graph JSON (e.g. "MyNode"). |
CATEGORY | Palette grouping and color. |
DESCRIPTION | One line for the node palette: what the node does and what it outputs. Keep it to 56 characters — the list shows it under the node's name, two lines deep, and cuts off past that. (LaTeX is supported.) |
DETAILS | Optional. The rest of the documentation — the library it wraps, the formula, caveats, which sibling node to reach for instead. Shown in the config panel and the node's Docs tab, never in the palette list. Leave it empty when the summary already says everything. |
define_inputs() / define_outputs() | Return PortDefinition lists — each has a name, a data_type, and optional description / optional / media. |
define_params() | Return ParamDefinition lists — int, float, string, bool, select, file pickers (model_file, image_file, data_file), tensor_grid, code (a multi-line editor with syntax highlighting; still an ordinary string param), or secret, with default, options, min_value/max_value, and visible_when. A param the graph leaves out reaches execute at its default — the engine and an exported script both fill it in — so every declared param is in params. A secret param (e.g. an API key) is masked in the editor and its value is never persisted — it is blanked on save, export, and publish, so use an environment variable to supply it to published apps. advanced=True puts the param in the node's collapsed Advanced section and changes nothing else; it is saved, exported and cached like any other param. option_packs on a select param maps an option to the optional pack it needs, as "<pack>" or "<pack>:<item>". An option whose pack or item is not installed is greyed out; a current value that needs one stays selected and shows a warning with an Install pack button. |
define_outputs_dynamic(params) / define_inputs_dynamic(params) | Optional. Change output or input ports based on parameter values, such as Split's chunks or PythonScript's input_ports. The static methods must describe the default parameters because the palette uses them; validation, rendering, and preset export use the dynamic definitions. |
execute(self, inputs, params, progress_callback=None, *, context=None) | Execute the node and return a dict keyed by output-port name. The engine passes each optional keyword argument only when the signature declares it. progress_callback receives a dict for each progress event; for example, the training loop sends {"event": "epoch", ...}. While the node runs, a text string in the dict is shown on the node card, except in an epoch event. Each event replaces the last and the card shows at most the text's last 1,000 characters, so send the end of the text so far (progress_text_tail from app.core.loop_control), not only the newest part. Events are also stored with the run, so rate-limit frequent ones with ProgressThrottle from the same module. context provides the run's device, seed, and determinism flag. |
REQUIRES_PACK | Optional class attribute naming the optional pack the node needs, as "<pack>" or "<pack>:<item>" (None by default). /api/nodes exposes it as requires_pack; while the pack is missing, the palette and the node show a pack badge and the config panel offers the install. It does not stop a run. To fail a run without the pack, call require_pack("<pack>") from app.core.packs in execute; the editor shows the error as a toast with an Open Package Center button. |
cacheable / align_inputs / cache_fingerprint(params) | Optional cache and device controls. Set cacheable = False for a node with trainable state, a returned live object reference, or a side effect not represented by its return value. Set align_inputs = False when passing inputs directly to numpy, sklearn, or PIL; otherwise the engine moves input tensors to the run device, and Tensor.numpy() fails for tensors outside the CPU. Override cache_fingerprint to add external state referenced by a parameter, such as a file's modification time, to the cache key. |
A custom node's DESCRIPTION, DETAILS and param descriptions are shown as written in every UI language: zh-TW translations exist only for built-in nodes and the plugin packs that ship in this repository (frontend/src/i18n/nodeLocales/zh-TW.ts), and a custom node or a third-party plugin cannot add its own.
Data types
Ports use the shared DataType enum: TENSOR, MODEL, DATASET, DATALOADER, OPTIMIZER, LOSS_FN, SCALAR, STRING, IMAGE, LIST, TRANSFORM, ANY, TRIGGER. An edge is valid when both ends have the same type, when either end is ANY, or from IMAGE to TENSOR. TRIGGER connects only to TRIGGER and drives execution order from Start nodes.
Showing an image in the results panel
A node that returns a picture must declare it with media=MEDIA_IMAGE on the output port. The value on that port is then a base64-encoded PNG string (no data: prefix), and the results panel renders it as an image:
from app.core.node_base import MEDIA_IMAGE, BaseNode, DataType, PortDefinition
@classmethod
def define_outputs(cls):
return [
PortDefinition(
name="image",
data_type=DataType.STRING,
media=MEDIA_IMAGE,
),
]
Declaring is mandatory — nothing inspects your values to work out what they are. An undeclared port stays plain data no matter how much it looks like an image, which is what keeps a long text output (an LLM answer, a token dump) from being rendered as a broken picture.
Drawing a chart in the results panel
media=MEDIA_CHART is the same mechanism for plots. The port's value is a JSON chart spec — a plain dict — which the editor draws with its own SVG components, so the picture is themed, hoverable, and sharp at any zoom instead of a fixed-size PNG:
from app.core.node_base import MEDIA_CHART, BaseNode, DataType, PortDefinition
@classmethod
def define_outputs(cls):
return [
PortDefinition(name="chart", data_type=DataType.ANY, media=MEDIA_CHART),
]
def execute(self, inputs, params, progress_callback=None, *, context=None):
return {"chart": {
"kind": "bar", # bar | line | scatter | heatmap
"title": "Mean petal length by species",
"bars": [{"label": "setosa", "value": 1.462}],
}}
Every number in a spec must be a finite, plain Python float or int: run events are serialised with allow_nan=False, and a numpy.float32 is not JSON-serialisable at all. Keep specs small — a node_status payload over CODEFYUI_RUN_EVENT_PAYLOAD_CAP_BYTES (128 KB by default) is replaced by an elision marker. The full per-kind payload reference lives in the stats pack's README, which is the reference implementation.
Emitting a playable video
media=MEDIA_VIDEO is the third built-in kind, and it works by reference: a clip cannot ride the event stream (one node_status event is capped at 128 KB), so the file lives under settings.MEDIA_DIR and the port's value is a small dict pointing at it. /api/media/<path> serves the file inline with a real Content-Type, so the editor's <video>/<img> elements play it directly:
{"path": "rollouts/run1.mp4", # POSIX-style, RELATIVE to MEDIA_DIR
"url": "/api/media/rollouts/run1.mp4",
"format": "mp4", # mp4 | gif | webm
"fps": 10.0, "frames": 240, "width": 96, "height": 96, "bytes": 81234}
Don't hand-build these — write frames through the VideoWrite node (or call core.video_io from your node), which owns the encoding (mp4 via an ffmpeg binary on PATH, gif via Pillow with no dependency at all), the path containment, and the reference shape. An absolute path is refused at the wire.
Your own media kind
No kind is special-cased. The resolver keys on whatever string a port declares, and any port value that is a non-empty dict is shipped through untouched, so a pack declaring media="waveform" already arrives in the browser as {"output_kind": "waveform", ...}. Only rendering it needs a frontend change; an editor that does not know a kind ignores it rather than breaking.
Need to package existing nodes rather than write new behavior? Use a preset. Want to share nodes with others as an installable bundle? Build a plugin pack.