🚧Documentation is under active development — content may be incomplete or change frequently.
Skip to Content
Build Custom Nodes

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:

FileOpen
meta.toml — the manifestview
node.py — the codeview
README.md — package docsview

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. The name is the package’s distributable identity.
  • [node]class_name must match the Python class in node.py. display_name is what users see on the canvas. hashtags drive search and grouping in the Node Library — five to ten descriptive tags is ideal.
  • [node.metadata] — optional UI hints like tooltip and icon (an emoji or path to an SVG).
  • [node.features] — capability flags. Most are advisory; requires_gpu is the only one that actually changes scheduling today.
  • [dependencies] — a coarse Python version pin. Real dependencies go in pixi.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_log

Three imports cover almost every node:

  • Node — the base class for non-HPC nodes. (HPC nodes inherit from HPCNodeBase instead.)
  • NodeResult and NodeException — 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 your OPTIONS names. Each value is a Parameter object — 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:

PrefixMeaning
abs:/home/user/data.pdbAbsolute path
rel:data/input.pdbRelative to the workflow’s working directory
node:demo_data/example.pdbRelative 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:

FieldTypePurpose
successboolDid the node finish successfully?
messagestrHuman-readable status line shown in the UI
datadictThe payload that downstream nodes see in their predecessor_data
metadatadictCommon metadata (case_name, execution_time)
filesdict{"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.

ClassTypeWhen to use
StringParameterSingle-line textNames, labels, short identifiers
TextParameterMulti-line textSequences, scripts, long prose
PasswordParameterMasked textSecrets (rendered as ••••)
IntegerParameterWhole numberCounts, indices, integer settings
FloatParameterDecimal numberThresholds, temperatures, ratios
BooleanParameterCheckboxOn/off toggles
SelectParameterDropdownFixed set of choices
FileParameterFile pickerRead-only file path with existence check
FileParameterEditFile picker + manual inputFiles that may not exist yet (output)
FolderParameterFolder pickerOutput 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:

  1. Creates an isolated Python environment for your node
  2. Installs every dependency listed
  3. 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.0 if you want stream_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-core to [pypi-dependencies] because your node.py imports 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 Parameter instances in OPTIONS, not dict specs. The type system gives you validation for free.
  • Always pass node_id=self.node_id to stream_log so 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_path before opening files. UI-supplied paths almost always have an abs: / rel: / node: prefix.
  • Keep result.data small and JSON-serializable. It’s the wire format between nodes; large blobs belong on disk with a path in result.files.
  • Catch exceptions in execute() and re-raise as NodeException with 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-py dependency to your pixi.toml if 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.

FilePurpose
package.tomlMulti-node bundle manifest
README.mdPipeline reference
workflows/hello-world-pipeline.jsonPre-wired workflow template
hello_encrypt/meta.tomlEncrypt — manifest
hello_encrypt/node.pyEncrypt — code
hello_encrypt/README.mdEncrypt — docs
hello_decrypt/meta.tomlDecrypt — manifest
hello_decrypt/node.pyDecrypt — code
hello_decrypt/README.mdDecrypt — docs
hello_reveal/meta.tomlReveal — manifest
hello_reveal/node.pyReveal — code
hello_reveal/README.mdReveal — 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