Build Custom Nodes
If you can write a Python function, you can build a Salpa node. The minimum viable node is two files: a meta.toml manifest and a node.py with one class.
This page dissects Hello Encrypt — a real, working node from the Hello World Pipeline you already have installed — and then shows you how to start your own.
The example: Hello Encrypt
Open these files alongside this page — view them directly in your browser, or right-click to download:
| File | Open |
|---|---|
meta.toml — the manifest | view |
node.py — the code | view |
README.md — package docs | view |
The companion files — hello_decrypt and hello_reveal — are linked at the bottom of this page.
That’s the whole node. About 150 lines of Python and 25 lines of TOML. It takes a plaintext message, applies a Caesar cipher, and writes the ciphertext to a file. We’ll walk through it section by section.
The manifest (meta.toml)
[package]
name = "hello-encrypt"
version = "1.0.0"
description = "Encrypts a message using a Caesar cipher and writes the ciphertext to a file"
author = "BoCoFlow Development Team"
license = "MIT"
[node]
class_name = "HelloEncrypt"
display_name = "Hello Encrypt"
hashtags = [
"hello-world",
"encryption",
"starter",
"tutorial",
"text-output",
"beginner",
]
[node.metadata]
tooltip = "Encrypts a message with a Caesar cipher and writes the ciphertext to a file"
[node.features]
supports_resume = false
requires_gpu = false
parallel_capable = false
long_running = false
[dependencies]
python = ">=3.9"
[compatibility]
platforms = ["linux", "macos", "windows"]What each block does:
[package]— name (kebab-case), version, description, license. Thenameis the package’s distributable identity.[node]—class_namemust match the Python class innode.py.display_nameis what users see on the canvas.hashtagsdrive search and grouping in the Node Library — five to ten descriptive tags is ideal.[node.metadata]— optional UI hints liketooltipandicon(an emoji or path to an SVG).[node.features]— capability flags. Most are advisory;requires_gpuis the only one that actually changes scheduling today.[dependencies]— a coarse Python version pin. Real dependencies go inpixi.toml(covered below).[compatibility]— which platforms the node supports.
The Python file (node.py)
import os
from datetime import datetime
from bocoflow_core.node import Node, NodeException, NodeResult
from bocoflow_core.parameters import (
FolderParameter,
IntegerParameter,
StringParameter,
)
from bocoflow_core.stream_logger import stream_log
def caesar_encrypt(text, shift):
"""Encrypt text using a Caesar cipher with the given shift."""
result = []
for char in text:
if char.isalpha():
base = ord("A") if char.isupper() else ord("a")
result.append(chr((ord(char) - base + shift) % 26 + base))
else:
result.append(char)
return "".join(result)
class HelloEncrypt(Node):
"""Encrypts a plaintext message using a Caesar cipher."""
OPTIONS = {
"case_name": StringParameter(
"Case Name",
docstring="Name for this case (used in output file names).",
),
"message": StringParameter(
"Message",
default="Hello from BoCoFlow!",
docstring="The plaintext message to encrypt.",
),
"shift": IntegerParameter(
"Cipher Shift",
default=3,
docstring="Number of positions to shift each letter (1-25).",
),
"output_dir": FolderParameter(
"Output Directory",
docstring="Directory where the encrypted file will be written.",
),
}
def execute(self, predecessor_data, flow_vars):
stream_log("Starting encryption...", node_id=self.node_id, progress=0)
try:
result = NodeResult()
case_name = flow_vars["case_name"].get_value() or "secret"
message = flow_vars["message"].get_value() or "Hello from BoCoFlow!"
shift = max(1, min(25, int(flow_vars["shift"].get_value() or 3)))
output_dir = self.resolve_path(flow_vars["output_dir"].get_value())
stream_log(f"Encrypting with shift={shift}...", node_id=self.node_id, progress=30)
encrypted = caesar_encrypt(message, shift)
os.makedirs(output_dir, exist_ok=True)
output_file = os.path.join(output_dir, f"{case_name}_encrypted.txt")
with open(output_file, "w") as f:
f.write(encrypted + "\n")
stream_log(f"Wrote: {case_name}_encrypted.txt", node_id=self.node_id, progress=90)
output_path = self.format_output_path(output_file)
result.data = {"case_name": case_name, "output_file": output_path}
result.files["output"] = {"encrypted": output_path}
result.success = True
result.message = f"Message encrypted for {case_name}"
stream_log("Encryption complete", node_id=self.node_id, progress=100)
return result.to_json()
except Exception as e:
stream_log(f"Error: {str(e)}", level="error", node_id=self.node_id)
raise NodeException("hello-encrypt", str(e))That’s the whole node. Let’s break it down.
Imports
from bocoflow_core.node import Node, NodeException, NodeResult
from bocoflow_core.parameters import FolderParameter, IntegerParameter, StringParameter
from bocoflow_core.stream_logger import stream_logThree imports cover almost every node:
Node— the base class for non-HPC nodes. (HPC nodes inherit fromHPCNodeBaseinstead.)NodeResultandNodeException— the standard return type and the exception class.- Parameter classes — one import per parameter type you use.
stream_log— for real-time progress updates in the UI.
OPTIONS defines the configuration form
OPTIONS is a class attribute, not a module-level dict. Each entry is a Parameter instance, not a JSON-style spec. Salpa renders one form field per entry in the side panel.
class HelloEncrypt(Node):
OPTIONS = {
"shift": IntegerParameter(
"Cipher Shift",
default=3,
docstring="Number of positions to shift each letter (1-25).",
),
...
}The dict key ("shift") is what your execute() method uses to read the value. The first positional argument ("Cipher Shift") is the label users see. default and docstring are optional but recommended.
execute(predecessor_data, flow_vars) is the contract
def execute(self, predecessor_data, flow_vars):
...
return result.to_json()Two arguments come in:
predecessor_data— a list of results from upstream nodes. Each entry is whatever the upstream node returned, typically a dict.flow_vars— a dict keyed by yourOPTIONSnames. Each value is aParameterobject — call.get_value()to read it.
shift = flow_vars["shift"].get_value()Path prefixes resolve via self.resolve_path
Salpa uses URI-style prefixes for cross-platform paths:
| Prefix | Meaning |
|---|---|
abs:/home/user/data.pdb | Absolute path |
rel:data/input.pdb | Relative to the workflow’s working directory |
node:demo_data/example.pdb | Relative to the node package itself |
Folder and file pickers in the UI return prefixed paths. Always resolve them before opening:
output_dir = self.resolve_path(flow_vars["output_dir"].get_value())Returning a result
Build a NodeResult, populate the parts you need, and return result.to_json():
result = NodeResult()
result.success = True
result.message = "Message encrypted for demo"
result.data = {"case_name": "demo", "output_file": "rel:demo_encrypted.txt"}
result.files["output"] = {"encrypted": "rel:demo_encrypted.txt"}
return result.to_json()The NodeResult shape:
| Field | Type | Purpose |
|---|---|---|
success | bool | Did the node finish successfully? |
message | str | Human-readable status line shown in the UI |
data | dict | The payload that downstream nodes see in their predecessor_data |
metadata | dict | Common metadata (case_name, execution_time) |
files | dict | {"input": {...}, "output": {...}} — file paths produced or consumed |
Anything you put in result.data becomes the next node’s predecessor_data[0]. Keep it small and JSON-serializable.
Parameter types
These are the real classes from bocoflow_core.parameters. Use them as instances inside your OPTIONS dict.
| Class | Type | When to use |
|---|---|---|
StringParameter | Single-line text | Names, labels, short identifiers |
TextParameter | Multi-line text | Sequences, scripts, long prose |
PasswordParameter | Masked text | Secrets (rendered as ••••) |
IntegerParameter | Whole number | Counts, indices, integer settings |
FloatParameter | Decimal number | Thresholds, temperatures, ratios |
BooleanParameter | Checkbox | On/off toggles |
SelectParameter | Dropdown | Fixed set of choices |
FileParameter | File picker | Read-only file path with existence check |
FileParameterEdit | File picker + manual input | Files that may not exist yet (output) |
FolderParameter | Folder picker | Output directories, input dirs |
All of them share the same constructor signature: (label, default=None, docstring=None, optional=False). SelectParameter adds an options argument:
from bocoflow_core.parameters import SelectParameter
OPTIONS = {
"force_field": SelectParameter(
"Force Field",
options=["amber99sb", "charmm27", "oplsaa"],
default="amber99sb",
docstring="Molecular mechanics force field",
),
}Adding Python dependencies (pixi.toml)
Hello Encrypt uses only Python’s standard library, so it has no pixi.toml and runs in-process in the worker. The moment your node needs NumPy, BioPython, MDAnalysis, or anything from PyPI or Conda, you add a pixi.toml next to your node.py:
[project]
name = "my-analysis-node"
version = "1.0.0"
channels = ["conda-forge"]
platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"]
[dependencies]
python = ">=3.9,<3.13"
numpy = ">=1.26"
biopython = ">=1.83"
# Required for stream_log() to reach the UI from a subprocess
redis-py = ">=5.0.0"
[pypi-dependencies]
# bocoflow-core is needed because your node.py imports from it
bocoflow-core = ">=1.0.0"When pixi.toml is present, Salpa automatically:
- Creates an isolated Python environment for your node
- Installs every dependency listed
- Runs your node in a subprocess inside that environment
Your dependencies don’t pollute the rest of Salpa. Other nodes don’t see them. This is the isolated subprocess strategy, and it’s what every non-trivial node uses.
Two gotchas worth knowing:
- Add
redis-py >= 5.0.0if you wantstream_log()updates to reach the UI in real time. Without it, logs still write to your console but live progress updates stop reaching the UI. - Add
bocoflow-coreto[pypi-dependencies]because yournode.pyimports from it. Without it, the subprocess won’t be able to instantiate your node class.
The two-level pattern: core.py + node.py
Hello Encrypt is small enough that everything lives in one file. For anything bigger, split your code in two:
my_node/
├── core.py Pure science — no Salpa imports
├── node.py Thin Salpa wrapper around core.py
└── ...The point is to keep your real computation framework-free. core.py should run as a standalone Python script. node.py reads parameters, calls core.py, and packages the result.
# core.py — no bocoflow_core imports
def run_analysis(input_pdb: str, threshold: float) -> dict:
# ... real work ...
return {"hits": [...], "summary": "..."}# node.py — the Salpa wrapper
from bocoflow_core.node import Node, NodeResult
from bocoflow_core.parameters import FileParameter, FloatParameter
try:
from .core import run_analysis
except ImportError:
run_analysis = None # Server env doesn't need core.py loaded
class MyAnalysis(Node):
OPTIONS = {
"input_pdb": FileParameter("Input PDB"),
"threshold": FloatParameter("Threshold", default=0.5),
}
def execute(self, predecessor_data, flow_vars):
result = NodeResult()
analysis = run_analysis(
input_pdb=self.resolve_path(flow_vars["input_pdb"].get_value()),
threshold=flow_vars["threshold"].get_value(),
)
result.data = analysis
result.success = True
return result.to_json()The try/except ImportError is intentional. Salpa loads node.py in two contexts: once to render the configuration form, and again — inside the isolated environment — to actually run the node. Only the second context has your scientific dependencies installed. Falling back to None lets the file load cleanly in either context; real execution happens where core.py is available.
For real-world examples of this split, install gmx-mdrun-local or pdb2pqr from the marketplace and inspect the package directories.
Logging and progress
stream_log() does two things at once: it writes to the Python logger (file and console), and it feeds the live progress bar and streaming log panel in the Salpa UI.
from bocoflow_core.stream_logger import stream_log
def execute(self, predecessor_data, flow_vars):
stream_log("Starting...", node_id=self.node_id, progress=0)
# ... do work ...
stream_log("Halfway there", node_id=self.node_id, progress=50)
# ... finish ...
stream_log("Done", node_id=self.node_id, progress=100)The function signature:
stream_log(
message: str,
level: str = "info", # "debug" | "info" | "warning" | "error"
node_id: str = None, # Always pass self.node_id
progress: float = None, # 0-100, drives the UI progress bar
component: str = None, # Optional class name
**metadata # Extra fields stashed in the log entry
)Pass node_id=self.node_id on every call so the log lines attach to the right node in the UI. Update progress at meaningful checkpoints — empty values between updates feel slower than real progress, even if the wall-clock time is identical.
If the live update channel isn’t reachable (for example, when redis-py is missing from your pixi.toml), stream_log falls back gracefully to file/console logging without raising.
Error handling
Wrap your execute() body in try/except and raise NodeException with a useful message:
from bocoflow_core.node import NodeException
def execute(self, predecessor_data, flow_vars):
try:
# ... your work ...
return result.to_json()
except Exception as e:
stream_log(f"Error: {str(e)}", level="error", node_id=self.node_id)
raise NodeException("hello-encrypt", str(e))NodeException takes two arguments: a node identifier (the meta.toml name is fine) and a reason. The UI surfaces both in the failure banner.
Loading your node into Salpa
A node package is just a directory with meta.toml + node.py (and optionally core.py, pixi.toml, README.md, tests/, demo_data/). Once you have one, Salpa gives you three ways to bring it in — all from the Marketplace page in the app.
Import from a local folder
Open Marketplace → Import Single Node, switch to the Local Folder tab, and point Salpa at your package directory (the one containing meta.toml). Two modes:
- Symlink mode (default) — Salpa links to your folder in place. Edits to your source are picked up immediately, so this is the right choice while you’re iterating on the code.
- Copy mode — Salpa copies the package into its installed-nodes area. Source and installed copy are independent afterwards.
You can optionally pre-build the isolated environment and run the package’s tests during import. Once installation finishes, your node appears in the Node Library under any of its hashtags.
Import from a GitHub repository
Same dialog, GitHub tab. Paste the repo URL (e.g. https://github.com/user/my-bocoflow-node) and optionally a tag or branch. Salpa clones the repo, validates the package layout, and installs it. Best for sharing your own node with collaborators or pulling someone else’s published node directly.
Add a source
For larger collections — a registry of related packages, your lab’s shared node library, or a long-lived development feed — use Marketplace → Sources and add a new source. Once registered, all packages in that source show up in the Browse tab and can be installed with one click. Both local-path sources and remote registries are supported.
What about a public marketplace?
A community-curated marketplace where anyone can publish nodes for everyone else to discover is on the roadmap and will land once there’s a meaningful volume of nodes and packages to host. Until then, the three flows above cover everything — local development, sharing via GitHub, and managing private registries.
Best practices
- Use real
Parameterinstances inOPTIONS, not dict specs. The type system gives you validation for free. - Always pass
node_id=self.node_idtostream_logso logs attach to the right node in the UI. - Update progress at meaningful checkpoints — 0%, 25%, 50%, 75%, 100% is a fine default. Silence between updates feels slow.
- Resolve paths with
self.resolve_pathbefore opening files. UI-supplied paths almost always have anabs:/rel:/node:prefix. - Keep
result.datasmall and JSON-serializable. It’s the wire format between nodes; large blobs belong on disk with a path inresult.files. - Catch exceptions in
execute()and re-raise asNodeExceptionwith a reason string. The UI surfaces the reason directly. - Add 5–10 hashtags to
meta.toml. Discoverability in the Node Library depends entirely on tagging. - Split heavy nodes into
core.py+node.py. Keep the science framework-free; let the wrapper be thin. - Add a
redis-pydependency to yourpixi.tomlif your node will run in a subprocess and you want live log streaming. - Include a
demo_data/folder with a small example input. Salpa’s “Use Demo Data” toggle picks it up automatically.
All source files
The full Hello World Pipeline source is published with this site. Click to view in your browser, or right-click to save.
| File | Purpose |
|---|---|
package.toml | Multi-node bundle manifest |
README.md | Pipeline reference |
workflows/hello-world-pipeline.json | Pre-wired workflow template |
hello_encrypt/meta.toml | Encrypt — manifest |
hello_encrypt/node.py | Encrypt — code |
hello_encrypt/README.md | Encrypt — docs |
hello_decrypt/meta.toml | Decrypt — manifest |
hello_decrypt/node.py | Decrypt — code |
hello_decrypt/README.md | Decrypt — docs |
hello_reveal/meta.toml | Reveal — manifest |
hello_reveal/node.py | Reveal — code |
hello_reveal/README.md | Reveal — docs |
After Salpa’s first launch, the same files are also installed on your machine — see “Where to put your node” above for the local paths.
What’s next
- Node System — the architecture this page builds on
- Getting Started — install Salpa and run your first workflow