Creating Your First Node
Now that you have PeppyOS installed, let’s create your first node. A node is the fundamental unit of computation in PeppyOS - 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 that communicate with the other nodes is 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 asynciofrom peppygen import NodeBuilder, NodeRunnerfrom peppygen.parameters import Parametersfrom peppygen.emitted_topics import message_stream
async def emit_hello_world_loop(node_runner: NodeRunner): counter = 0 while True: counter += 1 message = f"hello world count {counter}" print(message, flush=True) await message_stream.emit(node_runner, message) 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::{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) { 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}"); if let Err(e) = peppygen::emitted_topics::message_stream::emit(&runner, message).await { eprintln!("Failed to emit hello world: {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; PeppyOS 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, and the daemon tears every node down when it shuts down cleanly. In all of these cases the daemon first asks the node to shut down cooperatively, gives it a grace period to exit (shutdown_grace_secs, 3 seconds by default), and only then force-kills its process group. See Daemon shutdown and orphan prevention for the full sequence.
Inside your node, that cooperative ask surfaces through the runtime’s cancellation token. The same token also fires when the node’s watchdog detects that the daemon died uncleanly, and on Ctrl+C in standalone mode, so it is the single signal to watch for “time to clean up and exit”:
The background tasks returned from setup are cancelled automatically: their pending await raises asyncio.CancelledError, and the node exits once they finish. Wrap your loop in try/finally to park actuators, release hardware, or flush state on the way out:
async def emit_hello_world_loop(node_runner: NodeRunner): try: while True: # ... emit, read sensors, etc. await asyncio.sleep(3) finally: pass # park actuators, release hardware, flush stateAsync code can also wait for the shutdown explicitly with await node_runner.cancellation_token().cancelled(), mirroring the Rust pattern. Code that cannot rely on task cancellation (a synchronous helper, a thread you spawned yourself) can poll node_runner.cancellation_token().is_cancelled() instead.
The token returned by node_runner.cancellation_token() is cancelled. The hello_world node above already follows the recommended pattern: clone the token in the setup closure and have every spawned task select! on token.cancelled() next to its work, then run its cleanup when that branch wins:
tokio::select! { _ = token.cancelled() => break, // shutdown requested: clean up and exit _ = interval.tick() => { /* do work */ }}A node that exits within the grace period is reported as a clean stop; one that ignores the ask is force-killed and peppy node stop warns about it. If your node legitimately needs more than 3 seconds to wind down, raise lifecycle.shutdown_grace_secs in the daemon configuration.