Skip to content
Sponsor

Build a node pack

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 pack is a folder of such nodes that you drop into packs/.

Packs you build are yours to license: see License below.

packs/
hello/
pack.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 the pack ships

examples/packs/hello/ is a working pack with one pulled node and one fired node. Copy it into packs/, restart Boltjar, and its Shout and Greet nodes appear in the node library. The examples are MIT-0, so start from it freely.

id = "hello" # required: lowercase letters, digits, _ and -
name = "Hello" # required
version = "0.1.0" # required
author = "Your Name"
license = "MIT-0"
description = "A minimal example pack: one pulled node and one fired node."
homepage = "https://example.com/hello"
min_boltjar = "0.1.0" # refuse to load on an older Boltjar

Every node id in the pack starts with the pack’s id and a dot: hello.shout, hello.greet. Saved graphs store the id, so keep it fixed once people use it.

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 node that does work takes a trigger=True input. 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:

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

icon and subline on @node set how the node reads on the canvas:

  • icon names 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.
  • subline is 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 pack 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 pack registers.

Option What it does
trigger=True a value landing here fires the node
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 box

For 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", "xai/tts") a model picker for one family (llm, tts, stt, embed or rerank), with the picked model’s settings as knobs; the default runs when nothing is picked

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}).

Boltjar checks these when it loads a pack, and skips a pack that breaks one, with the reason in the server log and in GET /api/packs. The server and every other pack still load.

  • pack.toml and __init__.py are both there, and id, name and version are set.
  • Every node id starts with <pack 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.

Put model manifests in models/ next to pack.toml. They load after the core ones and cannot replace a model that is already declared. See Models.

Boltjar is AGPL-3.0-or-later with a node pack exception. A pack that talks to Boltjar only through boltjar.sdk, pack.toml, model manifests and the graph format, and copies no Boltjar code, is yours: license it however you want, open or closed. The exact terms are in LICENSE-EXCEPTION.md.

A pack is Python code that runs with your permissions when Boltjar starts. Install only packs you trust, and read Security.

Publish your pack in its own repository, with install steps that say “copy this folder into packs/”. A pack that belongs in core Boltjar goes through a pull request instead: see Contributing.