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.
The shape of a pack
Section titled “The shape of a pack”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 shipsexamples/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.
pack.toml
Section titled “pack.toml”id = "hello" # required: lowercase letters, digits, _ and -name = "Hello" # requiredversion = "0.1.0" # requiredauthor = "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 BoltjarEvery 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 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. 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.
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 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 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", "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}).
Rules a pack follows
Section titled “Rules a pack follows”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.tomland__init__.pyare both there, andid,nameandversionare 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.
Models in a pack
Section titled “Models in a pack”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.
License of your pack
Section titled “License of your pack”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.
Security
Section titled “Security”A pack is Python code that runs with your permissions when Boltjar starts. Install only packs you trust, and read Security.
Share it
Section titled “Share it”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.