Custom nodes
A node is a Python class with a @node decorator. It declares its ports and knobs, and the editor draws it from that declaration: a new node needs no editor code, and it gets the same knobs, right-click menu and typed wires as the built-in ones. A custom node is a folder of such nodes that you drop into custom_nodes/, the way ComfyUI names it.
Custom nodes you build are yours to license: see License below.
The shape of a custom node
Section titled “The shape of a custom node”custom_nodes/ hello/ custom_node.toml the manifest __init__.py imported at startup; importing it registers the nodes nodes.py the nodes (any layout you like) models/ optional: model manifests it shipsexamples/custom_nodes/hello/ is a working custom node with one pulled node and one fired node. Copy it into custom_nodes/, restart Boltjar, and its Shout and Greet nodes appear in the node library. The examples are MIT-0, so start from it freely.
custom_node.toml
Section titled “custom_node.toml”id = "hello" # required: lowercase letters, digits, _ and -name = "Hello" # requiredversion = "0.1.0" # requiredauthor = "Your Name"license = "MIT-0"description = "A minimal example custom node: one pulled node and one fired node."homepage = "https://example.com/hello"min_boltjar = "0.1.0" # refuse to load on an older BoltjarEvery node id in the custom node starts with its id and a dot: hello.shout, hello.greet. Saved graphs store the id, so keep it fixed once people use it.
A pulled node
Section titled “A pulled node”A data node runs when a fired node reads its output. It gets its inputs as keyword arguments and returns a dict with one entry per output:
from boltjar.sdk import Kind, Port, Widget, node
@node(id="hello.shout", name="Shout", kind=Kind.TRANSFORM, category="Hello", pulled=True, summary="Upper-case the text and add a suffix.", icon="text-outline", subline="shout · {suffix|or:no suffix}")class Shout: suffix = Widget(kind="text", default="!") inputs = [Port("text", "text")] outputs = [Port("out", "text")]
def run(self, text=None, **_): return {"out": str(text or "").upper() + str(self.suffix or "")}A fired node
Section titled “A fired node”A node that does work takes a trigger=True input of type event. It runs when an event lands there, pulls its other inputs at that moment, and passes a trigger on so the next node can run after it. A data wire never fires a node: a custom node that declares trigger=True on a port that is not an event is refused when it loads, with the node named, and the editor lets only event wires into a trigger input:
@node(id="hello.greet", name="Greet", kind=Kind.TRANSFORM, category="Hello", summary="Greet a name each time a trigger arrives.", icon="hand-left-outline", subline="{greeting|clip:14} · x{times}")class Greet: greeting = Widget(kind="text", default="Hello") times = Widget(kind="number", default=1, min=1, max=5, step=1) inputs = [Port("trigger", "event", trigger=True), Port("name", "text", optional=True)] outputs = [Port("out", "text"), Port("trigger", "event")]
def run(self, trigger=None, name=None, **_): line = f"{self.greeting}, {name or 'world'}!" return {"out": " ".join([line] * int(self.times)), "trigger": True}Outputs go out in the order of the dict, so put data before trigger: a node fired by the trigger then reads the new data. A fired node’s run can be async def for anything that waits on the network; a pulled node’s run must stay synchronous.
How a node looks
Section titled “How a node looks”icon and subline on @node set how the node reads on the canvas:
iconnames an Ionicons icon the editor ships, such as"timer-outline". The node shows it in its header, the palette and the menus. Left empty, or naming an icon the editor does not ship, the node shows the glyph of its kind.sublineis the short line under the node’s title.{field}puts a knob’s value there (its default until it is set), and filters after a|shape it. Left empty, the line is the node’s category in lower case.
| Filter | What it gives |
|---|---|
clip:16 |
the value on one line, cut to 16 characters |
or:empty |
empty when the value is empty |
bool |
true or false |
model |
a model id as provider · model |
tags |
how many {tags} a template holds, as 3 tags |
A placeholder that names no knob, or a filter the editor does not know, stops the custom node from loading with an error that says which.
Port(name, type, ...) declares an input or output. The type is one of the pipe types, or a new one your custom node registers.
| Option | What it does |
|---|---|
trigger=True |
an event landing here fires the node; the port must be an event |
optional=True |
the graph turns On without a wire here |
growable=True |
one socket per wire, with an empty socket always ready |
ghost_base="name" |
a growable socket is named after the wired source, like the HTTP Request tags |
op_field="operation", op_values=("find",) |
shown only while a knob has one of those values |
scaffold="hello.greet" |
on an output: right-clicking offers to add that node, already wired |
A knob is a class attribute. A plain annotated one is enough for simple cases:
class Wait: seconds: float = 2.0 # a number knob loud: bool = False # an on/off switch label: str = "done" # a text boxFor more control, use a Widget, or the helpers in boltjar.sdk:
| Helper | Knob |
|---|---|
select(["a", "b"], default="a") |
a dropdown |
slider(0.7, 0.0, 2.0, step=0.05) |
a number with a slider |
code("", expand=True) |
a text block; expand makes it grow when the node is resized |
tmpl("", kind="text") |
a text field that completes {tag} and {{secret.NAME}} as you type |
model("tts") |
a model picker for one family (llm, tts, stt, embed or rerank), with the picked model’s settings as knobs. It starts with no model picked, and a graph with the node turns On only once one is; optional=True lets the node run with none picked. A default model is refused |
Widget also takes label, placeholder, op_field and op_values (show it only for some operations), options_from (db.tables or kv.keys fills a dropdown from the wired store), accepts_secrets, and promotable=False for a knob that must never become an input (a kind="model" picker never does). A knob reads as self.<name> inside run, and when you convert it to an input and wire it, self.<name> holds the wired value.
The SDK also has secret(), but the editor does not draw that knob, so nobody can fill it in. For a key, use a field with accepts_secrets=True (every tmpl() field has it): the editor offers the stored secrets there as {{secret.NAME}}, so the graph file keeps the name and never the key. Your node gets the text as typed. The runtime does not swap in the key; the core nodes do that themselves, and boltjar.sdk has no call for it.
kind= sets how the node runs and how the editor colours it:
| Kind | Implements |
|---|---|
Kind.VALUE |
value(self): a constant source |
Kind.TRANSFORM |
run(self, **inputs): synchronous when pulled, sync or async when fired |
Kind.TRIGGER |
async start(self, ctx): loop while ctx.alive, call ctx.emit(port, value) |
Kind.SENSOR |
run(self, **inputs), with pulled=True, volatile=True to read fresh every time |
Kind.LOGIC |
route(self, **inputs): return the name of the output to send the value to |
Kind.OUTPUT |
deliver(self, value, ctx, inputs=None) |
A trigger’s ctx also has ctx.sleep(seconds), ctx.log(...), ctx.emit_many({...}) to send several outputs on one turn, and ctx.pull(port) to read a wired input. To fail on purpose and still fire an error output, raise NodeFailure(message, outputs={"error": message}).
Rules a custom node follows
Section titled “Rules a custom node follows”Boltjar checks these when it loads a custom node, and skips one that breaks a rule, with the reason in the server log and in GET /api/custom-nodes. The server and every other custom node still load.
custom_node.tomland__init__.pyare both there, andid,nameandversionare set.- Every node id starts with
<its id>.. - It does not redefine a node or a pipe type that is already registered. Registering the same type again, unchanged, is fine.
- Its
min_boltjar, if set, is not newer than the running Boltjar. - It imports without errors.
Models in a custom node
Section titled “Models in a custom node”Put model manifests in models/ next to custom_node.toml. They load after the core ones and cannot replace a model that is already declared. See Models.
License of your custom node
Section titled “License of your custom node”A custom node is yours: license it however you want, open or closed. Boltjar is Apache-2.0, so building on its SDK asks nothing of your code. If you copy parts of Boltjar into your custom node, keep their license notice and the NOTICE.
Security
Section titled “Security”A custom node is Python code that runs with your permissions when Boltjar starts. Install only custom nodes you trust, and read Security.
Share it
Section titled “Share it”Publish your custom node in its own repository, with install steps that say “copy this folder into custom_nodes/”. One that belongs in core Boltjar goes through a pull request instead: see Contributing.