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.

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), then salpa new my-node generates a ready-to-edit package (core.py + node.py + tests/ + pixi.toml). salpa add numpy biopython names 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 install builds the isolated environment; pixi run test runs your node.py tests in it; salpa validate checks the package will install and register; salpa smoke actually runs the node on its own demo_data/; and salpa push hands 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 dev shows 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.

validate and smoke answer different questions. validate is static — it reads your package and tells you whether it satisfies the install and registration contract, without running anything. smoke executes it: execute() on a real input must succeed with a non-trivial payload, and execute() 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:

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)

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. The name is the package’s distributable identity. license is 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_name must match the Python class in node.py. display_name is what users see on the canvas. num_in / num_out set the connection ports. hashtags drive 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 optionally platform_note. Both [node.metadata].category and [node].category are read, but metadata wins — put it there. platform_note replaces 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 in package.toml under [package.metadata].
  • [node.features]default_timeout only. 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 from pixi.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 be README.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_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 — one entry per upstream node. Each entry is that node’s result.data already unwrapped for you — not the {"success": …, "data": …} envelope it returned.
  • 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()

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:

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", ), }

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:

  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 (salpa add handles the first for you):

  • 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.
  • Do NOT put bocoflow_core in [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 separate test env (the [feature.test] block above), so pixi run test can import node.py and 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-node

Your 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. push talks 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 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 lives in the public Salpa Hub  — the same repository the app installs packages from, so what you read here is exactly what you get.

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 — 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