Skip to content

Standalone nodes

Constantly adding and running nodes through the node stack is highly inefficient during development. In that scenario, you want to be able to run your node as a regular Rust/Python program and use your favorite IDE to debug it.

While debugging the nodes based on the logs can be quite helpful, nothing beats the ability to fire up the debugger to inspect the code that is supposed to run inside the Peppy node stack. To support this, peppy can run a node in “standalone mode”: it communicates with other nodes in the stack but runs as a regular program outside of it, allowing you to use standard debugging tools. Let’s create a new node:

  1. Initialize the node:

    Terminal window
    peppy node init --toolchain uv standalone
  2. Navigate into the directory:

    Terminal window
    cd standalone

Then modify the peppy.json5 configuration to look like this:

peppy.json5
{
peppy_schema: "node/v1",
manifest: {
name: "standalone",
tag: "v1",
},
interfaces: {},
execution: {
language: "python",
// A bunch of fake parameters required to start our node
parameters: {
device_path: "string",
video: {
frame_rate: "u16",
resolution: {
width: "u16",
height: "u16",
},
encoding: "string",
},
},
build_cmd: [
"uv",
"sync"
],
run_cmd: [
"uv",
"run",
"standalone"
]
}
}

Now sync the node:

Terminal window
peppy node sync

(You can also pass --sync/-s to peppy node add if you want to sync and add in one step, e.g. peppy node add . -sb to sync, add, and build together.)

Now if you try to run the node:

Terminal window
uv run standalone

You’ll run into the following error:

RuntimeError: missing required parameter(s) for standalone mode: device_path, video. Provide them via StandaloneConfig().with_parameters()

These parameters are usually provided during peppy node run, but since we want this node to run as a standalone program, we need to pass them outside of the Peppy daemon environment. We can define our parameters in a params.json file at the root of the project:

params.json
{
"device_path": "/dev/video0",
"video": {
"frame_rate": 30,
"resolution": {
"width": 1920,
"height": 1080
},
"encoding": "h264"
}
}

Then modify the source file to read from this file:

src/standalone/__main__.py
import json
from peppygen import NodeBuilder, NodeRunner, StandaloneConfig
from peppygen.parameters import Parameters
async def setup(params: Parameters, node_runner: NodeRunner):
print("Inside the setup callback!")
def main():
# Parameters can also be defined directly in code:
#
# from peppygen.parameters import Video, VideoResolution
#
# params = Parameters(
# device_path="/dev/video0",
# video=Video(
# frame_rate=30,
# resolution=VideoResolution(
# width=1920,
# height=1080,
# ),
# encoding="h264",
# ),
# )
with open("params.json") as f:
params = json.load(f)
standalone_config = StandaloneConfig().with_parameters(params)
NodeBuilder().standalone(standalone_config).run(setup)
if __name__ == "__main__":
main()

Now if we run the following command again:

Terminal window
uv run standalone

The node should run without a crash. The standalone object allows us to load parameters from an external JSON file and pass them to the node, which in turn allows us to run our node as a regular Rust/Python program. Note that the standalone config is completely ignored when a node is run with peppy node run; all parameters provided during the node run operation take precedence.

Parameters are not the only thing a launch resolves. A node that declares pairings, producer-binding slots, or observer slots reads those from the boot config the daemon hands it, and standalone mode has no daemon to write one. The standalone config carries one builder per slot kind so the node still starts:

BuilderStands in for
with_peer_pinthe daemon’s --pair delivery for a depends_on.pairings slot
with_bound_producerone --link occurrence, or one launcher links: entry, for a depends_on.nodes / depends_on.contracts slot
with_vacant_producer_slota launcher writing a producer-binding slot { vacant: "<why>" }
with_observed_sourceone member of the set the daemon stamps into an observer slot at spawn

Every builder takes the local slot’s link_id first. The three that name a remote instance follow it with that instance’s (core_node, instance_id) address, and the two of those that also name a slot on the other side (a pairing’s peer and an observed pairing’s source) take that slot’s link_id last. with_vacant_producer_slot names only the local slot, because there is no instance to point at:

standalone_config = (
StandaloneConfig()
.with_parameters(params)
.with_peer_pin("arm", "core_x", "arm_1", "controller")
.with_bound_producer("camera", "core_x", "front_camera")
.with_vacant_producer_slot("greeter")
.with_observed_source("watch", "core_x", "arm_1", "controller")
)

Repeat calls for the same link_id accumulate in call order, exactly as repeated --link flags do, so the Nth call is the Nth member the node reads back.

Each slot’s seeded set has to satisfy the slot’s declared cardinality at startup, which is the rule launch validation applies at plan time: exactly one for one, at most one for zero_or_one, at least one for one_or_more, any number for zero_or_more. So a slot whose cardinality has a floor of one must be seeded, and the node refuses to start otherwise rather than running a slot its own manifest says cannot be empty. The two zero-floor cardinalities may be left out entirely: an unseeded observer slot observes nothing, and an unseeded zero_or_more producer-binding slot binds nothing. A zero_or_one producer-binding slot is the one case that needs a word either way, because omitting it is how you forget it: with_vacant_producer_slot is the standalone spelling of a deployment writing the slot vacant.

Naming a link_id the manifest does not declare is a typo rather than a shape error, so it warns and is ignored instead of failing the run.