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.
Would you rather follow one goal from start to finish? Tutorial: a node that runs a GROMACS minimisation walks the whole way — empty directory, dependency, code, the bug that only shows up once the app installs your node, and your own node running on the canvas. This page is the reference companion: it takes a simpler node apart file by file, so read it when you want to know what a piece is rather than what to do next.
Fast path — the whole loop from the terminal:
pip install salpa-cli(Python 3.9+ — the macOS system Python is enough), thensalpa new my-nodegenerates a ready-to-edit package (core.py+node.py+tests/+pixi.toml).salpa add numpy biopythonnames the packages your code needs — it resolves them, writes them, and narrows the platforms you claim to the ones that can actually run them.salpa env installbuilds the isolated environment;pixi run testruns yournode.pytests in it;salpa validatechecks the package will install and register;salpa smokeactually runs the node on its owndemo_data/; andsalpa pushhands it to the Salpa app on your machine, ready to install. This page walks through what that scaffold contains, using a real node as the example.Or don’t hold the order in your head.
salpa devshows the package in one panel — what is done, what is outstanding, what each step would do — and runs the next one for you. It re-reads the state every time, so the checklist is the menu. Whether your node actually runs, and whether the app has it, stay marked not checked until something checks them.
validateandsmokeanswer different questions.validateis static — it reads your package and tells you whether it satisfies the install and registration contract, without running anything.smokeexecutes it:execute()on a real input must succeed with a non-trivial payload, andexecute()with that input taken away must not report success. Neither checks that your science is right — only that the node runs and refuses bad input.
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)
Here is the manifest to write for this node — every key below does something. (The shipped
hello_encrypt/meta.toml linked above is a little different: it predates this guidance and still
carries several of the inert keys called out after the table. It loads fine; it is just not the
model to copy.)
[package]
name = "hello-encrypt"
version = "1.0.1"
description = "Encrypts a message using a Caesar cipher and writes the ciphertext to a file"
author = "BoundaryComputing"
license = "MIT"
[node]
class_name = "HelloEncrypt"
display_name = "Hello Encrypt"
hashtags = [
"hello-world",
"encryption",
"starter",
"tutorial",
"text-output",
"beginner",
]
[node.metadata]
icon = "🔒"
category = "Tutorial"
# platform_note = "Requires X, which has no Windows conda package."
[node.features]
# default_timeout = 3600 # optional; seconds
[dependencies]
python = ">=3.9"What each block does:
[package]— name (kebab-case), version, description, author, license. Thenameis the package’s distributable identity.licenseis required: it is read with a bare subscript, so a package without one raises on load, gets swallowed by the registry’s catch-all, and your node simply never appears in the palette — with no error you will see.[node]—class_namemust match the Python class innode.py.display_nameis what users see on the canvas.num_in/num_outset the connection ports.hashtagsdrive search and grouping — five to ten descriptive tags is ideal.[node.metadata]—icon(an emoji),category(which groups your node in the Node Library), and optionallyplatform_note. Both[node.metadata].categoryand[node].categoryare read, but metadata wins — put it there.platform_notereplaces the app’s generic “unsupported platform” error with your own sentence; set it whenever a dependency does not build everywhere. In a multi-node package it lives inpackage.tomlunder[package.metadata].[node.features]—default_timeoutonly. It becomes the node’s starting timeout in the config panel (600s if unset).[dependencies]— declarative only: what the app displays as the node’s dependencies. It installs nothing. The environment comes frompixi.toml, and that is where runtime deps must go.
Write only the keys above. Manifests in the wild also carry
tooltip,supports_resume,requires_gpu,parallel_capable,long_running,bocoflow_core_version,[documentation]and[compatibility]. Nothing reads any of them — they were propagated by an older template and describe a node to no one. They are harmless, so existing packages need no edit, but a new manifest should leave them out. (The README is served by name: it must beREADME.md.)
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 Salpa!",
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 Salpa!"
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— one entry per upstream node. Each entry is that node’sresult.dataalready unwrapped for you — not the{"success": …, "data": …}envelope it returned.flow_vars— a dict keyed by yourOPTIONSnames. Each value is aParameterobject — call.get_value()to read it.
shift = flow_vars["shift"].get_value()Read one level in, not two. This is the most common mistake in a first multi-node package, and it fails quietly — the node runs, finds nothing, and carries on with a default:
value = predecessor_data[0]["case_name"] # correct value = predecessor_data[0]["data"]["case_name"] # wrong — already unwrapped
If your node accepts input from more than one upstream node, upstream() merges them and reads a
single key, so you never index by position:
from bocoflow_core.node import upstream
case_name = upstream(predecessor_data, "case_name", default="secret")It needs bocoflow-core-sdk >= 0.1.2 in your test environment (see below).
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",
),
}There is an eleventh class, CondaEnvParameter, left over from before Salpa moved to per-node
isolated environments. It still loads, but nothing in the current execution model needs it — don’t
reach for it in a new node.
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, it needs a pixi.toml next to its node.py.
Let the CLI write it. salpa add numpy biopython reads your manifest’s own channels, resolves every name against them, writes the dependencies, and — the part worth having — narrows platforms to the intersection that can actually run them, leaving a comment saying why each one was dropped. It solves once, and if any name fails to resolve it writes nothing at all, so the fix for a typo is to run the same command again.
That last point is the reason to prefer it over typing the file by hand. A platforms list is a claim about where your node works, and the honest way to make that claim is to let the solver decide it. Declaring four platforms because an example did is how a node ends up failing on a machine its author never had.
Here is what salpa add produces, so you can read what it wrote:
[project]
name = "my-analysis-node"
version = "1.0.1"
channels = ["conda-forge"]
# Written by `salpa add` — the platforms that can actually run these dependencies,
# not a wish list. It narrows this line; it never widens it for you.
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"
# --- Local testing (a SEPARATE env — never in [dependencies]) -------------------
# `pixi run test` runs your node.py tests against bocoflow-core-sdk (the node-authoring
# API, on PyPI). Keep it in this test env, NOT the default one: the Salpa app provides the
# full bocoflow_core at runtime, and a subset in the default env would shadow it.
[feature.test.dependencies]
pytest = ">=7"
[feature.test.pypi-dependencies]
bocoflow-core-sdk = ">=0.1.2" # 0.1.2 adds upstream(); 0.1.1 made stream_log() fail fast
[feature.test.tasks]
test = "python -m pytest tests/ -v"
[environments]
test = ["test"]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 (salpa add handles the first for you):
- 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. - Do NOT put
bocoflow_corein[dependencies]. The Salpa app provides the full runtime when it executes your node — a copy in the default env would shadow it. For local testing,bocoflow-core-sdk(the node-authoring API) goes in a separatetestenv (the[feature.test]block above), sopixi run testcan importnode.pyand run its tests.
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 a real-world example of this split, install the pdbmdauto package from the marketplace and inspect its node directories — its GROMACS and PDB2PQR steps are both built this way.
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, there are four ways to bring it in: one from your terminal, and three from the Marketplace page in the app.
Push it from the terminal
If you have the CLI, this is the shortest path. With Salpa running:
salpa push my-nodeYour package appears in Marketplace → Browse, ready to install. Edit your code and run the same command again to refresh what the app offers — your directory is the source, so nothing is copied and there is no second copy to keep in step. salpa unpush my-node takes it back out, leaving your own files exactly as they are.
Two things worth being precise about:
- This is local, not publishing.
pushtalks to the Salpa app on your own machine. Nothing is uploaded anywhere and no public registry is involved. - It stops at the shelf. The package becomes available to install; the install itself stays a click you make in the app, running the same installer every other package goes through. There is no second installer hiding in the CLI.
push runs the validate checks first and refuses a package that would not install, so most mistakes surface before the app ever sees them. If your package isn’t already laid out as a source (registry.json + packages/<pkg>/), it offers to arrange it that way and shows you exactly what it will move first.
It does not run your node, though — that is salpa smoke, and it is worth doing before you push. A package can satisfy every structural check and still fail the moment it executes.
salpa push --copy copies the package into the app instead of linking your directory — self-contained, and it survives you moving your working copy, at the cost of another --copy push per edit.
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-salpa-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 flows above cover everything — local development (salpa push or a folder import), 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 lives in the public Salpa Hub — the same repository the app installs packages from, so what you read here is exactly what you get.
| 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 — open Marketplace → Installed in the app and use the Docs button on the Hello World Pipeline card to read them there.
What’s next
- Node System — the architecture this page builds on
- Getting Started — install Salpa and run your first workflow
salpa docs— the authoring guides bundled with the CLI, readable offline