Creating your first node
Now that you have Peppy installed, let’s create your first node. A node is the fundamental unit of computation in Peppy - it represents a runnable application or service. In this guide, we’ll explore how nodes work and how they communicate with each other.
You can think of nodes as compute units that are each responsible for a single job. For example, a node can:
- Emit frames from a USB camera
- Move a single actuator
- Act as the brain of a robot by receiving input from sensors (audio, video, etc.) and emitting actions
The key point is that a node is responsible for performing a single function.
Project structure
Section titled “Project structure”Run:
-
Initialize the node:
Terminal window peppy node init --toolchain uv hello_world -
Navigate into the directory:
Terminal window cd hello_world
-
Initialize the node:
Terminal window peppy node init --toolchain cargo hello_world -
Navigate into the directory:
Terminal window cd hello_world
In the node folder, you’ll find the following:
- A
peppy.json5file - A
.peppyfolder local to your node - Your project scaffolding based on the selected toolchain
Exploring the peppy.json5 file
Section titled “Exploring the peppy.json5 file”This is the entry point and the single source of truth for every node. All node interfaces and communication are defined in the peppy.json5 configuration file, regardless of the programming language used.
A developer who needs to understand or share a node can simply look at this configuration file and immediately know how it works without digging into the code.
Everything in a peppy.json5 is meant to be shared and distributed, so nothing local to your system should be added to this file.
The .peppy folder
Section titled “The .peppy folder”This is a cache folder that contains everything automatically generated by the peppy daemon. Nothing here should be modified manually, and the folder should be added to .gitignore.
Writing your first node
Section titled “Writing your first node”Let’s open the peppy.json5 file of the node we just created and explore it together.
{ peppy_schema: "node/v1", manifest: { name: "hello_world", tag: "v1", }, interfaces: {}, execution: { language: "python", build_cmd: [ "uv", "sync" ], run_cmd: [ "uv", "run", "hello_world" ] }}{ peppy_schema: "node/v1", manifest: { name: "hello_world", tag: "v1", }, interfaces: {}, execution: { language: "rust", build_cmd: [ "cargo", "build", "--release" ], run_cmd: [ "./target/release/hello_world" ] }}Let’s go through the different options:
peppy_schema: Identifies the structure of the configuration file. For node configs always set this to"node/v1"manifest: Contains information about the node. Each node is identified by itsnameandtagexecution: Contains the language and execution commands for the nodelanguage: The programming language used by the node (e.g."python","rust")build_cmd: Command run during the build phase of the node (e.g. viapeppy node build,peppy node add --build, or the combined shorthandspeppy node add -sb/-sr). This is typically the place where you want to run heavy operations like code compilation.run_cmd: Command run when a node instance is started
interfaces: This is where the interfaces used to communicate with the other nodes are defined
Let’s modify this configuration file to expose a topic that sends a “hello world” message.
{ peppy_schema: "node/v1", manifest: { name: "hello_world", tag: "v1", }, interfaces: { topics: { emits: [ { name: "message_stream", qos_profile: "sensor_data", message_format: { message: "string" }, } ], } }, execution: { language: "python", build_cmd: [ "uv", "sync" ], run_cmd: [ "uv", "run", "hello_world" ] },}{ peppy_schema: "node/v1", manifest: { name: "hello_world", tag: "v1", }, interfaces: { topics: { emits: [ { name: "message_stream", qos_profile: "sensor_data", message_format: { message: "string" }, } ], } }, execution: { language: "rust", build_cmd: [ "cargo", "build", "--release" ], run_cmd: [ "./target/release/hello_world" ] },}To apply these changes, the node needs to synchronize with the cache in the .peppy folder. Run:
peppy node syncThis command will generate the interfaces to use in our source code. If you’d rather sync, add, and build in a single step (useful once you’re iterating quickly), use peppy node add -sb, or -sr to also start an instance after the build. We can now modify the code and access those interfaces:
import asyncioimport sys
from peppygen import NodeBuilder, NodeRunnerfrom peppygen.parameters import Parametersfrom peppygen.emitted_topics import message_stream
async def emit_hello_world_loop(node_runner: NodeRunner): # Declare the publisher once, then publish each message on it. try: publisher = await message_stream.declare_publisher(node_runner) except Exception as e: print(f"Failed to declare message_stream publisher: {e}", file=sys.stderr) return
counter = 0 while True: counter += 1 message = f"hello world count {counter}" print(message, flush=True) try: await publisher.publish(message_stream.build_message(message)) except Exception as e: print(f"Failed to publish hello world: {e}", file=sys.stderr) await asyncio.sleep(3)
async def setup(params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(emit_hello_world_loop(node_runner))]
def main(): NodeBuilder().run(setup)
if __name__ == "__main__": main()use std::sync::Arc;use std::time::Duration;
use peppygen::emitted_topics::message_stream;use peppygen::{NodeBuilder, NodeRunner, Parameters, Result};use peppylib::runtime::CancellationToken;
/// Emits a "hello world count X" message every 3 seconds, starting immediately./// The loop runs until the cancellation token is triggered.async fn emit_hello_world_loop(runner: Arc<NodeRunner>, token: CancellationToken) { // Declare the publisher once; every publish below is then lock-free. let publisher = match message_stream::declare_publisher(&runner).await { Ok(publisher) => publisher, Err(e) => { eprintln!("Failed to declare message_stream publisher: {e}"); return; } }; let mut counter: u64 = 0; let mut interval = tokio::time::interval(Duration::from_secs(3)); loop { tokio::select! { _ = token.cancelled() => break, _ = interval.tick() => { counter += 1; let message = format!("hello world count {counter}"); println!("{message}"); match message_stream::build_message(message) { Ok(payload) => { if let Err(e) = publisher.publish(payload).await { eprintln!("Failed to publish hello world: {e}"); } } Err(e) => eprintln!("Failed to build hello world message: {e}"), } } } }}
fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { let runner = node_runner.clone(); let token = node_runner.cancellation_token().clone();
// We use tokio::spawn to avoid blocking the closure tokio::spawn(emit_hello_world_loop(runner, token));
Ok(()) })}As you can see, the topic interface has already been generated. We didn’t need to implement serialization, encoding, or communication layers; Peppy handles all of that automatically.
Additionally, the tag and name assigned to your node provide contract isolation. When the node’s interfaces change in a way that breaks consumers, you publish under a new tag (e.g. v2); dependent nodes that pin the old tag keep working until they are explicitly migrated.
Graceful shutdown
Section titled “Graceful shutdown”A node instance rarely gets to decide when it stops: peppy node stop stops it on demand, peppy node add stops running instances when it replaces a node, the daemon tears every node down when it shuts down cleanly, and a plain SIGINT/SIGTERM works too. Every one of these paths gives your node the same two things:
- The cancellation token (
node_runner.cancellation_token()) fires. This is the signal to stop working: have long-running tasks watch it and break out of their loops. - Shutdown hooks registered with
node_runner.on_shutdown(...)then run to completion, with messaging still connected. This is where cleanup belongs (parking actuators, releasing hardware or locks, flushing state): unlike a task watching the token, a hook is guaranteed to be awaited before the node exits.
The hook callback may be a plain function or an async def:
async def setup(params, node_runner: NodeRunner): async def release_lock(): # runs on every stop path, before the node's tasks are torn down await datastore.remove(node_runner, "my_lock", response_timeout_secs=2.0)
node_runner.on_shutdown(release_lock)The background tasks returned from setup are cancelled automatically after the hooks: their pending await raises asyncio.CancelledError, and the node exits once they finish. Keep try/finally for last-moment tidy-up; cleanup that must be guaranteed belongs in on_shutdown.
The hello_world node above already follows the recommended pattern for stopping work: every spawned task select!s on token.cancelled() next to its work. Cleanup goes in a hook:
let runner = node_runner.clone();node_runner.on_shutdown(async move { // runs on every stop path, after the token fires, before run() returns if let Err(e) = datastore::remove(&runner, "my_lock", TIMEOUT).await { tracing::warn!("failed to release lock: {e}"); }});Do not put cleanup in a spawned task that watches the token instead: once the token fires the runtime begins tearing down, and a detached task is not guaranteed to run again.
The whole shutdown is bounded by a grace period (shutdown_grace_secs, 5 seconds by default) for your cleanup to run: a node that shuts down cooperatively is reported as a clean stop, one that ignores the ask is force-killed, and peppy node stop warns about the latter. The shutdown lifecycle guide covers the full contract: every stop path, hook ordering, the grace windows, and Python’s task-teardown mechanics.