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.
{ schema_version: 1, manifest: { name: "hello_world", tag: "0.1.0", language: "python", }, process: { add_cmd: [ "uv", "sync" ], start_cmd: [ "uv", "run", "hello_world" ] }, interfaces: {}}{ schema_version: 1, manifest: { name: "hello_world", tag: "0.1.0", language: "rust", }, process: { add_cmd: [ "cargo", "build", "--release" ], start_cmd: [ "cargo", "run", "--release" ] }, interfaces: {}}Let’s go through the different options:
schema_version: Always set to version 1 for the moment, it allows the daemon to identify the structure of the configuration filemanifest: Contains information about the node. Each node is identified by itsnameandtagprocess: Contains the execution commands for the nodeadd_cmd: Command run when the node is added to the Node stack (more on this later). This is typically the place where you want to run heavy operations like code compilation.start_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.
{ schema_version: 1, manifest: { name: "hello_world", tag: "0.1.0", language: "python", }, process: { add_cmd: [ "uv", "sync" ], start_cmd: [ "uv", "run", "hello_world" ] }, interfaces: { exposes: { topics: [ { name: "message_stream", qos_profile: "sensor_data", message_format: { message: "string" }, } ], } }}{ schema_version: 1, manifest: { name: "hello_world", tag: "0.1.0", language: "rust", }, process: { add_cmd: [ "cargo", "build", "--release" ], start_cmd: [ "./target/release/hello_world" ] }, interfaces: { exposes: { topics: [ { name: "message_stream", qos_profile: "sensor_data", message_format: { message: "string" }, } ], } }}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. We can now modify the code and access those interfaces:
import asynciofrom peppygen import NodeBuilder, NodeRunnerfrom peppygen.parameters import Parametersfrom peppygen.exposed_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::exposed_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 version isolation. If the node’s interfaces change in a future update, dependent nodes won’t break—they continue using the previous version until explicitly migrated.