This is the full developer documentation for PeppyOS
# PeppyOS Guide
> A guide to start using PeppyOS
This guide covers the installation of PeppyOS and introduces you to creating nodes so you can understand how the system operates.
PeppyOS runs on Linux (x86\_64/aarch64, tested on Ubuntu 24.04, Fedora, and Arch Linux) and macOS (aarch64).
LLM-friendly versions of this documentation are available at [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt).
# Actions
> How to use actions in peppy
Actions are for **long-running tasks** that need feedback during execution and support cancellation. A client sends a goal to an action node, which can provide periodic feedback while working and delivers a final result upon completion.
Use actions for tasks like navigation, arm movement, or any operation that runs over time and benefits from progress updates. Actions are processed **serially** by a node: two requests to the same action cannot run in parallel.
## Action lifecycle
[Section titled “Action lifecycle”](#action-lifecycle)
An action consists of three communication channels built on top of services and topics:
1. **Goal** (service) — the client sends a goal request; the server accepts or rejects it.
2. **Feedback** (topic) — the server publishes progress updates while working on the goal.
3. **Result** (service) — the client requests the final result once the server finishes.
Additionally, the client can issue a **cancel** request at any time to abort an active goal.
```plaintext
Client Server
│ │
│──── fire_goal (request) ──────────>│
│<─── GoalResponse (accepted) ───────│
│ │
│<─── feedback ──────────────────────│ (repeated)
│<─── feedback ──────────────────────│
│ │
│──── get_result (request) ─────────>│
│<─── ResultResponse ────────────────│
```
## Exposing an action
[Section titled “Exposing an action”](#exposing-an-action)
A node that handles action goals declares its actions under `interfaces.actions.exposes` in its `peppy.json5`. Each action defines a `goal_service`, a `feedback_topic`, and a `result_service`:
```json5
{
schema_version: 1,
manifest: {
name: "brain",
tag: "0.1.0",
},
interfaces: {
actions: {
exposes: [
{
name: "move_arm",
goal_service: {
request_message_format: {
arm_id: "u16",
desired_position: {
$type: "array",
$items: "i32",
$length: 3
}
},
response_message_format: {
accepted: "bool"
}
},
feedback_topic: {
qos_profile: "sensor_data",
message_format: {
new_position: {
$type: "array",
$items: "i32",
$length: 3
}
}
},
result_service: {
response_message_format: {
success: "bool",
error_msg: {
$type: "string",
$optional: true
},
final_position: {
$type: "array",
$items: "i32",
$length: 3
}
}
}
}
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/brain"]
},
}
```
The `goal_service.request_message_format` is optional — an action can have no goal parameters (e.g. a `calibrate` action that just starts when fired).
### Handling goals
[Section titled “Handling goals”](#handling-goals)
After running `peppy node sync`, the code generator creates a module for each exposed action under `peppygen::exposed_actions`. Use `ActionHandle::expose` to set up the action, then handle goals, emit feedback, and respond with results. Each goal is handled in a separate task, but only one action can be processed at a time:
```rust
use peppygen::exposed_actions::move_arm;
use peppygen::{NodeBuilder, Parameters, Result};
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
let mut action = move_arm::ActionHandle::expose(&node_runner).await?;
tokio::spawn(async move {
// Wait for a goal request
action.handle_goal_next_request(|request| -> Result {
println!(
"goal received: arm_id={} desired={:?}",
request.data.arm_id, request.data.desired_position
);
Ok(move_arm::GoalResponse::new(true))
})
.await?;
// Emit feedback while working
action.emit_feedback([7, 31, 43]).await?;
// Handle the result request
action.handle_result_next_request(|_request| -> Result {
Ok(move_arm::ResultResponse::new(
true,
None,
[98, 4, 26],
))
})
.await?;
Ok::<_, peppygen::Error>(())
});
Ok(())
})
}
```
The `GoalRequest` contains:
* `instance_id` — the instance that sent the goal.
* `core_node` — the core node of the caller.
* `data` — the deserialized goal parameters (only present when `request_message_format` is defined).
### Handling cancellation
[Section titled “Handling cancellation”](#handling-cancellation)
Between accepting a goal and delivering a result, the server can handle cancel requests inside the spawned task:
```rust
action.handle_cancel_next_request(|request| -> Result {
println!("cancel request from {}", request.instance_id);
Ok(move_arm::CancelResponse::new(
true, // accepted
Some("goal cancelled".to_owned()), // error_message
))
})
.await?;
```
The `CancelResponse` contains:
* `accepted` — whether the cancellation was accepted.
* `error_message` — an optional message explaining the cancellation outcome.
After accepting a cancellation, the server should stop processing and not respond to the result request for that goal.
### Serving goals continuously
[Section titled “Serving goals continuously”](#serving-goals-continuously)
To serve multiple goals in sequence, alternate between waiting for goals and handling follow-up requests:
```rust
let mut action = move_arm::ActionHandle::expose(&node_runner).await?;
loop {
// Wait for a new goal
action.handle_goal_next_request(|request| -> Result {
Ok(move_arm::GoalResponse::new(true))
})
.await?;
// Do work, emit feedback...
action.emit_feedback([0, 0, 0]).await?;
// Handle either cancel or result
action.handle_result_next_request(|_request| -> Result {
Ok(move_arm::ResultResponse::new(true, None, [10, 20, 30]))
})
.await?;
}
```
## Consuming an action
[Section titled “Consuming an action”](#consuming-an-action)
A node that sends goals declares what it consumes under `interfaces.actions.consumes`. Dependencies are declared in `manifest.depends_on` and referenced by `local_node_id` in the interface:
```json5
{
schema_version: 1,
manifest: {
name: "controller",
tag: "0.1.0",
depends_on: {
nodes: [
{ name: "brain", tag: "0.1.0", local_id: "brain" },
]
},
},
interfaces: {
actions: {
consumes: [
{
local_node_id: "brain", // References depends_on.nodes[].local_id
name: "move_arm", // Action name on that node
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/controller"]
},
}
```
Note
The `node add` and `node sync` commands require the target node to already be in the node stack so the proper interfaces can be generated.
### Firing a goal
[Section titled “Firing a goal”](#firing-a-goal)
The code generator creates a module for each consumed action under `peppygen::consumed_actions`. Use `fire_goal` to send a goal, then listen for feedback and request the result:
```rust
use peppygen::consumed_actions::brain_move_arm;
use peppygen::{NodeBuilder, Parameters, QoSProfile, Result};
use std::time::Duration;
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
let request = brain_move_arm::GoalRequest {
arm_id: 7,
desired_position: [10, 20, 30],
};
let mut goal = brain_move_arm::fire_goal(
&node_runner,
Duration::from_secs(5), // timeout
None, // target_core_node (None = any)
None, // target_instance_id (None = any)
request,
QoSProfile::SensorData, // QoS for the feedback topic
)
.await?;
println!("goal accepted={}", goal.data.accepted);
Ok(())
})
}
```
The `fire_goal` function returns a `GoalResponse` containing:
* `data.accepted` — whether the goal was accepted.
* `action_handle` — a handle used for subsequent feedback, result, and cancel calls.
### Receiving feedback
[Section titled “Receiving feedback”](#receiving-feedback)
Use `on_next_feedback_message` to receive the next feedback message from the server:
```rust
let feedback = brain_move_arm::on_next_feedback_message(
&mut goal.action_handle,
).await?;
println!("new_position={:?}", feedback.new_position);
```
This blocks until a feedback message arrives. The server can emit multiple feedback messages, so you can call this in a loop if needed.
### Getting the result
[Section titled “Getting the result”](#getting-the-result)
Use `get_result` to request the final result from the server:
```rust
let result = brain_move_arm::get_result(
&node_runner,
&goal.action_handle,
Duration::from_secs(5), // timeout
)
.await?;
println!(
"success={} error={:?} final_position={:?}",
result.data.success,
result.data.error_msg.as_deref(),
result.data.final_position
);
```
### Cancelling a goal
[Section titled “Cancelling a goal”](#cancelling-a-goal)
Use `cancel_goal` to request cancellation of an active goal:
```rust
let cancel_response = brain_move_arm::cancel_goal(
&node_runner,
&goal.action_handle,
Duration::from_secs(5), // timeout
)
.await?;
println!(
"cancel accepted={} error={}",
cancel_response.data.accepted,
cancel_response.data.error_message.as_deref().unwrap_or(""),
);
```
After a goal is cancelled, requesting the result will fail with a timeout since the server stops processing that goal.
## Serial processing
[Section titled “Serial processing”](#serial-processing)
Actions are designed for tasks where only one goal should be active at a time. The server processes goals one after another: it waits for a goal, handles it (with optional feedback and cancel), delivers the result, and then waits for the next goal.
If you need parallel execution of similar tasks, consider using multiple instances of the same node or restructuring the work as independent services.
# Bidirectional communication
> How to use external consumed topics for bidirectional data flow between nodes
In a standard PeppyOS setup, a node subscribes to topics from its declared dependencies using `local_node_id`. This creates a directed graph: if `arm_controller` depends on `robot_arm`, it can expect topics from `robot_arm` — but not the other way around, since that would create a circular dependency.
**External consumed topics** solve this by letting a node subscribe to a topic *without* declaring a dependency on the publisher. The subscriber defines the `message_format` inline and receives messages from any node that emits a matching topic.
## Example: robot arm control loop
[Section titled “Example: robot arm control loop”](#example-robot-arm-control-loop)
Consider a robot arm that needs bidirectional communication between two nodes:
* **`arm_controller`** — plans trajectories and sends joint commands.
* **`robot_arm`** — drives the physical joints and reports their state.
```plaintext
arm_controller robot_arm
│ │
│─── emits: joint_commands ───────────────────>│ consumes: joint_commands (external)
│ │
│ consumes: joint_states (linked) <───────────│ emits: joint_states
│ │
```
`arm_controller` depends on `robot_arm` to consume its `joint_states` topic — that’s a standard linked consumed topic. `robot_arm` needs to receive `joint_commands`, but it cannot depend on `arm_controller` (circular dependency). Instead, it declares `joint_commands` as an **external consumed topic**.
## Configuring external consumed topics
[Section titled “Configuring external consumed topics”](#configuring-external-consumed-topics)
An external consumed topic has a `name` and an inline `message_format`, but no `local_node_id`:
* Python
robot\_arm/peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "robot_arm",
tag: "0.1.0",
// No depends_on — robot_arm does not depend on arm_controller
},
interfaces: {
topics: {
emits: [
{
name: "joint_states",
qos_profile: "sensor_data",
message_format: {
positions: {
$type: "array",
$items: "f64",
$length: 3
},
velocities: {
$type: "array",
$items: "f64",
$length: 3
},
timestamp: "time"
}
}
],
consumes: [
{
// No local_node_id — this is an external consumed topic
name: "joint_commands",
message_format: {
target_positions: {
$type: "array",
$items: "f64",
$length: 3
},
max_velocity: "f64"
}
}
],
},
},
execution: {
language: "python",
build_cmd: ["uv", "sync"],
run_cmd: ["uv", "run", "robot_arm"]
},
}
```
* Rust
robot\_arm/peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "robot_arm",
tag: "0.1.0",
// No depends_on — robot_arm does not depend on arm_controller
},
interfaces: {
topics: {
emits: [
{
name: "joint_states",
qos_profile: "sensor_data",
message_format: {
positions: {
$type: "array",
$items: "f64",
$length: 3
},
velocities: {
$type: "array",
$items: "f64",
$length: 3
},
timestamp: "time"
}
}
],
consumes: [
{
// No local_node_id — this is an external consumed topic
name: "joint_commands",
message_format: {
target_positions: {
$type: "array",
$items: "f64",
$length: 3
},
max_velocity: "f64"
}
}
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/robot_arm"]
},
}
```
Compare this with the `arm_controller` node, which uses a standard linked consumed topic:
* Python
arm\_controller/peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "arm_controller",
tag: "0.1.0",
depends_on: {
nodes: [
{ name: "robot_arm", tag: "0.1.0", local_id: "robot_arm" },
]
},
},
interfaces: {
topics: {
emits: [
{
name: "joint_commands",
qos_profile: "reliable",
message_format: {
target_positions: {
$type: "array",
$items: "f64",
$length: 3
},
max_velocity: "f64"
}
}
],
consumes: [
{
local_node_id: "robot_arm", // Linked — references depends_on
name: "joint_states",
},
],
},
},
execution: {
language: "python",
build_cmd: ["uv", "sync"],
run_cmd: ["uv", "run", "arm_controller"]
},
}
```
* Rust
arm\_controller/peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "arm_controller",
tag: "0.1.0",
depends_on: {
nodes: [
{ name: "robot_arm", tag: "0.1.0", local_id: "robot_arm" },
]
},
},
interfaces: {
topics: {
emits: [
{
name: "joint_commands",
qos_profile: "reliable",
message_format: {
target_positions: {
$type: "array",
$items: "f64",
$length: 3
},
max_velocity: "f64"
}
}
],
consumes: [
{
local_node_id: "robot_arm", // Linked — references depends_on
name: "joint_states",
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/arm_controller"]
},
}
```
Note
The `message_format` of an external consumed topic is defined inline by the subscriber. For linked consumed topics, the format is resolved automatically from the dependency’s configuration — which is why only `local_node_id` and `name` are needed.
## Receiving external messages
[Section titled “Receiving external messages”](#receiving-external-messages)
After running `peppy node sync`, the code generator creates a module for each external consumed topic under `peppygen.consumed_topics` (Python) / `peppygen::consumed_topics` (Rust). The module name is the **topic name only** (e.g. `joint_commands`), since there is no source node to prefix it with.
* Python
src/robot\_arm/\_\_main\_\_.py
```python
import asyncio
import time
from peppygen import NodeBuilder, NodeRunner
from peppygen.parameters import Parameters
from peppygen.consumed_topics import joint_commands
from peppygen.emitted_topics import joint_states
async def handle_commands(node_runner: NodeRunner):
while True:
instance_id, command = await joint_commands.on_next_message_received(
node_runner,
)
print(
f"received from {instance_id}: "
f"target={command.target_positions} max_vel={command.max_velocity}"
)
# Drive the joints, then report state
await joint_states.emit(
node_runner,
command.target_positions,
[0.0, 0.0, 0.0],
time.time(),
)
async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]:
return [asyncio.create_task(handle_commands(node_runner))]
def main():
NodeBuilder().run(setup)
if __name__ == "__main__":
main()
```
* Rust
src/main.rs
```rust
use peppygen::consumed_topics::joint_commands;
use peppygen::emitted_topics::joint_states;
use peppygen::{NodeBuilder, Parameters, Result};
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
let node_runner_clone = node_runner.clone();
tokio::spawn(async move {
loop {
let (instance_id, command) = joint_commands::on_next_message_received(
&node_runner_clone,
None,
None,
)
.await
.expect("failed to receive joint command");
println!(
"received from {}: target={:?} max_vel={}",
instance_id, command.target_positions, command.max_velocity
);
// Drive the joints, then report state
let _ = joint_states::emit(
&node_runner_clone,
command.target_positions,
[0.0, 0.0, 0.0],
std::time::SystemTime::now(),
)
.await;
}
});
Ok(())
})
}
```
The API is identical to linked consumed topics — the only difference is where the `message_format` comes from (inline vs. resolved from the dependency).
On the other side, the `arm_controller` uses a standard linked topic. Notice the module name includes the node prefix (`robot_arm_joint_states`):
* Python
src/arm\_controller/\_\_main\_\_.py
```python
import asyncio
from peppygen import NodeBuilder, NodeRunner
from peppygen.parameters import Parameters
from peppygen.consumed_topics import robot_arm_joint_states
from peppygen.emitted_topics import joint_commands
def compute_next_target(current: list[float]) -> list[float]:
# Trajectory planning logic
return [current[0] + 0.1, current[1], current[2]]
async def control_loop(node_runner: NodeRunner):
while True:
# Linked topic: module name is _
instance_id, state = await robot_arm_joint_states.on_next_message_received(
node_runner,
)
# Compute next target based on current state
target = compute_next_target(state.positions)
await joint_commands.emit(
node_runner,
target,
1.0, # max_velocity
)
async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]:
return [asyncio.create_task(control_loop(node_runner))]
def main():
NodeBuilder().run(setup)
if __name__ == "__main__":
main()
```
* Rust
src/main.rs
```rust
use peppygen::consumed_topics::robot_arm_joint_states;
use peppygen::emitted_topics::joint_commands;
use peppygen::{NodeBuilder, Parameters, Result};
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
let node_runner_clone = node_runner.clone();
tokio::spawn(async move {
loop {
// Linked topic: module name is _
let (instance_id, state) = robot_arm_joint_states::on_next_message_received(
&node_runner_clone,
None,
None,
)
.await
.expect("failed to receive joint state");
// Compute next target based on current state
let target = compute_next_target(&state.positions);
let _ = joint_commands::emit(
&node_runner_clone,
target,
1.0, // max_velocity
)
.await;
}
});
Ok(())
})
}
fn compute_next_target(current: &[f64; 3]) -> [f64; 3] {
// Trajectory planning logic
[current[0] + 0.1, current[1], current[2]]
}
```
## Linked vs. external consumed topics
[Section titled “Linked vs. external consumed topics”](#linked-vs-external-consumed-topics)
| | Linked | External |
| -------------- | ------------------------------ | -------------------------------- |
| Config | `local_node_id` + `name` | `name` + `message_format` |
| Dependency | Required in `depends_on` | None |
| Message format | Resolved from the dependency | Defined inline by the subscriber |
| Module name | `_` | `` |
| Subscribes to | A specific publisher node | Any node emitting that topic |
## Why only topics?
[Section titled “Why only topics?”](#why-only-topics)
PeppyOS has three communication patterns — topics, services, and actions — but only topics support external (dependency-free) subscriptions. The reason is how each pattern flows data:
| | Topics | Services | Actions |
| ----------------- | ---------------------------------------------- | ---------------------------------------------- | --------------------------------------------- |
| Pattern | Publish-subscribe | Request-response | Goal-feedback-result |
| Data flow | One-way: publisher → subscriber | Two-way: request then response | Multi-step: goal, feedback, result |
| Initiator | Publisher pushes; subscriber listens passively | Consumer actively calls the exposer | Consumer orchestrates the full lifecycle |
| Coupling | None — subscriber does not call the publisher | Inherent — consumer targets a specific service | Inherent — built on services, same constraint |
| External variant? | Yes — passive listening needs no dependency | No — calling requires a target node | No — same as services |
A topic subscriber is **passive**: it declares a message format and waits for data to arrive, without knowing or addressing the publisher. That is why removing `depends_on` works — there is nothing to call.
[Services](/advanced_guides/services) and [actions](/advanced_guides/actions) are **caller-driven**: the consumer actively invokes a specific service on a specific node. Even without a config dependency, the consumer would still need to target that node at runtime, reintroducing the coupling that external consumed topics are designed to avoid.
In the robot arm example, `robot_arm` passively listens for `joint_commands` from any source — it never calls `arm_controller`. If it needed to call a service on `arm_controller` instead, it would need `depends_on`, creating the circular dependency that external consumed topics exist to prevent.
## When to use external consumed topics
[Section titled “When to use external consumed topics”](#when-to-use-external-consumed-topics)
Use external consumed topics when:
* **Bidirectional communication** — two nodes need to exchange data and a direct dependency would create a cycle.
* **Loose coupling** — the subscriber should not care which specific node publishes the data. For instance, a `robot_arm` node that accepts commands from any controller (teleoperation, autonomous planner, calibration script).
* **Non-DAG communication** — the publisher is not part of the node stack, or the relationship between publisher and subscriber is not a natural dependency.
For all other cases, prefer linked consumed topics — they enforce explicit dependencies and let PeppyOS resolve message formats automatically.
# Containers
> Make your nodes truly portable
Containers package a node and all of its dependencies into a single, self-contained image. A containerized node runs identically regardless of what is installed on the host — no more “works on my machine” issues.
Use containers when you need:
* **Portability** — ship a node to another machine without worrying about system dependencies.
* **Reproducibility** — guarantee the same runtime environment every time.
* **Isolation** — prevent conflicts between nodes that need different versions of the same library.
PeppyOS uses [Apptainer](https://apptainer.org/) as its container runtime. On macOS, Apptainer runs transparently inside a [Lima](https://lima-vm.io/) virtual machine — no extra setup is needed.
## Setup (Linux)
[Section titled “Setup (Linux)”](#setup-linux)
On Linux, Apptainer uses unprivileged user namespaces which may require a one-time system configuration. The installer handles this automatically, but if you skipped it or installed peppy manually you can run:
```sh
peppy container setup
```
This configures the following (prompting for `sudo` when needed):
1. **uidmap package** — installs `newuidmap` (required for fakeroot mode).
2. **AppArmor profile** (Ubuntu 24.04+ only) — installs a profile that allows Apptainer to create user namespaces.
To check the current state without making any changes:
```sh
peppy container status
```
This prints a pass/fail summary of each prerequisite and exits with code `0` (all pass) or `1` (something needs fixing).
Note
On macOS, no setup is needed — containers run inside a Lima VM which handles permissions transparently.
## Initializing a container node
[Section titled “Initializing a container node”](#initializing-a-container-node)
Pass the `--container` flag to `peppy node init`:
* Python
```sh
peppy node init --toolchain uv --container my_node
```
* Rust
```sh
peppy node init --toolchain cargo --container my_node
```
This generates the same project scaffolding as a regular node, plus an `apptainer.def` file that describes how the container image is built.
## The `peppy.json5` configuration
[Section titled “The peppy.json5 configuration”](#the-peppyjson5-configuration)
A container node includes a `container` block inside its `execution` section instead of the usual `build_cmd` and `run_cmd` fields. The two are mutually exclusive — a node is either a container node or a process node, never both.
* Python
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "my_node",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "python",
container: {
def_file: "apptainer.def",
},
}
}
```
* Rust
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "my_node",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "rust",
container: {
def_file: "apptainer.def",
},
}
}
```
The `def_file` field points to the Apptainer definition file relative to the node root. You can rename or relocate it as long as `def_file` matches.
Compare this with a standard process node, which defines `build_cmd` and `run_cmd` instead:
* Python
peppy.json5 (process node)
```json5
{
schema_version: 1,
manifest: {
name: "my_node",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "python",
build_cmd: ["uv", "sync", "--no-editable"],
run_cmd: ["./.venv/bin/python", "-m", "my_node"]
}
}
```
* Rust
peppy.json5 (process node)
```json5
{
schema_version: 1,
manifest: {
name: "my_node",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/my_node"]
}
}
```
Container nodes don’t need `build_cmd` or `run_cmd` — the definition file takes care of both building and running the node.
## The `apptainer.def` file
[Section titled “The apptainer.def file”](#the-apptainerdef-file)
The generated definition file is a standard [Apptainer definition file](https://apptainer.org/docs/user/latest/definition_files.html). Here is what `peppy node init --container` generates:
* Python
apptainer.def
```apptainer
Bootstrap: docker
From: tuatini/peppy-python-uv-base
%labels
Name my_node
Version 0.1.0
%environment
export PATH="/opt/my_node/.venv/bin:$PATH"
%files
. /opt/my_node
%post
set -eux
cd /opt/my_node
uv sync --no-editable
%runscript
cd /opt/my_node
exec ./.venv/bin/python -m my_node
```
* Rust
apptainer.def
```apptainer
Bootstrap: docker
From: tuatini/peppy-rust-cargo-base
%labels
Name my_node
Version 0.1.0
%files
. /opt/my_node
%post
set -eux
cd /opt/my_node
cargo build --release
%runscript
cd /opt/my_node
exec ./target/release/my_node
```
Each section serves a specific purpose:
| Section | Purpose |
| -------------------- | ----------------------------------------------------------------------- |
| `Bootstrap` / `From` | Base image to build from (Ubuntu 24.04 by default) |
| `%labels` | Metadata embedded in the image |
| `%environment` | Environment variables set when the container runs |
| `%files` | Copies the node source into the image at `/opt/` |
| `%post` | Build steps — install system packages, toolchains, and compile the node |
| `%runscript` | Entry point executed when the container starts |
Note
The `%files` section copies the entire node directory into the container. PeppyOS automatically copies (rather than symlinks) internal libraries like `peppylib` so that they are fully available inside the image.
## Adding a container node
[Section titled “Adding a container node”](#adding-a-container-node)
Adding a container node works the same as a regular node — first stage it, then build:
```sh
peppy node add ./my_node
peppy node build my_node:0.1.0
```
You can also combine both steps with `peppy node add ./my_node --build` (shorthand `-b`). If you’ve just edited `peppy.json5`, add `--sync`/`-s` as well — e.g. `peppy node add ./my_node -sb` to sync, add, and build in one shot.
Under the hood, PeppyOS runs `apptainer build` during the build phase to produce a `.sif` (Singularity Image Format) file. This replaces the `build_cmd` step used by process nodes — the entire build happens inside the container according to the `%post` section of the definition file.
The resulting `.sif` file is stored in PeppyOS’s internal storage and is ready to be started.
Note
Container builds can take longer than regular builds because they install system packages and toolchains from scratch. Subsequent builds reuse Docker layer caches when possible.
## Starting a container node
[Section titled “Starting a container node”](#starting-a-container-node)
Starting a container node also uses the same command:
```sh
peppy node run my_node
```
PeppyOS runs the `.sif` image with `apptainer run`. Environment variables such as `PEPPY_RUNTIME_CONFIG` are passed into the container automatically — you don’t need to configure anything beyond what a regular node requires.
The container executes the `%runscript` section, which runs the compiled binary (Rust) or the Python module entry point.
## Mounting host directories
[Section titled “Mounting host directories”](#mounting-host-directories)
By default, a container is isolated from the host filesystem. Use `mount_paths` to bind-mount host directories into the running container — useful for sharing datasets, persisting output, or exposing device files.
Add a `mount_paths` array to the `container` block inside `execution` in `peppy.json5`:
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "my_node",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "python",
container: {
def_file: "apptainer.def",
mount_paths: [
"/data/models:/opt/models:ro",
"/tmp/my_node_output:/output:rw"
]
},
}
}
```
Each entry follows the format `host_path:container_path[:options]`:
| Format | Example | Behaviour |
| ---------------------------------- | ------------------------------- | ----------------------------------------------------------------------- |
| `host_path` | `"/data/models"` | Mounted at the same path inside the container |
| `host_path:container_path` | `"/data/models:/opt/models"` | Mounted at a different path inside the container |
| `host_path:container_path:options` | `"/data/models:/opt/models:ro"` | Mounted with explicit options (`ro` for read-only, `rw` for read-write) |
PeppyOS creates any missing parent directories on the host automatically before starting the container.
Note
Top-level system directories such as `/`, `/tmp`, `/var`, `/etc`, `/dev`, `/usr`, `/home`, `/opt`, `/bin`, and `/sbin` cannot be used as mount sources. Subdirectories of these paths are fine — for example, `/tmp/my_app_data` is allowed but `/tmp` is not.
Note
On macOS, PeppyOS automatically configures the Lima VM to make mounted paths accessible inside the guest. No extra setup is needed — paths outside your home directory are handled transparently.
### Using parameters in mount paths
[Section titled “Using parameters in mount paths”](#using-parameters-in-mount-paths)
Mount paths can reference runtime [parameters](/getting_started/parameters/) using the `${parameters:}` syntax. This lets each node instance mount a different host path based on its configuration.
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "uvc_camera",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "rust",
parameters: {
device_path: "string",
},
container: {
def_file: "apptainer.def",
mount_paths: [
"${parameters:device_path}:/dev/video0:rw"
]
},
},
}
```
When the node runs, `${parameters:device_path}` is replaced with the actual value provided in the deployment configuration. For example, if the instance supplies `device_path: "/dev/video2"`, the resulting bind mount is `/dev/video2:/dev/video0:rw`.
For nested parameters, use dot notation:
peppy.json5 (nested example)
```json5
{
// ...
execution: {
// ...
parameters: {
video: {
device_path: "string",
frame_rate: "u16",
},
},
container: {
def_file: "apptainer.def",
mount_paths: [
"${parameters:video.device_path}:/dev/video0:rw"
]
},
},
// ...
}
```
Note
Only parameters of type `"string"` can be referenced in mount paths. Numeric or object parameters will be rejected at parse time.
Note
Blocked system directory validation (e.g., rejecting `/tmp` as a mount source) is applied to the resolved path at runtime, not at parse time.
## Extra runtime arguments
[Section titled “Extra runtime arguments”](#extra-runtime-arguments)
You can pass additional command-line arguments directly to Apptainer or Lima using `apptainer_build_extra_args`, `apptainer_run_extra_args`, and `lima_shell_extra_args` in the `container` block inside `execution`.
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "my_node",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "python",
container: {
def_file: "apptainer.def",
apptainer_build_extra_args: ["--no-setgroups"],
apptainer_run_extra_args: ["--no-setgroups"],
},
}
}
```
| Field | Purpose |
| ---------------------------- | ---------------------------------------------------------------------- |
| `apptainer_build_extra_args` | Extra flags appended to `apptainer build` (e.g., `["--no-setgroups"]`) |
| `apptainer_run_extra_args` | Extra flags appended to `apptainer run` (e.g., `["--no-setgroups"]`) |
| `lima_shell_extra_args` | Extra flags passed to `limactl shell` on macOS (ignored on Linux) |
All fields are optional and default to an empty list when omitted.
Note
These arguments are passed verbatim — no validation is performed beyond basic string checks. Incorrect flags will cause Apptainer or Lima to fail at build or start time.
## macOS support
[Section titled “macOS support”](#macos-support)
On macOS, Apptainer is not natively available. PeppyOS bundles a [Lima](https://lima-vm.io/) virtual machine that runs Apptainer inside a lightweight Linux guest. This is handled transparently — all `peppy node` commands work identically on macOS and Linux. No additional installation or configuration is required.
## Customizing the definition file
[Section titled “Customizing the definition file”](#customizing-the-definition-file)
The generated `apptainer.def` is a starting point. You can modify it freely to fit your needs. Common customizations include:
### Adding system dependencies
[Section titled “Adding system dependencies”](#adding-system-dependencies)
Add packages to the `%post` section:
```apptainer
%post
apt-get update
apt-get install -y --no-install-recommends \
libopencv-dev libudev-dev
rm -rf /var/lib/apt/lists/*
```
### Changing the base image
[Section titled “Changing the base image”](#changing-the-base-image)
Swap the `From` line to use a different base:
```apptainer
Bootstrap: docker
From: nvidia/cuda:12.4.0-devel-ubuntu24.04
```
### Using a pre-built base image for faster builds
[Section titled “Using a pre-built base image for faster builds”](#using-a-pre-built-base-image-for-faster-builds)
Every `peppy node add` runs the full `%post` section from scratch — installing system packages, toolchains, and compiling dependencies each time. For nodes with heavy dependencies this can be slow.
You can speed things up by baking those slow steps into a custom Docker image and using it as your base. The first build pays the cost once; every subsequent `node add` starts from the cached image and only rebuilds your application code.
1. **Create a `Dockerfile`** with the dependencies your node needs:
* Python
Dockerfile
```dockerfile
FROM ubuntu:24.04
RUN set -eux \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl python3 python3-venv \
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh
```
* Rust
Dockerfile
```dockerfile
FROM ubuntu:24.04
RUN set -eux \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/* \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
```
2. **Build and push the image** to a registry your build machine can reach:
```sh
docker build -t my-registry/my_node-base:latest .
docker push my-registry/my_node-base:latest
```
3. **Point your `apptainer.def`** at the new image and remove the steps that are already baked in:
* Python
apptainer.def
```apptainer
Bootstrap: docker
From: my-registry/my_node-base:latest
%labels
Name my_node
Version 0.1.0
%environment
export PATH="/root/.local/bin:/opt/my_node/.venv/bin:$PATH"
%files
. /opt/my_node
%post
set -eux
cd /opt/my_node
uv sync --no-editable
%runscript
cd /opt/my_node
exec ./.venv/bin/python -m my_node
```
* Rust
apptainer.def
```apptainer
Bootstrap: docker
From: my-registry/my_node-base:latest
%labels
Name my_node
Version 0.1.0
%environment
export PATH="/root/.cargo/bin:$PATH"
%files
. /opt/my_node
%post
set -eux
cd /opt/my_node
cargo build --release
%runscript
cd /opt/my_node
exec ./target/release/my_node
```
Now `peppy node add` only runs the application-specific build steps — package installation and toolchain setup are already in the base image.
Tip
If multiple nodes share the same system dependencies, a single base image can serve all of them. Update the base image when dependencies change and tag it with a version so builds stay reproducible.
### Adding environment variables
[Section titled “Adding environment variables”](#adding-environment-variables)
Add variables to the `%environment` section so they are available at runtime:
```apptainer
%environment
export PATH="/root/.cargo/bin:$PATH"
export RUST_LOG=info
```
# Lockfiles
> Validate dependency interfaces with hashes to prevent consuming from a wrong node
## Introduction
[Section titled “Introduction”](#introduction)
Coming soon!
# Services
> How to use services in peppy
Services implement a **request-response** communication pattern between nodes. A node *exposes* a service to handle incoming requests, and other nodes *consume* that service to send requests and receive responses.
Use services for operations that need a result, such as querying a node’s state, toggling a feature, or triggering a one-time computation.
## Exposing a service
[Section titled “Exposing a service”](#exposing-a-service)
A node that handles service requests declares its services under `interfaces.services.exposes` in its `peppy.json5`. Each service defines a `name`, an optional `request_message_format`, and an optional `response_message_format`:
```json5
{
schema_version: 1,
manifest: {
name: "uvc_camera",
tag: "0.1.0",
},
interfaces: {
services: {
exposes: [
{
name: "enable_camera",
request_message_format: {
enable: "bool",
},
response_message_format: {
enabled: "bool",
error_msg: {
$type: "string",
$optional: true
},
},
},
{
// A service without a request body — the caller just needs the response.
name: "get_camera_info",
response_message_format: {
card_type: "string",
size: "string",
interval: "string"
},
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/uvc_camera"]
},
}
```
Both `request_message_format` and `response_message_format` are optional. A service with no request body acts like a simple getter, and a service with no response body acts like a fire-and-forget trigger.
### Handling requests
[Section titled “Handling requests”](#handling-requests)
After running `peppy node sync`, the code generator creates a module for each exposed service under `peppygen::exposed_services`. Use `handle_next_request` to process incoming requests. Each request is handled in a separate task so that the main async block is not blocked:
```rust
use peppygen::exposed_services::enable_camera;
use peppygen::{NodeBuilder, Parameters, Result};
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
tokio::spawn(async move {
enable_camera::handle_next_request(
&node_runner,
|request| -> Result {
println!(
"enable_camera request from {}: enable = {}",
request.instance_id, request.data.enable
);
Ok(enable_camera::Response::new(
request.data.enable,
Some("ok".to_owned()),
))
},
)
.await
});
Ok(())
})
}
```
The `request` argument contains:
* `instance_id` — the instance that sent the request.
* `data` — the deserialized request payload (only present when a `request_message_format` is defined).
`handle_next_request` processes a single request and returns. To serve requests continuously, call it in a loop inside a spawned task:
```rust
tokio::spawn(async move {
loop {
let _ = enable_camera::handle_next_request(&node_runner, |request| {
// ...
})
.await;
}
});
```
For a service without a request body, the handler receives a `Request` with only the `instance_id`:
```rust
use peppygen::exposed_services::get_camera_info;
tokio::spawn(async move {
get_camera_info::handle_next_request(
&node_runner,
|request| -> Result {
println!("get_camera_info request from {}", request.instance_id);
Ok(get_camera_info::Response::new(
"UVC Webcam".to_owned(),
"1920x1080".to_owned(),
"30fps".to_owned(),
))
},
)
.await
});
```
## Consuming a service
[Section titled “Consuming a service”](#consuming-a-service)
A node that calls a service declares what it consumes under `interfaces.services.consumes`. Dependencies are declared in `manifest.depends_on` and referenced by `local_node_id` in the interface:
```json5
{
schema_version: 1,
manifest: {
name: "robot_brain",
tag: "0.1.0",
depends_on: {
nodes: [
{ name: "uvc_camera", tag: "0.1.0", local_id: "uvc_camera" },
]
},
},
interfaces: {
services: {
consumes: [
{
local_node_id: "uvc_camera", // References depends_on.nodes[].local_id
name: "enable_camera", // Service name on that node
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/robot_brain"]
},
}
```
Note
The `node add` and `node sync` commands require the target node to already be in the node stack so the proper interfaces can be generated. If the target node is missing, you will see an error like:
```plaintext
Error: `robot_brain:0.1.0` depends on `uvc_camera:0.1.0`, but it does not exist in the stack
```
### Calling a service
[Section titled “Calling a service”](#calling-a-service)
The code generator creates a module for each consumed service under `peppygen::consumed_services`. Use `poll` to send a request and wait for a response:
```rust
use peppygen::consumed_services::uvc_camera_enable_camera;
use peppygen::{NodeBuilder, Parameters, Result};
use std::time::Duration;
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
let request = uvc_camera_enable_camera::Request::new(true);
let response = uvc_camera_enable_camera::poll(
&node_runner,
Duration::from_secs(5), // timeout
None, // target_core_node (None = any)
None, // target_instance_id (None = any)
request,
)
.await?;
println!(
"enable_camera result: instance={} enabled={} error={}",
response.instance_id,
response.data.enabled,
response.data.error_msg.as_deref().unwrap_or(""),
);
Ok(())
})
}
```
The `poll` function takes:
* `node_runner` — the node runner reference.
* `timeout` — maximum time to wait for a response.
* `target_core_node` — optionally target a specific core node, or `None` for any.
* `target_instance_id` — optionally target a specific instance, or `None` for any.
* `request` — the request payload.
The response contains:
* `instance_id` — the instance that handled the request.
* `data` — the deserialized response payload.
For a service without a request body, `poll` does not take a request argument:
```rust
use peppygen::consumed_services::uvc_camera_get_camera_info;
let response = uvc_camera_get_camera_info::poll(
&node_runner,
Duration::from_secs(5),
None,
None,
).await?;
println!("Camera: {} {}", response.data.card_type, response.data.size);
```
## Instance targeting
[Section titled “Instance targeting”](#instance-targeting)
When multiple instances of the same node are running, you can control which instance handles your service request:
* **`None`** (default) — the request is sent to all instances and the first response wins.
* **`Some("instance_id")`** — the request is sent to a specific instance.
```rust
// Target a specific instance
let response = uvc_camera_enable_camera::poll(
&node_runner,
Duration::from_secs(5),
None,
Some("my-camera-instance"),
request,
)
.await?;
```
## Error handling
[Section titled “Error handling”](#error-handling)
Service calls can fail with three error types:
* **ServiceUnreachable** — no instance is listening for that service.
* **ServiceTimeout** — no response was received within the timeout.
* **ServiceError** — the handler returned an error, which is propagated back to the caller.
If the service handler returns an `Err`, the error is forwarded to the caller rather than silently timing out. This means a failing handler does not block the service from continuing to accept new requests.
# Topics
> How to use topics in peppy
Topics implement a **publish-subscribe** communication pattern between nodes. A node *emits* a topic to publish messages, and other nodes *expect* that topic to receive them.
Use topics for continuous, unidirectional data streams such as sensor readings, camera frames, state updates, or any information that flows over time. Multiple consumers can listen to the same topic simultaneously.
## Emitting a topic
[Section titled “Emitting a topic”](#emitting-a-topic)
A node that publishes messages declares its topics under `interfaces.topics.emits` in its `peppy.json5`. Each topic defines a `name`, a `qos_profile`, and a `message_format`:
```json5
{
schema_version: 1,
manifest: {
name: "uvc_camera",
tag: "0.1.0",
},
interfaces: {
topics: {
emits: [
{
name: "video_stream",
qos_profile: "sensor_data",
message_format: {
header: {
$type: "object",
stamp: "time",
frame_id: "u32"
},
encoding: "string",
width: "u32",
height: "u32",
frame: {
$type: "array",
$items: "u8"
}
}
}
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/uvc_camera"]
},
}
```
The `qos_profile` controls delivery guarantees. Available profiles are:
* **`sensor_data`** — optimised for high-frequency data where occasional drops are acceptable.
* **`standard`** — balanced defaults suitable for most use cases. This is the default when `qos_profile` is omitted.
* **`reliable`** — guarantees delivery at the cost of higher latency.
* **`critical`** — strongest delivery guarantees for safety-critical data.
### Publishing messages
[Section titled “Publishing messages”](#publishing-messages)
After running `peppy node sync`, the code generator creates a module for each emitted topic under `peppygen::emitted_topics`. Use `emit` to publish a message:
```rust
use peppygen::emitted_topics::video_stream;
use peppygen::{NodeBuilder, Parameters, Result};
use std::time::Duration;
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
let node_runner_clone = node_runner.clone();
tokio::spawn(async move {
let mut frame_id = 0u32;
loop {
let _ = video_stream::emit(
&node_runner_clone,
video_stream::MessageHeader {
stamp: std::time::SystemTime::now(),
frame_id,
},
"rgb8".to_owned(),
640,
480,
vec![1, 2, 3],
)
.await;
frame_id = frame_id.wrapping_add(1);
tokio::time::sleep(Duration::from_secs_f64(0.1)).await;
}
});
Ok(())
})
}
```
The `emit` function takes the `node_runner` reference followed by each field from the `message_format` in order. Nested objects become generated structs (e.g. `video_stream::MessageHeader`) whose fields match the object definition.
For a topic with a simple message format:
```json5
{
name: "message_stream",
qos_profile: "sensor_data",
message_format: {
message: "string"
}
}
```
the `emit` call takes a single `String` argument:
```rust
use peppygen::emitted_topics::message_stream;
message_stream::emit(&node_runner, "hello world".to_owned()).await?;
```
## Consuming a topic
[Section titled “Consuming a topic”](#consuming-a-topic)
A node that receives messages declares what it consumes under `interfaces.topics.consumes`. Dependencies are declared in `manifest.depends_on` and referenced by `local_node_id` in the interface:
```json5
{
schema_version: 1,
manifest: {
name: "web_video_stream",
tag: "0.1.0",
depends_on: {
nodes: [
{ name: "uvc_camera", tag: "0.1.0", local_id: "uvc_camera" },
]
},
},
interfaces: {
topics: {
consumes: [
{
local_node_id: "uvc_camera", // References depends_on.nodes[].local_id
name: "video_stream", // Topic name on that node
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/web_video_stream"]
},
}
```
Note
The `node add` and `node sync` commands require the target node to already be in the node stack so the proper interfaces can be generated. If the target node is missing, you will see an error like:
```plaintext
Error: `web_video_stream:0.1.0` depends on `uvc_camera:0.1.0`, but it does not exist in the stack
```
### Receiving messages
[Section titled “Receiving messages”](#receiving-messages)
The code generator creates a module for each consumed topic under `peppygen::consumed_topics`. The module name is `_` based on the `local_node_id` field — in this case `uvc_camera_video_stream`. Use `on_next_message_received` to wait for the next message:
```rust
use peppygen::consumed_topics::uvc_camera_video_stream;
use peppygen::{NodeBuilder, Parameters, Result};
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
let (instance_id, frame) = uvc_camera_video_stream::on_next_message_received(
&node_runner,
None, // target_core_node (None = any)
None, // target_instance_id (None = any)
)
.await?;
println!(
"got {}x{} frame encoded as {} from {}",
frame.width, frame.height, frame.encoding, instance_id
);
Ok(())
})
}
```
The `on_next_message_received` function takes:
* `node_runner` — the node runner reference.
* `target_core_node` — optionally filter messages from a specific core node, or `None` for any.
* `target_instance_id` — optionally filter messages from a specific instance, or `None` for any.
It returns a tuple of:
* `instance_id` — the instance that published the message.
* `message` — the deserialized message, with fields matching the topic’s `message_format`.
### Receiving messages continuously
[Section titled “Receiving messages continuously”](#receiving-messages-continuously)
`on_next_message_received` processes a single message and returns. To receive messages continuously, call it in a loop. Each message is processed in a separate task so that the receive loop is not blocked:
```rust
use std::sync::Arc;
use peppygen::consumed_topics::uvc_camera_video_stream;
use peppygen::{NodeBuilder, NodeRunner, Parameters, Result};
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
tokio::spawn(receive_frames(node_runner));
Ok(())
})
}
async fn receive_frames(node_runner: Arc) {
loop {
let result = uvc_camera_video_stream::on_next_message_received(
&node_runner,
None,
None,
)
.await;
match result {
Ok((instance_id, frame)) => {
tokio::spawn(async move {
println!("got {}x{} frame from {}", frame.width, frame.height, instance_id);
});
}
Err(e) => {
eprintln!("Error receiving frame: {e}");
break;
}
}
}
}
```
## Instance targeting
[Section titled “Instance targeting”](#instance-targeting)
When multiple instances of the same node are publishing, you can control which publisher you receive from:
* **`None`** (default) — messages from all instances are received.
* **`Some("instance_id")`** — only messages from a specific instance are received.
```rust
// Only receive from a specific camera instance
let (instance_id, frame) = uvc_camera_video_stream::on_next_message_received(
&node_runner,
None,
Some("my-camera-instance"),
)
.await?;
```
## Message format types
[Section titled “Message format types”](#message-format-types)
The `message_format` supports the following field types:
| Type | Rust type |
| ---------- | ----------------------- |
| `"bool"` | `bool` |
| `"u8"` | `u8` |
| `"u16"` | `u16` |
| `"u32"` | `u32` |
| `"u64"` | `u64` |
| `"i8"` | `i8` |
| `"i16"` | `i16` |
| `"i32"` | `i32` |
| `"i64"` | `i64` |
| `"f32"` | `f32` |
| `"f64"` | `f64` |
| `"string"` | `String` |
| `"bytes"` | `Vec` |
| `"time"` | `std::time::SystemTime` |
Fields can also use complex types:
* **Object** — a nested struct:
```json5
header: {
$type: "object",
stamp: "time",
frame_id: "u32"
}
```
* **Array** — a variable-length list:
```json5
frame: {
$type: "array",
$items: "u8"
}
```
* **Fixed-length array** — an array with a known size:
```json5
position: {
$type: "array",
$items: "f32",
$length: 3
}
```
* **Optional** — a field that may be absent:
```json5
error_msg: {
$type: "string",
$optional: true
}
```
# Variants & Flavors
> Define alternative implementations of the same node
Peppy provides two ways to organize alternative implementations of a node:
* **Variant** — same interface, different implementation. Swap between AI models, mock nodes, or platform-specific builds without the rest of the stack noticing.
* **Flavor** — different interfaces, same core function. A repository of implementations that serve the same role (e.g., a robot brain) but target different robots with different joints, sensors, and capabilities.
Both use the same underlying mechanism — the `variants` array in the root node’s manifest — but they serve different design goals.
## How variants work
[Section titled “How variants work”](#how-variants-work)
A variant is a regular node that inherits the **manifest** and **interfaces** from a root node but defines its own **execution**. When you add a variant, peppy swaps the root node’s execution with the variant’s execution while keeping the same manifest and interfaces.
For example, if you have a `brain` node running a Rust-based controller, you can add a `mock` variant that runs a lightweight Python script instead — same interfaces, different execution. The rest of your stack doesn’t notice the difference.
Only one variant (or the root node itself) can be active in the stack at a time — adding a variant replaces any previous entry for that `name:tag`.
## Variants — same interface, different implementations
[Section titled “Variants — same interface, different implementations”](#variants--same-interface-different-implementations)
When multiple implementations serve the same role in the system, they share an identical interface. The rest of the stack does not need to know which implementation is active — it talks to the node through the same topics, services, and actions regardless.
This is the core use case for peppy variants: one root node defines the manifest and interfaces, and each variant supplies only an execution.
**Example — depth camera drivers:**
A `depth_camera` node ships a default driver and declares variants for other hardware. Every variant publishes the same `depth_image` and `point_cloud` topics, so downstream nodes consume depth data without caring which physical sensor is behind it.
```plaintext
depth_camera/ ← root node
├── peppy.json5 ← manifest + interfaces + default execution
├── realsense/ ← variant (execution only)
│ └── peppy.json5
├── zed/ ← variant (execution only)
│ └── peppy.json5
└── oak_d/ ← variant (execution only)
└── peppy.json5
```
```json5
// depth_camera/peppy.json5 (root node)
{
schema_version: 1,
manifest: {
name: "depth_camera",
tag: "0.1.0",
variants: [
{ name: "realsense", source: { local: "./realsense" } },
{ name: "zed", source: { local: "./zed" } },
{ name: "oak_d", source: { local: "./oak_d" } },
],
},
interfaces: {
topics: {
emits: [
{
name: "depth_image",
qos_profile: "sensor_data",
message_format: {
width: "u32",
height: "u32",
encoding: "string",
data: { $type: "array", $items: "u8" },
},
},
{
name: "point_cloud",
qos_profile: "sensor_data",
message_format: {
num_points: "u32",
x: { $type: "array", $items: "f32" },
y: { $type: "array", $items: "f32" },
z: { $type: "array", $items: "f32" },
},
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/depth_camera"],
},
}
```
Because every variant exposes `depth_image` and `point_cloud` with the same message formats, they are interchangeable — swap the active variant without touching any other node in the stack.
**Example — mock node for testing:**
A `mock` variant lets you test the rest of the stack without real hardware. It publishes the same topics with synthetic data:
```json5
// depth_camera/mock/peppy.json5 — lightweight test double
{
schema_version: 1,
execution: {
language: "python",
build_cmd: ["uv", "sync"],
run_cmd: ["uv", "run", "mock_depth_camera"],
},
}
```
The mock variant defines only an execution — it inherits the root’s `depth_image` and `point_cloud` interfaces, so every downstream node works exactly as it would with real hardware.
**Example — platform-specific builds:**
A node can also declare variants for different execution environments. For instance, a `lidar` node might run natively on Linux but need a macOS variant for local development and a containerized variant for CI:
```json5
// lidar/peppy.json5 (root node — Linux native)
{
schema_version: 1,
manifest: {
name: "lidar",
tag: "0.2.0",
variants: [
{ name: "macos", source: { local: "./macos" } },
{ name: "containerized", source: { local: "./containerized" } },
],
},
interfaces: {
topics: {
emits: [
{
name: "scan",
qos_profile: "sensor_data",
message_format: {
ranges: { $type: "array", $items: "f32" },
angle_min: "f32",
angle_max: "f32",
},
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/lidar"],
},
}
```
```json5
// lidar/containerized/peppy.json5 — same interfaces, runs in a container
{
schema_version: 1,
execution: {
language: "rust",
container: {
def_file: "apptainer.def",
},
},
}
```
All three — native Linux, macOS, and containerized — publish the same `scan` topic. The rest of the stack is unaffected by which variant is active.
## Flavors — different interfaces, same core function
[Section titled “Flavors — different interfaces, same core function”](#flavors--different-interfaces-same-core-function)
A **flavor** is a variant whose source node implements the same core function as the root node but targets a **different system** — typically a different robot. Because each system has different joints, sensors, and capabilities, the interfaces necessarily differ between flavors.
Within the source repository, flavors are independent nodes with their own manifests and interfaces. Each node is designed to be **consumed as a variant** in its target system’s project. A standard peppy node can serve as a variant in another project as long as its interfaces match the root node’s interfaces in that project.
Note
Any peppy node can be used as a variant of another node — it just needs matching interfaces. Flavour nodes are independent within their source repository but are each referenced as a variant source from a different downstream project.
**Example — GR00T brain for multiple robots:**
A GR00T brain repository contains implementations for different robot platforms. Each has its own joint configuration and sensor layout, so they define different interfaces. These are flavors of the same core function — a GR00T-powered robot brain.
```plaintext
groot_brain/
├── shared/ ← common code used by all flavors
├── openarm01/ ← flavor: full node (manifest + interfaces + execution)
│ └── peppy.json5
├── unitree_g1/ ← flavor: full node (manifest + interfaces + execution)
│ └── peppy.json5
└── 1x/ ← flavor: full node (manifest + interfaces + execution)
└── peppy.json5
```
Each flavor has its own manifest and interfaces tailored to its target robot:
```json5
// groot_brain/openarm01/peppy.json5 — a standalone node
{
schema_version: 1,
manifest: {
name: "openarm01_brain",
tag: "0.1.0",
},
interfaces: {
topics: {
emits: [
{
name: "joint_commands",
qos_profile: "sensor_data",
message_format: {
// OpenArm01 has 6 joints
positions: { $type: "array", $items: "f64", $length: 6 },
},
},
],
},
},
execution: {
language: "python",
build_cmd: ["uv", "sync"],
run_cmd: ["uv", "run", "openarm01_brain"],
},
}
// unitree_g1 and 1x each have their own peppy.json5 with
// different joint counts, sensors, and message formats.
```
A downstream OpenArm01 robot project can then reference this flavor as a variant of its own `brain` node:
```json5
// openarm01_robot/brain/peppy.json5 (root node in the robot project)
{
schema_version: 1,
manifest: {
name: "brain",
tag: "0.1.0",
variants: [
{
name: "groot",
source: {
repo: "https://github.com/example/groot-brain.git",
path: "openarm01",
ref: "main",
},
},
],
},
// Same interfaces as openarm01_brain — variant matching succeeds
interfaces: {
topics: {
emits: [
{
name: "joint_commands",
qos_profile: "sensor_data",
message_format: {
positions: { $type: "array", $items: "f64", $length: 6 },
},
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/brain"],
},
}
```
## Defining variants
[Section titled “Defining variants”](#defining-variants)
Variants are declared in the root node’s `manifest.variants` array. Each entry has a `name` and a `source`:
```json5
// peppy.json5 (root node)
{
schema_version: 1,
manifest: {
name: "robot_brain",
tag: "0.1.0",
variants: [
{ name: "mock", source: { local: "../mock_brain" } },
{ name: "skildai", source: { local: "../skildai_brain" } },
{
name: "pi0",
source: {
repo: "https://github.com/example/pi0-brain.git",
path: "brain",
ref: "main",
},
},
],
},
interfaces: {
topics: {
emits: [
{
name: "joint_commands",
qos_profile: "sensor_data",
message_format: {
positions: { $type: "array", $items: "f64", $length: 6 },
velocities: { $type: "array", $items: "f64", $length: 6 },
},
},
],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/robot_brain"],
},
}
```
## Default variant
[Section titled “Default variant”](#default-variant)
A variant named `"default"` has special meaning: it is the node’s **primary implementation**, relocated to a separate directory. When a `default` variant is declared, the root `peppy.json5` omits the `execution` section entirely — the execution comes from the default variant instead.
This is useful when you want every implementation (including the primary one) to live under a `variants/` subdirectory for a cleaner project layout:
```plaintext
uvc_camera/ ← root node (no execution)
├── peppy.json5 ← manifest + interfaces only
└── variants/
├── default/ ← default variant (execution)
│ └── peppy.json5
└── mujoco/ ← another variant (execution)
└── peppy.json5
```
```json5
// uvc_camera/peppy.json5 (root node — no execution)
{
schema_version: 1,
manifest: {
name: "uvc_camera",
tag: "0.1.0",
variants: [
{ name: "default", source: { local: "./variants/default" } },
{ name: "mujoco", source: { local: "./variants/mujoco" } },
],
},
interfaces: {
topics: {
emits: [
{
name: "image",
qos_profile: "sensor_data",
message_format: {
width: "u32",
height: "u32",
encoding: "string",
data: { $type: "array", $items: "u8" },
},
},
],
},
},
// No `execution` — it comes from the default variant
}
```
uvc\_camera/variants/default/peppy.json5
```json5
{
schema_version: 1,
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/uvc_camera"],
},
}
```
When you run `peppy node add ./uvc_camera` without specifying a `--variant`, the default variant is resolved automatically. You can still select a different variant explicitly with `peppy node add --variant mujoco ./uvc_camera`.
Note
When a `default` variant is present, the root `peppy.json5` **must not** define an `execution` section — the two are mutually exclusive. Conversely, if no `default` variant is declared, the `execution` section is required as usual.
## Variant config
[Section titled “Variant config”](#variant-config)
A variant’s `peppy.json5` only needs a `schema_version` and an `execution`. The `manifest` and `interfaces` fields are optional:
```json5
// peppy.json5 (mock variant)
{
schema_version: 1,
execution: {
language: "rust",
run_cmd: ["./target/release/mock_brain"],
},
}
```
Each variant has its own `execution`, which means:
* Its own `language` (Rust or Python).
* Its own `build_cmd` for build steps.
* Its own `run_cmd` or `container` configuration.
* Its own `parameters` schema.
Each variant also gets its own `.peppy` directory with independently generated code bindings and fingerprints.
## Interface matching
[Section titled “Interface matching”](#interface-matching)
If a variant’s `peppy.json5` does define an `interfaces` section, the interfaces **must match** the root node’s interfaces. The comparison is order-independent — topics, services, and actions can appear in any order, and message format fields can be in any order, as long as the same items and attributes are present.
If the interfaces do not match, the add operation fails with a `VariantInterfaceMismatch` error.
```json5
// This variant config is valid — same interfaces, different order
{
schema_version: 1,
interfaces: {
topics: {
emits: [
// Same topic as root but message format fields in different order
{
name: "joint_commands",
qos_profile: "sensor_data",
message_format: {
velocities: { $type: "array", $items: "f64", $length: 6 },
positions: { $type: "array", $items: "f64", $length: 6 },
},
},
],
},
},
execution: {
language: "python",
build_cmd: ["uv", "sync"],
run_cmd: ["uv", "run", "mock_brain"],
},
}
```
If a variant omits `interfaces` entirely, no validation is performed and the root’s interfaces are used as-is.
## Adding a variant
[Section titled “Adding a variant”](#adding-a-variant)
Use the `--variant` flag with `peppy node add`. The source type is detected automatically:
```sh
# By name (looked up in root manifest)
peppy node add --variant mock ./robot_brain
# From a git repository (with ref)
peppy node add \
--variant https://github.com/example/mock-brain.git/brain@main \
./robot_brain
# From an HTTP archive
peppy node add \
--variant https://releases.example.com/mock-brain-0.1.0.tar.zst \
./robot_brain
```
For git sources, append `@ref` to specify a branch, tag, or commit (e.g., `@main`, `@v1.0`).
In all cases, the positional argument (`./robot_brain`) is the root node — its manifest and interfaces are used. The variant provides only the execution. The variant appears in the node stack under the root’s name and tag (`robot_brain:0.1.0`).
Note
Just like a regular `peppy node add`, adding a variant only stages it in the `Added` stage — it does **not** run the variant’s `build_cmd` automatically. Chain the build with `peppy node add --variant mock ./robot_brain -b` (or run `peppy node build robot_brain:0.1.0` afterward).
Tip
You can also run a variant directly with `cargo run` / `uv run` from inside the variant’s directory and peppy will merge it with the parent root on the fly — see [Running a variant in standalone mode](/guides/standalone_node/#running-a-variant-in-standalone-mode).
## Variant source types in the manifest
[Section titled “Variant source types in the manifest”](#variant-source-types-in-the-manifest)
When declaring variants in the manifest, sources support the same three types as regular deployment sources:
| Source type | Example |
| ----------- | ---------------------------------------------------------------- |
| Local path | `{ local: "../mock_brain" }` |
| Git repo | `{ repo: "https://github.com/...", path: "brain", ref: "main" }` |
| URL archive | `{ url: "https://example.com/brain.tar.zst", sha256: "..." }` |
Local paths are resolved relative to the root node’s directory.
# Node communication
> Learn how to make nodes send messages to each other
A node by itself isn’t very useful without the ability to communicate with other nodes in the node stack. Each node is only aware of the interfaces it exposes and the ones it consumes, as defined in its `peppy.json5` configuration. For example:
* Python
```json5
{
schema_version: 1,
manifest: {
name: "controller",
tag: "0.1.0",
},
interfaces: {
topics: {
emits: [],
consumes: [],
},
services: {
exposes: [],
consumes: [],
},
actions: {
exposes: [],
consumes: [],
},
},
execution: {
language: "python",
build_cmd: ["uv", "sync", "--no-editable"],
run_cmd: ["uv", "run", "controller"]
},
}
```
* Rust
```json5
{
schema_version: 1,
manifest: {
name: "controller",
tag: "0.1.0",
},
interfaces: {
topics: {
emits: [],
consumes: [],
},
services: {
exposes: [],
consumes: [],
},
actions: {
exposes: [],
consumes: [],
},
},
execution: {
language: "rust",
build_cmd: ["cargo", "build", "--release"],
run_cmd: ["./target/release/controller"]
},
}
```
Here we have `interfaces` organized by kind (`topics`, `services`, `actions`). Topics use `emits` and `consumes` lists, as do services and actions, which use `exposes` and `consumes` lists. These interfaces define the dependencies between nodes.
## Topics, Services & Actions
[Section titled “Topics, Services & Actions”](#topics-services--actions)
PeppyOS provides three primary communication patterns for nodes to exchange data:
* **Topics** are used for continuous, unidirectional data streams. A node *publishes* messages to a topic, and any number of nodes can *subscribe* to receive them. This is ideal for sensor data, state updates, or any information that flows continuously (e.g., camera images, odometry).
* **Services** implement a request-response pattern. A node connects to another node and waits for a response. Use services for quick operations that need a result, like querying a node’s state or triggering a one-time computation.
* **Actions** are for long-running tasks that need feedback and cancellation support. A client sends a goal to an action node, which provides periodic feedback during execution and a final result upon completion. Actions are built on top of topics and services internally. Use them for tasks like navigation or arm movement. Actions are processed serially by a node: two requests to the same action cannot run in parallel. For example, a `move_arm` action that moves a robot’s arm can only perform one movement at a time.
## Consumed interfaces
[Section titled “Consumed interfaces”](#consumed-interfaces)
In our `hello_world_param` node, we’ve already emitted a topic. Let’s try to make this topic communicate with another node.
We first need to initialize the `hello_receiver` node in a new folder:
1. Initialize the node:
* Python
```sh
peppy node init --toolchain uv hello_receiver
```
* Rust
```sh
peppy node init --toolchain cargo hello_receiver
```
2. Navigate into the directory:
```sh
cd hello_receiver
```
with the following `peppy.json5`:
* Python
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "hello_receiver",
tag: "0.1.0",
depends_on: {
nodes: [
{ name: "hello_world_param", tag: "0.1.0", local_id: "hello_world_param" },
]
},
},
interfaces: {
topics: {
consumes: [
{
local_node_id: "hello_world_param",
name: "message_stream",
}
],
}
},
execution: {
language: "python",
build_cmd: [
"uv",
"sync"
],
run_cmd: [
"uv",
"run",
"hello_receiver"
]
},
}
```
* Rust
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "hello_receiver",
tag: "0.1.0",
depends_on: {
nodes: [
{ name: "hello_world_param", tag: "0.1.0", local_id: "hello_world_param" },
]
},
},
interfaces: {
topics: {
consumes: [
{
local_node_id: "hello_world_param",
name: "message_stream",
}
],
}
},
execution: {
language: "rust",
build_cmd: [
"cargo",
"build",
"--release"
],
run_cmd: [
"./target/release/hello_receiver"
]
},
}
```
and the following source file:
* Python
src/hello\_receiver/\_\_main\_\_.py
```python
import asyncio
from peppygen import NodeBuilder, NodeRunner
from peppygen.parameters import Parameters
from peppygen.consumed_topics import hello_world_param_message_stream
async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]:
return [asyncio.create_task(receive_messages(node_runner))]
async def receive_messages(node_runner: NodeRunner):
while True:
(
instance_id,
message,
) = await hello_world_param_message_stream.on_next_message_received(node_runner)
print(f"Received from {instance_id}: {message.message}")
def main():
NodeBuilder().run(setup)
if __name__ == "__main__":
main()
```
* Rust
src/main.rs
```rust
use std::sync::Arc;
use peppygen::consumed_topics::hello_world_param_message_stream;
use peppygen::{NodeBuilder, NodeRunner, Parameters, Result};
fn main() -> Result<()> {
NodeBuilder::new().run(|_args: Parameters, node_runner| async move {
tokio::spawn(receive_messages(node_runner));
Ok(())
})
}
async fn receive_messages(node_runner: Arc) {
loop {
let result = hello_world_param_message_stream::on_next_message_received(
&node_runner,
None,
None,
)
.await;
match result {
Ok((instance_id, message)) => println!("Received from {instance_id}: {}", message.message),
Err(e) => {
eprintln!("Error receiving message: {e}");
break;
}
}
}
}
```
Finally, add this new node to the stack:
1. Sync the node interfaces:
```sh
peppy node sync
```
2. Add the node to the stack:
```sh
peppy node add .
```
Tip
You can combine both steps into a single command with `peppy node add . --sync` (or `-s`). Add `-b`/`-r` to also build or run in the same invocation — e.g. `peppy node add . -sb`.
Note
Make sure `hello_world_param` is already in the node stack, otherwise you might get the following error:
```sh
Error: `hello_receiver:0.1.0 depends on `hello_world_param:0.1.0`, but it does not exist in the stack
```
The `node add` and `node sync` both require the consumed nodes to be present in the node stack to generate the proper interfaces for the programming language they are written in.
## Starting the nodes
[Section titled “Starting the nodes”](#starting-the-nodes)
Now we need to make sure our nodes are started. If we take a look at our node stack:
```sh
❯ peppy stack list
Listing nodes...
Requesting node stack graph from core 'sweet-germain-4388'...
Node stack:
- sweet-germain-4388:core-node [Root] (/Users/tuatini/workspace/peppy) (1 instance: ["brave-zhukovsky-6918"])
- hello_receiver:0.1.0 [Ready] (/Users/tuatini/.peppy/built_nodes/hello_receiver_0.1.0_d41475) (0 instances: [])
- hello_world_param:0.1.0 [Ready] (/Users/tuatini/.peppy/built_nodes/hello_world_param_0.1.0_9d5fa0) (0 instances: [])
Dependencies:
- hello_receiver:0.1.0 -> hello_world_param:0.1.0
```
We need to make sure that at least one instance is started for `hello_receiver:0.1.0` and another one for `hello_world_param:0.1.0`. The order in which the nodes are started does not affect the node communication. Let’s start a `hello_world_param:0.1.0` instance:
```sh
peppy node run hello_world_param:0.1.0 name=planet
```
Since our node requires a `name`, we provide it with that argument on startup. Now let’s start the `hello_receiver` node:
```sh
❯ peppy node run hello_receiver:0.1.0
Running node hello_receiver:0.1.0...
Starting node hello_receiver:0.1.0 with instance_id 'vigorous-buck-8117' and 0 argument(s)...
Calling node_start for hello_receiver:0.1.0 (instance_id=vigorous-buck-8117)...
Log file: /Users/tuatini/.peppy/logs/run/vigorous-buck-8117.log
Started node instance 'vigorous-buck-8117' (pid: 77190)
```
When you start the node, you can see a path to the logs. In my case: `/Users/tuatini/.peppy/logs/run/vigorous-buck-8117.log`. If we open it up:
```plaintext
[2026-01-24T10:02:47.630] [stderr] Finished `release` profile [optimized] target(s) in 0.21s
[2026-01-24T10:02:47.635] [stderr] Running `target/release/hello_receiver`
[2026-01-24T10:02:51.243] [stdout] Received from gifted-moser-9365: hello planet count 7
[2026-01-24T10:02:54.243] [stdout] Received from gifted-moser-9365: hello planet count 8
[2026-01-24T10:02:57.244] [stdout] Received from gifted-moser-9365: hello planet count 9
```
We can see the messages received from the `hello_world_param:0.1.0` instance!
## Starting a second instance with different parameters
[Section titled “Starting a second instance with different parameters”](#starting-a-second-instance-with-different-parameters)
Now let’s push things a little further. Imagine we need a second instance with different parameters—we can start one like this:
```sh
❯ peppy node run hello_world_param:0.1.0 name=you
Running node hello_world_param:0.1.0...
Starting node hello_world_param:0.1.0 with instance_id 'admiring-black-0614' and 1 argument(s)...
Calling node_start for hello_world_param:0.1.0 (instance_id=admiring-black-0614)...
Log file: /Users/tuatini/.peppy/logs/run/admiring-black-0614.log
Started node instance 'admiring-black-0614' (pid: 78063)
```
And we check the logs again:
```sh
❯ tail /Users/tuatini/.peppy/logs/run/vigorous-buck-8117.log
[2026-01-24T10:04:36.242] [stdout] Received from gifted-moser-9365: hello planet count 42
[2026-01-24T10:04:39.244] [stdout] Received from gifted-moser-9365: hello planet count 43
[2026-01-24T10:04:42.244] [stdout] Received from gifted-moser-9365: hello planet count 44
[2026-01-24T10:04:45.244] [stdout] Received from gifted-moser-9365: hello planet count 45
[2026-01-24T10:04:47.085] [stdout] Received from admiring-black-0614: hello you count 1
[2026-01-24T10:04:48.243] [stdout] Received from gifted-moser-9365: hello planet count 46
[2026-01-24T10:04:50.085] [stdout] Received from admiring-black-0614: hello you count 2
[2026-01-24T10:04:51.244] [stdout] Received from gifted-moser-9365: hello planet count 47
```
We can see the messages from both instances!
```sh
❯ peppy stack list
Listing nodes...
Requesting node stack graph from core 'sweet-germain-4388'...
Node stack:
- sweet-germain-4388:core-node [Root] (/Users/tuatini/workspace/peppy) (1 instance: ["brave-zhukovsky-6918"])
- hello_receiver:0.1.0 [Ready] (/Users/tuatini/.peppy/built_nodes/hello_receiver_0.1.0_d41475) (1 instance: ["vigorous-buck-8117"])
- hello_world_param:0.1.0 [Ready] (/Users/tuatini/.peppy/built_nodes/hello_world_param_0.1.0_9d5fa0) (2 instances: ["gifted-moser-9365", "admiring-black-0614"])
Dependencies:
- hello_receiver:0.1.0 -> hello_world_param:0.1.0
```
***
We’ll explore [services](/advanced_guides/services) and [actions](/advanced_guides/actions) in the advanced guides, although they fundamentally work the same way. Refer to nodes in [this repository](https://github.com/Peppy-bot/nodes_hub.git) for more examples of topics/service/action usage.
# Creating Your First Node
> Learn how to create and run your first PeppyOS 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”](#project-structure)
Run:
* Python
1. Initialize the node:
```sh
peppy node init --toolchain uv hello_world
```
2. Navigate into the directory:
```sh
cd hello_world
```
Note
[`uv`](https://docs.astral.sh/uv/) is the default Python toolchain in PeppyOS, but you can use any toolchain you prefer ([`pixi`](https://prefix.dev/), [`poetry`](https://python-poetry.org/), [`pip`](https://pip.pypa.io/en/stable), etc.). To do so, first initialize the node with `peppy node init --toolchain uv`, then add `peppygen = { path = ".peppy/libs/peppygen" }` as a dependency in your toolchain’s configuration, and update `build_cmd` and `run_cmd` in `peppy.json5` to match your toolchain’s commands. PeppyOS is designed to work with whichever tools you prefer.
* Rust
1. Initialize the node:
```sh
peppy node init --toolchain cargo hello_world
```
2. Navigate into the directory:
```sh
cd hello_world
```
Note
[`cargo`](https://doc.rust-lang.org/cargo/) is the default Rust toolchain in PeppyOS, but you can use any build system you prefer. To do so, first initialize the node with `peppy node init --toolchain cargo`, then add `peppygen = { path = ".peppy/libs/peppygen" }` as a dependency in your build configuration, and update `build_cmd` and `run_cmd` in `peppy.json5` to match your build system’s commands. PeppyOS is designed to work with whichever tools you prefer.
In the node folder, you’ll find the following:
* A `peppy.json5` file
* A `.peppy` folder local to your node
* Your project scaffolding based on the selected toolchain
## Exploring the `peppy.json5` file
[Section titled “Exploring the peppy.json5 file”](#exploring-the-peppyjson5-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”](#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”](#writing-your-first-node)
Let’s open the `peppy.json5` file of the node we just created and explore it together.
* Python
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "hello_world",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "python",
build_cmd: [
"uv",
"sync"
],
run_cmd: [
"uv",
"run",
"hello_world"
]
}
}
```
* Rust
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "hello_world",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "rust",
build_cmd: [
"cargo",
"build",
"--release"
],
run_cmd: [
"./target/release/hello_world"
]
}
}
```
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 file
* `manifest`: Contains information about the node. Each node is identified by its `name` and `tag`
* `execution`: Contains the language and execution commands for the node
* `language`: The programming language used by the node (e.g. `"python"`, `"rust"`)
* `build_cmd`: Command run during the build phase of the node (e.g. via `peppy node build`, `peppy node add --build`, or the combined shorthands `peppy 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.
* Python
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "hello_world",
tag: "0.1.0",
},
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"
]
},
}
```
* Rust
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "hello_world",
tag: "0.1.0",
},
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:
```sh
peppy node sync
```
This 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:
* Python
src/hello\_world/\_\_main\_\_.py
```python
import asyncio
from peppygen import NodeBuilder, NodeRunner
from peppygen.parameters import Parameters
from 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()
```
* Rust
src/main.rs
```rust
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, 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 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.
# Installation
> How to install PeppyOS on your system
To install `peppy`, run the following command in your terminal:
```sh
curl -fsSL https://peppy.bot/install.sh | bash
```
This installer also sets up the `peppy` background service. To skip that step, set `PEPPY_NO_SERVICE_INSTALL=1`.
Note
To install a specific version, set the `PEPPY_VERSION` environment variable:
```sh
curl -fsSL https://peppy.bot/install.sh | PEPPY_VERSION="v0.3.0" sh
```
## Verifying the installation
[Section titled “Verifying the installation”](#verifying-the-installation)
You can verify the service is running with:
* Linux
```sh
systemctl --user status peppy
```
* macOS
```sh
launchctl list bot.peppy
```
**Managing the service (optional)**
* Linux
Start the service:
```sh
systemctl --user start peppy
```
Stop the service:
```sh
systemctl --user stop peppy
```
Restart the service:
```sh
systemctl --user restart peppy
```
* macOS
Start the service:
```sh
launchctl load ~/Library/LaunchAgents/bot.peppy.plist
```
Stop the service:
```sh
launchctl unload ~/Library/LaunchAgents/bot.peppy.plist
```
Restart the service:
1. Unload the service:
```sh
launchctl unload ~/Library/LaunchAgents/bot.peppy.plist
```
2. Reload the service:
```sh
launchctl load ~/Library/LaunchAgents/bot.peppy.plist
```
# Launch files
> Learn how to run all your nodes with a single launch file
Launch files let you start multiple nodes simultaneously, including those with dependencies on each other. With a single launch file, you or your team can recreate an entire node environment using just one command.
## Running the Example
[Section titled “Running the Example”](#running-the-example)
Start by cloning [this repository](https://github.com/Peppy-bot/launch_example):
1. Clone the repository:
```sh
git clone https://github.com/Peppy-bot/launch_example
```
2. Navigate into the directory:
```sh
cd launch_example
```
In the root directory, you’ll find a [`peppy_launcher.json5`](https://github.com/Peppy-bot/launch_example/blob/main/peppy_launcher.json5) file. Opening it reveals the following structure:
1. All deployments are defined in the `deployments` array
2. Each deployment requires a `source` and `instances` attribute
The `source` attribute supports three formats:
* **URL**: A link to a `.tar.zst` archive, requiring both `url` and `sha256` attributes
* **Git repository**: Requires `repo`, `path` (location within the repo), and `ref` attributes
* **Local path**: Specified via the `local` key, pointing to a directory on your filesystem. The path can be either absolute or relative to the `peppy_launcher.json5` file
### Variants
[Section titled “Variants”](#variants)
Each deployment source can optionally include a `variant` field to select a specific variant of the node (e.g., a mock implementation or an architecture-specific build). The `variant` object supports three formats:
* **Name**: Reference a variant by name — `{ name: "mock-rust" }`
* **Git repository**: Fetch a variant from a git repo — requires `repo`, with optional `path` and `ref`
* **URL**: Fetch a variant from an HTTP(S) URL — requires `url`, with optional `sha256`
Examples:
```json5
// Named variant
{
source: {
repo: "https://github.com/Peppy-bot/nodes_hub.git",
path: "rust/fake_openarm01_controller",
ref: "main",
variant: {
name: "mock-rust"
}
},
instances: [
{ instance_id: "the_nervous_system" }
]
}
```
```json5
// Git variant
{
source: {
local: "./my_node",
variant: {
repo: "https://github.com/Peppy-bot/node_variants.git",
path: "mock",
ref: "v1.0.0"
}
},
instances: [
{ instance_id: "my_node_1" }
]
}
```
```json5
// URL variant
{
source: {
local: "./my_node",
variant: {
url: "https://example.com/my_node_variant.tar.zst",
sha256: "33e83da60a54e3bb487a9a3b67705918602143b30f158143b6909acaf017a36a"
}
},
instances: [
{ instance_id: "my_node_1" }
]
}
```
Caution
The git `ref` and `deployment.source` tag share the same format but are independent values. This means you could have a git `ref` of `v0.1.0` while `peppy.json5` specifies `v0.1.1`. Ensure your git tags match the version in `peppy.json5` to avoid confusion!
From within the cloned repository, execute the launcher:
* Python
```sh
peppy stack launch ./python/peppy_launcher.json5
```
* Rust
```sh
peppy stack launch ./rust/peppy_launcher.json5
```
PeppyOS automatically inspects the `name` and `tag` of each node, verifies that all dependencies are met, and constructs the node stack in the correct order.
Caution
Running the `peppy stack launch` command clears the existing node stack and stops all running instances
Verify that the node stack was configured correctly:
```sh
❯ peppy stack list
Listing nodes...
Requesting node stack graph from core 'adoring-wiles-7286'...
Node stack:
- adoring-wiles-7286:core-node [Root] (/Users/tuatini/workspace/peppy) (1 instance: ["happy-tu-8997"])
- fake_openarm01_controller:0.1.0 [Ready] (/Users/tuatini/.peppy/built_nodes/fake_openarm01_controller_0.1.0_d7dd45) (1 instance: ["the_nervous_system"])
- fake_robot_brain:0.1.0 [Ready] (/Users/tuatini/.peppy/built_nodes/fake_robot_brain_0.1.0_4eb897) (1 instance: ["the_brain"])
- fake_uvc_camera:0.1.0 [Ready] (/Users/tuatini/.peppy/built_nodes/fake_uvc_camera_0.1.0_89ce12) (2 instances: ["camera_front", "camera_rear"])
- fake_video_reconstruction:0.1.0 [Ready] (/Users/tuatini/.peppy/built_nodes/fake_video_reconstruction_0.1.0_08320a) (1 instance: ["video_reconstruction_1"])
Dependencies:
- fake_robot_brain:0.1.0 -> fake_openarm01_controller:0.1.0
- fake_robot_brain:0.1.0 -> fake_uvc_camera:0.1.0
- fake_video_reconstruction:0.1.0 -> fake_uvc_camera:0.1.0
```
The output confirms that all nodes have been added to the stack along with their dependency relationships.
# The Node stack
> Learn how to add, start, stop, and manage nodes in the PeppyOS stack
The **node stack** is the central registry that manages all your nodes and their relationships in PeppyOS. Think of it as a directed graph where:
* **Nodes** are registered configurations that can have multiple running instances
* **Edges** represent explicit dependencies declared via `depends_on` in the node manifest
When you add a node to the stack, PeppyOS validates that all its dependencies are satisfied—ensuring that any topics, services, or actions your node relies on are provided by other nodes already in the stack. This dependency validation prevents runtime errors and helps you understand how your nodes interconnect.
The stack always contains a **core node** and an instance of it at its root, which coordinates the lifecycle of all other nodes. You can query the stack to see which nodes depend on each other, visualize the dependency graph, and manage node instances. The **node stack** is started as a daemon on your system and the `peppy` binary communicates with it to execute user actions.
## Adding `hello_world` to the stack
[Section titled “Adding hello\_world to the stack”](#adding-hello_world-to-the-stack)
Inside your node directory, add it to the PeppyOS stack by running:
```sh
peppy node add .
```
* Python
Caution
[uv](https://docs.astral.sh/uv/getting-started/installation/) must be installed on your system. Without it, Peppy will fail with a `No such file or directory` error.
* Rust
Caution
[cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) must be installed on your system. Without it, Peppy will fail with a `No such file or directory` error.
Note
For faster Rust builds, install [sccache](https://github.com/mozilla/sccache#installation). When Peppy detects `sccache` on your system PATH, it automatically sets `RUSTC_WRAPPER=sccache` for Rust node builds, caching compilation artifacts across runs.
This command registers the node with the core node by staging a snapshot of the source directory. After a successful add, the node enters the `Added` stage: its config is known to the daemon, but no runnable artifact exists yet.
Caution
`peppy node add` does **not** run the node’s `build_cmd`. To produce a runnable artifact you either run `peppy node build :` afterward, or chain the two in one shot with `peppy node add . --build` (shorthand `-b`). Adding `--run` (`-r`) also spawns an instance once the build finishes, and implies `--build`.
The output of this command provides some useful information:
```plaintext
Adding node from /private/tmp/hello_world...
Log file: ~/.peppy/logs/add/hello_world_0.1.0_20260121_224824_699.log
Added node hello_world:0.1.0 to the node stack
Snapshot path: ~/.peppy/built_nodes/hello_world_0.1.0_b19507
```
This gives you access to the log file for debugging in case the `add` command fails and also shows a “snapshot path”. When a node is added to the node stack, a copy is made in the peppy cache to “snapshot” the node based on its name and tag. No two copies of the same node with the same name + tag can exist at the same time in the node stack. Re-adding a node with the same name and tag overrides the one that was previously in the node stack, except if that node has dependencies—in which case the operation fails.
### Syncing before adding
[Section titled “Syncing before adding”](#syncing-before-adding)
If you’ve just edited `peppy.json5` — for example, added a new topic or changed a parameter — the generated peppygen interface code under `.peppy/` is stale. `peppy node sync` regenerates it, and the `--sync` (shorthand `-s`) flag on `peppy node add` runs that sync step **before** staging the snapshot so the added node ships up-to-date bindings:
```sh
peppy node add -sb . # sync, then add, then build
peppy node add -sr . # sync, then add, then build, then run an instance
```
Tip
`-sb` and `-sr` are the shorthands you’ll reach for constantly during development. The typical edit-loop is: change `peppy.json5` or source, hit `peppy node add -sr .`, watch the logs, iterate. Without `-s`, a stale `.peppy/` bindings directory can make the daemon see an out-of-date interface; without `-r`, you have to follow every add with a separate `peppy node run`. Bundling them collapses the whole “I edited something, now run it” cycle to a single command.
If you pass runtime `key=value` arguments alongside `-r`, keep them **after** the source path — `peppy node add -sr . frequency=30` — so the source positional isn’t confused with the trailing args.
`--sync` is only valid for local filesystem sources — remote git and HTTP sources are synced server-side when the daemon fetches them. It is **not** implied by `--build` or `--run`; it’s a prerequisite step, not a post-step. Running `peppy node sync` as a separate command before `peppy node add` is equivalent.
## Starting your node
[Section titled “Starting your node”](#starting-your-node)
Start your node:
```sh
peppy node run hello_world:0.1.0
```
This will run the `run_cmd` command from the `peppy.json5` process configuration. The format is `:` for a node that is already part of the node stack.
The `node run` command outputs the path to a log file where you can inspect the node’s output. For example:
```sh
cat /home/user/.peppy/logs/run/elegant-solomon-5423.log
[2026-02-17T10:15:23.599] Executing run_cmd: uv run hello_world (working_dir: /home/user/.peppy/built_nodes/hello_world_0.1.0_f2765c)
[2026-02-17T10:15:23.837] [stdout] hello world count 1
[2026-02-17T10:15:26.848] [stdout] hello world count 2
[2026-02-17T10:15:29.852] [stdout] hello world count 3
```
## Checking node status
[Section titled “Checking node status”](#checking-node-status)
View all added & running nodes:
```sh
peppy stack list
```
You should see something like:
```plaintext
❯ peppy stack list
Listing nodes...
Requesting node stack graph from core 'hungry-buck-4136'...
Node stack:
- hungry-buck-4136:core-node [Root] (/root/.peppy/bin) (1 instance: ["sleepy-wiles-5676"])
- hello_world:0.1.0 [Ready] (/root/.peppy/built_nodes/hello_world_0.1.0_b19507) (1 instance: ["elegant-solomon-5423"])
Dependencies:
(none)
```
The bracketed label after each node is its **stage** — the artifact-level lifecycle of the node:
* **`Added`** — the node is registered and a snapshot of the source has been taken, but `build_cmd` has not been run yet, so there is no runnable artifact.
* **`Building`** — a build is in progress. A second concurrent `node build` on the same entity is rejected until this one finishes.
* **`Ready`** — the build artifact is on disk. The node can be run; it may currently have zero, one, or several instances.
* **`Root`** — the synthetic core-node entity. Always present, and always reports exactly one running instance (the daemon itself).
Per-instance state (`starting`, `running`) lives inside the bracketed instance list, independently of the node’s stage.
## Inspecting a node with `peppy node info`
[Section titled “Inspecting a node with peppy node info”](#inspecting-a-node-with-peppy-node-info)
When you need more than the one-line view from `stack list`, `peppy node info` dumps the node’s stage, its currently-tracked instances with their individual states, and the paths to its add log and per-instance run logs. It takes a `:` reference to a node **that has already been added to the stack** — it doesn’t touch the filesystem or any git/http source, so run `peppy node add` first if the node isn’t in the stack yet:
```sh
peppy node info hello_world:0.1.0
```
```plaintext
Node Information
==================================================
Name: hello_world
Tag: 0.1.0
Language: Python
Build cmd: uv sync
Run cmd: uv run hello_world
Node Stack Status
--------------------------------------------------
Status: In node stack
Stage: Ready
Instances: 2 tracked
- inst-abc [running]
- inst-def [starting]
Logs
--------------------------------------------------
hello_world:0.1.0
Add log: /Users/you/.peppy/logs/add/hello_world_0.1.0_20260121_224824_699.log
Run logs:
- inst-abc: /Users/you/.peppy/logs/run/inst-abc.log
- inst-def: /Users/you/.peppy/logs/run/inst-def.log
```
This is usually the fastest way to answer “is my node built yet?” and “where is the log for this specific instance?”. The `Add log` path is what `peppy node add` writes to; per-instance run logs are what `peppy node run` writes to. Build logs live under `~/.peppy/logs/build/` and are surfaced by `peppy node build` directly when it runs.
## Stopping your node
[Section titled “Stopping your node”](#stopping-your-node)
When you’re done, you can stop your node:
```sh
peppy node stop
```
This stops **one instance** of the `hello_world:0.1.0` node, but the node itself still remains in the node stack.
## Removing your node
[Section titled “Removing your node”](#removing-your-node)
To remove the node from the node stack, run:
```sh
peppy node remove hello_world:0.1.0
```
If you run `peppy stack list` again, you’ll see that only the core node remains:
```sh
❯ peppy stack list
Listing nodes...
Requesting node stack graph from core 'hungry-buck-4136'...
Node stack:
- hungry-buck-4136:core-node [Root] (/root/.peppy/bin) (1 instance: ["sleepy-wiles-5676"])
Dependencies:
(none)
```
## Next steps
[Section titled “Next steps”](#next-steps)
Now that you’ve created your first node, you can:
* Add [interfaces](/reference/concepts/#interfaces) to communicate with other nodes
* Configure [parameters](/reference/concepts/#manifest) for your node
* Learn about the [node stack](/reference/concepts/#node-stack) and dependencies
# Node parameters
> Learn how to pass starting parameters to nodes
Sometimes you may want to run multiple instances of the same node with different input parameters. For example, a camera node might point to `/dev/video0` on your system while another one points to `/dev/video1`. Parameters allow you to run multiple instances of the same node with different input settings.
## Adding parameters
[Section titled “Adding parameters”](#adding-parameters)
In a new folder, initialize a new node:
1. Initialize the node:
* Python
```sh
peppy node init --toolchain uv hello_world_param
```
* Rust
```sh
peppy node init --toolchain cargo hello_world_param
```
2. Navigate into the directory:
```sh
cd hello_world_param
```
Then modify the `peppy.json5` configuration to look like this:
* Python
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "hello_world_param",
tag: "0.1.0",
},
interfaces: {
topics: {
emits: [
{
name: "message_stream",
qos_profile: "sensor_data",
message_format: {
message: "string"
},
}
],
}
},
execution: {
language: "python",
parameters: {
name: "string",
},
build_cmd: [
"uv",
"sync"
],
run_cmd: [
"uv",
"run",
"hello_world_param"
]
},
}
```
* Rust
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "hello_world_param",
tag: "0.1.0",
},
interfaces: {
topics: {
emits: [
{
name: "message_stream",
qos_profile: "sensor_data",
message_format: {
message: "string"
},
}
],
}
},
execution: {
language: "rust",
parameters: {
name: "string",
},
build_cmd: [
"cargo",
"build",
"--release"
],
run_cmd: [
"./target/release/hello_world_param"
]
},
}
```
Notice the new `parameters` key inside the `execution` section, which declares the parameter schema the node accepts at runtime.
## Synchronizing the interfaces
[Section titled “Synchronizing the interfaces”](#synchronizing-the-interfaces)
Now let’s try to create a new snapshot of our node in the node stack:
```sh
peppy node add .
```
You’ll get an error similar to this one:
```text
Error: Fingerprint verification failed: Node config fingerprint mismatch: expected 9a58e1936c52c83d47d31621108e32bb78d2197c2d1989f0001e945a754e29a8, got ca1b1d51711a782db3152a723c6cdc255191c2a1721c7a19088e878fc9879c39.
The config may have been modified after code generation. Run `node sync` to update the peppygen lib on your node.
```
This error occurs because any changes to `peppy.json5` need to be synced with the generated interfaces first. This safeguard guarantees that `peppy.json5` **always** stays in sync with the node’s actual behavior. To update the interfaces, run:
```sh
peppy node sync
```
This ensures that `peppy node add .` will succeed on the next run. Alternatively, pass `--sync` (or `-s`) directly to `peppy node add` to run the sync as part of the add: `peppy node add . --sync`. Before doing that, let’s modify the source code first.
## Using the parameter
[Section titled “Using the parameter”](#using-the-parameter)
Now that we’ve synchronized our project, the new parameter can be used in the main file by modifying it like this:
* Python
src/hello\_world\_param/\_\_main\_\_.py
```python
import asyncio
from peppygen import NodeBuilder, NodeRunner
from peppygen.parameters import Parameters
from peppygen.emitted_topics import message_stream
async def emit_hello_world_loop(node_runner: NodeRunner, name: str):
counter = 0
while True:
counter += 1
message = f"hello {name} 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, params.name))]
def main():
NodeBuilder().run(setup)
if __name__ == "__main__":
main()
```
* Rust
src/main.rs
```rust
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, token: CancellationToken, name: String) {
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 {name} 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, args.name.clone()));
Ok(())
})
}
```
Next, add the node to the stack:
```sh
peppy node add .
```
Note
If a node in the stack shares the same name and tag and has no dependencies, running `peppy node add` again will overwrite it. This makes iterating during development straightforward.
To verify the node was added, inspect the stack:
```sh
peppy stack list
```
Note
If you open the node project in your IDE, you should see autocompletion for the generated interfaces. This is because PeppyOS transforms the `peppy.json` configuration into bindings for the targeted programming language.
# Sharing nodes
> Learn how to share your node or use other people's nodes
One of PeppyOS’s key features is the ability to share nodes with other people. As an example, we’ll try to pull the `uvc_camera` node from [this repo](https://github.com/Peppy-bot/nodes_hub.git) in a separate shell.
* Python
```sh
peppy node add --variant mock-python https://github.com/Peppy-bot/nodes_hub.git/uvc_camera
```
or
```sh
peppy node add --variant mock-python --ref main https://github.com/Peppy-bot/nodes_hub.git/uvc_camera
```
* Rust
```sh
peppy node add --variant mock-rust https://github.com/Peppy-bot/nodes_hub.git/uvc_camera
```
or
```sh
peppy node add --variant mock-rust --ref main https://github.com/Peppy-bot/nodes_hub.git/uvc_camera
```
Note
When `--ref` is used during `peppy node add`, it corresponds to the git tag/release or hash of the repo. On the other hand, the tag in `peppy node run uvc_camera:0.1.0` corresponds to the tag found in `peppy.json5`.
Note
[Variants](/advanced_guides/variants.mdx) (`--variant`) are a special kind of node, but for the moment you can imagine this is a regular node we’re dealing with
Now that this node is added, we can start it:
```sh
peppy node run uvc_camera:0.1.0 device_path=/dev/video0 video.camera_encoding="mjpeg" video.topic_encoding="rgb8" video.frame_rate=25 video.resolution.width=1280 video.resolution.height=720
```
and inspect its logs:
```sh
❯ tail /Users/tuatini/.peppy/logs/run/angry-ride-4256.log
[2026-01-25T16:38:51.860] [stdout] [uvc_camera] Video params: 1280x720 @ 25 fps, encoding: rgb8
[2026-01-25T16:38:51.860] [stdout] [uvc_camera] Starting video loop...
[2026-01-25T16:38:51.860] [stdout] [uvc_camera] Video file found: /Users/tuatini/.peppy/built_nodes/fake_uvc_camera_0.1.0_093fa9/assets/robot.mp4
[2026-01-25T16:38:51.860] [stdout] [uvc_camera] Opening video file for playback...
[2026-01-25T16:38:54.892] [stdout] [uvc_camera] Emitted frame 65
[2026-01-25T16:38:57.913] [stdout] [uvc_camera] Emitted frame 129
[2026-01-25T16:39:00.926] [stdout] [uvc_camera] Emitted frame 194
```
We see that video frames are emitted from that node. We can pull a new node that depends on `uvc_camera` to read those frames and reconstruct a short video. Pull another node from the same repository:
* Python
```sh
peppy node add https://github.com/Peppy-bot/example_nodes.git/python/fake_video_reconstruction
```
* Rust
```sh
peppy node add https://github.com/Peppy-bot/example_nodes.git/rust/fake_video_reconstruction
```
Start the node:
```sh
peppy node run fake_video_reconstruction:0.1.0 video_duration_seconds=5
```
If we look at the logs of the started `fake_video_reconstruction` node:
```text
[2026-01-26T15:35:25.170] [stdout] Recording 250 frames (5 seconds at 50 fps)...
[2026-01-26T15:35:27.408] [stdout] Recorded 50/250 frames (1 seconds)
[2026-01-26T15:35:29.526] [stdout] Recorded 100/250 frames (2 seconds)
[2026-01-26T15:35:31.811] [stdout] Recorded 150/250 frames (3 seconds)
[2026-01-26T15:35:34.106] [stdout] Recorded 200/250 frames (4 seconds)
[2026-01-26T15:35:36.423] [stdout] Recorded 250/250 frames (5 seconds)
[2026-01-26T15:35:36.423] [stdout] Recording complete. Encoding video...
[2026-01-26T15:35:37.222] [stdout] Video saved to: /var/folders/kb/36lp35_92z5_jg6gfqhvkm600000gn/T/.tmpCJoXVL/reconstructed_video.mp4
```
We can see the video has been saved to a temporary folder. Go ahead and open it—you’ll find the reconstructed video of the robot. In the same way, you can share your own nodes by hosting them in a Git repository and connect them to remote nodes. A node can also be pulled in the same way if it’s in a `.tar.zst` archive.
# Standalone nodes
> Learn how to debug a node before it's added to the node stack
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.
## Debugging a node
[Section titled “Debugging a node”](#debugging-a-node)
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:
* Python
```sh
peppy node init --toolchain uv standalone
```
* Rust
```sh
peppy node init --toolchain cargo standalone
```
2. Navigate into the directory:
```sh
cd standalone
```
with the following configuration:
* Python
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "standalone",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "python",
// A bunch of fake parameters required to start our node
parameters: {
device: {
physical: "string",
sim: "string",
priority: "string"
},
video: {
frame_rate: "u16",
resolution: {
width: "u16",
height: "u16",
},
encoding: "string",
},
},
build_cmd: [
"uv",
"sync"
],
run_cmd: [
"uv",
"run",
"standalone"
]
}
}
```
* Rust
peppy.json5
```json5
{
schema_version: 1,
manifest: {
name: "standalone",
tag: "0.1.0",
},
interfaces: {},
execution: {
language: "rust",
// A bunch of fake parameters required to start our node
parameters: {
device: {
physical: "string",
sim: "string",
priority: "string"
},
video: {
frame_rate: "u16",
resolution: {
width: "u16",
height: "u16",
},
encoding: "string",
},
},
build_cmd: [
"cargo",
"build",
"--release"
],
run_cmd: [
"./target/release/standalone"
]
}
}
```
Now sync the node:
```sh
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:
* Python
```sh
uv run standalone
```
You’ll run into the following error:
```sh
RuntimeError: missing required parameter(s) for standalone mode: device, video. Provide them via StandaloneConfig().with_parameters()
```
* Rust
```sh
cargo run
```
You’ll run into the following error:
```sh
Error: ParameterDeserialization(ParameterDeserializationError(["device", "video"]))
```
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
```json
{
"device": {
"physical": "/dev/video0",
"sim": "virtual_camera",
"priority": "high"
},
"video": {
"frame_rate": 30,
"resolution": {
"width": 1920,
"height": 1080
},
"encoding": "h264"
}
}
```
Then modify the source file to read from this file:
* Python
src/standalone/\_\_main\_\_.py
```python
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 Device, Video, VideoResolution
#
# params = Parameters(
# device=Device(
# physical="/dev/video0",
# sim="virtual_camera",
# priority="high",
# ),
# 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:
```sh
uv run standalone
```
* Rust
src/main.rs
```rust
use peppygen::{NodeBuilder, Parameters, Result};
use peppylib::runtime::StandaloneConfig;
fn main() -> Result<()> {
// Parameters can also be defined directly in code:
//
// use peppygen::parameters::{device::Device, video::{Video, VideoResolution}};
//
// let params = Parameters {
// device: Device {
// physical: "/dev/video0".to_string(),
// sim: "virtual_camera".to_string(),
// priority: "high".to_string(),
// },
// video: Video {
// frame_rate: 30,
// resolution: VideoResolution {
// width: 1920,
// height: 1080,
// },
// encoding: "h264".to_string(),
// },
// };
let json = std::fs::read_to_string("params.json")
.expect("failed to read params.json");
let params: Parameters = serde_json::from_str(&json)
.expect("failed to parse params.json");
let standalone_config = StandaloneConfig::new().with_parameters(¶ms);
NodeBuilder::new()
.standalone(standalone_config)
.run(|args: Parameters, node_runner| async {
println!("Inside the run closure!");
let _ = args;
let _ = node_runner;
Ok(())
})
}
```
Now if we run the following command again:
```sh
cargo run
```
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.
## Running a variant in standalone mode
[Section titled “Running a variant in standalone mode”](#running-a-variant-in-standalone-mode)
You can also run a [variant](/advanced_guides/variants) as a standalone program. Variants typically omit the `manifest` and `interfaces` sections of `peppy.json5` and inherit them from their parent root node. When you launch a variant directly with `cargo run` / `uv run`, peppy walks up the directory tree to find the nearest ancestor `peppy.json5` that declares a `manifest` and merges the two configs — the variant’s `execution` wins, the parent’s `manifest` and `interfaces` are adopted as-is.
This means you can sit inside a variant subdirectory (typically `variants//`) and debug exactly what the daemon would run when that variant is selected:
```plaintext
uvc_camera/
├── peppy.json5 ← root node (manifest + interfaces, no execution)
└── variants/
├── default/
│ └── peppy.json5 ← execution only
└── mock/
├── peppy.json5 ← execution only
├── params.json
└── src/
└── main.rs
```
```sh
cd uvc_camera/variants/mock
cargo run # or `uv run mock` for Python
```
When standalone mode initializes, peppy loads the variant’s `peppy.json5`, walks up to find the parent root, and produces a merged `NodeConfig` where the node is known as `uvc_camera:0.1.0` (inherited from the root manifest) with the variant’s own `execution` (including `parameters`). The standalone messenger then uses those merged interfaces to talk to the rest of the stack, just like a non-variant standalone node. Cargo / uv remain in charge of actually running the binary — `run_cmd` is only consulted by the daemon when the node is launched through `peppy node run`, not in standalone mode.
Caution
If there is no ancestor `peppy.json5` with a `manifest` section — for example if you’ve copied a variant directory out on its own — peppy will fail to start with an error explaining that a parent root was not found. In that case, either restore the parent `peppy.json5`, or convert the variant into a self-contained root by adding its own `manifest` and `interfaces`.
# Changelog
> Release notes and version history for PeppyOS
All notable changes to PeppyOS will be documented on this page.
Subscribe to the [Atom feed](/changelog.xml) for updates.
**[v0.7.0 (Alpha)](/releases/v0-7-0/)** — *Separate \`node add/start\` into \`node add/build/run\`*
## What's Changed
* Add `node build` step to workflow by [@godardt](https://github.com/godardt) in [#153](https://github.com/Peppy-bot/peppy/pull/153)
* Separate `node add` from `node build` commands by [@godardt](https://github.com/godardt) in [#155](https://github.com/Peppy-bot/peppy/pull/155)
* refactor: rename `start_cmd` to `run_cmd` by [@godardt](https://github.com/godardt) in [#156](https://github.com/Peppy-bot/peppy/pull/156)
* Fix standalone integration by [@godardt](https://github.com/godardt) in [#157](https://github.com/Peppy-bot/peppy/pull/157)
* Change `node info` to look up nodes by name:tag by [@godardt](https://github.com/godardt) in [#158](https://github.com/Peppy-bot/peppy/pull/158)
* Add `node` command shorthands by [@godardt](https://github.com/godardt) in [#159](https://github.com/Peppy-bot/peppy/pull/159)
* Release v0.7.0 by [@godardt](https://github.com/godardt) in [#160](https://github.com/Peppy-bot/peppy/pull/160)
**Full Changelog**: [`v0.6.2...v0.7.0`](https://github.com/Peppy-bot/peppy/compare/v0.6.2...v0.7.0)
**[v0.6.2 (Alpha)](/releases/v0-6-2/)** — *Add code and command optimizations*
## What's Changed
* Code cleanup & optimization by [@godardt](https://github.com/godardt) in [#149](https://github.com/Peppy-bot/peppy/pull/149)
* Node sync path by [@godardt](https://github.com/godardt) in [#150](https://github.com/Peppy-bot/peppy/pull/150)
* Core node name is now fixed across reboot/reinstallation by [@godardt](https://github.com/godardt) in [#151](https://github.com/Peppy-bot/peppy/pull/151)
* Release v0.6.2 by [@godardt](https://github.com/godardt) in [#152](https://github.com/Peppy-bot/peppy/pull/152)
**Full Changelog**: [`v0.6.1...v0.6.2`](https://github.com/Peppy-bot/peppy/compare/v0.6.1...v0.6.2)
**[v0.6.1 (Alpha)](/releases/v0-6-1/)** — *Add support for arrays of objects in message format schemas*
## What's Changed
* Support arrays of objects in message format schemas by [@godardt](https://github.com/godardt) in [#147](https://github.com/Peppy-bot/peppy/pull/147)
* Release v0.6.1 by [@godardt](https://github.com/godardt) in [#148](https://github.com/Peppy-bot/peppy/pull/148)
**Full Changelog**: [`v0.6.0...v0.6.1`](https://github.com/Peppy-bot/peppy/compare/v0.6.0...v0.6.1)
**[v0.6.0 (Alpha)](/releases/v0-6-0/)** — *Add support for node variants*
## What's Changed
* Create a dedicated `runtime` section to hold `language`, `container`, `parameters` and `add_cmd`/`start_cmd` by [@godardt](https://github.com/godardt) in [#136](https://github.com/Peppy-bot/peppy/pull/136)
* Node variants by [@godardt](https://github.com/godardt) in [#137](https://github.com/Peppy-bot/peppy/pull/137)
* Docker base images by [@godardt](https://github.com/godardt) in [#143](https://github.com/Peppy-bot/peppy/pull/143)
* Fix variants by [@godardt](https://github.com/godardt) in [#142](https://github.com/Peppy-bot/peppy/pull/142)
* Add more meaningful output to the `node add` operation by [@godardt](https://github.com/godardt) in [#144](https://github.com/Peppy-bot/peppy/pull/144)
* Add variant launchers support by [@godardt](https://github.com/godardt) in [#145](https://github.com/Peppy-bot/peppy/pull/145)
* Release v0.6.0 by [@godardt](https://github.com/godardt) in [#146](https://github.com/Peppy-bot/peppy/pull/146)
**Full Changelog**: [`v0.5.10...v0.6.0`](https://github.com/Peppy-bot/peppy/compare/v0.5.10...v0.6.0)
**[v0.5.10 (Alpha)](/releases/v0-5-10/)** — *Add support for installation in containers*
## What's Changed
* Install in container by [@godardt](https://github.com/godardt) in [#141](https://github.com/Peppy-bot/peppy/pull/141)
**Full Changelog**: [`v0.5.9...v0.5.10`](https://github.com/Peppy-bot/peppy/compare/v0.5.9...v0.5.10)
**[v0.5.9 (Alpha)](/releases/v0-5-9/)** — *Remove Apptainer setuid*
## What's Changed
* Fix: Apptainer suid removal by [@godardt](https://github.com/godardt) in [#139](https://github.com/Peppy-bot/peppy/pull/139)
**Full Changelog**: [`v0.5.8...v0.5.9`](https://github.com/Peppy-bot/peppy/compare/v0.5.8...v0.5.9)
**[v0.5.8 (Alpha)](/releases/v0-5-8/)** — *Fix architecture mismatch in Apptainer binary*
## What's Changed
* Fix compilation of dependencies for correct architectures by [@godardt](https://github.com/godardt) in [#138](https://github.com/Peppy-bot/peppy/pull/138)
**Full Changelog**: [`v0.5.7...v0.5.8`](https://github.com/Peppy-bot/peppy/compare/v0.5.7...v0.5.8)
**[v0.5.7 (Alpha)](/releases/v0-5-7/)** — *Official support for more Linux distros*
## What's Changed
* Officially support more Linux distros by [@godardt](https://github.com/godardt) in [#134](https://github.com/Peppy-bot/peppy/pull/134)
* v0.5.7 by [@godardt](https://github.com/godardt) in [#135](https://github.com/Peppy-bot/peppy/pull/135)
**Full Changelog**: [`v0.5.6...v0.5.7`](https://github.com/Peppy-bot/peppy/compare/v0.5.6...v0.5.7)
**[v0.5.6 (Alpha)](/releases/v0-5-6/)** — *Add support for mounted devices in containers*
## What's Changed
* refactor: simplify PR 128 by [@claude](https://github.com/claude)\[bot] in [#131](https://github.com/Peppy-bot/peppy/pull/131)
* Add extra args support for apptainer build/run and lima shell by [@godardt](https://github.com/godardt) in [#128](https://github.com/Peppy-bot/peppy/pull/128)
* Add documentation for llms by [@godardt](https://github.com/godardt) in [#132](https://github.com/Peppy-bot/peppy/pull/132)
* v0.5.6 by [@godardt](https://github.com/godardt) in [#133](https://github.com/Peppy-bot/peppy/pull/133)
**Full Changelog**: [`v0.5.5...v0.5.6`](https://github.com/Peppy-bot/peppy/compare/v0.5.5...v0.5.6)
**[v0.5.5 (Alpha)](/releases/v0-5-5/)** — *Fix issues with installation script on some systems*
## What's Changed
* Improve install scripts by [@godardt](https://github.com/godardt) in [#126](https://github.com/Peppy-bot/peppy/pull/126)
* Containers mounts with runtime vars by [@godardt](https://github.com/godardt) in [#125](https://github.com/Peppy-bot/peppy/pull/125)
* fix: switch container images to standard registries by [@godardt](https://github.com/godardt) in [#129](https://github.com/Peppy-bot/peppy/pull/129)
* v0.5.5 by [@godardt](https://github.com/godardt) in [#130](https://github.com/Peppy-bot/peppy/pull/130)
**Full Changelog**: [`v0.5.4...v0.5.5`](https://github.com/Peppy-bot/peppy/compare/v0.5.4...v0.5.5)
**[v0.5.0 (Alpha)](/releases/v0-5-0/)** — *Bidirectional communication support*
## What's Changed
* Rework peppy structure by [@godardt](https://github.com/godardt) in [#102](https://github.com/Peppy-bot/peppy/pull/102)
* Separate DAG from communication by [@godardt](https://github.com/godardt) in [#104](https://github.com/Peppy-bot/peppy/pull/104)
* Fix/actions revamp by [@godardt](https://github.com/godardt) in [#106](https://github.com/Peppy-bot/peppy/pull/106)
* Add bidirectional communication by [@godardt](https://github.com/godardt) in [#107](https://github.com/Peppy-bot/peppy/pull/107)
* Fix node add with existing instances by [@godardt](https://github.com/godardt) in [#109](https://github.com/Peppy-bot/peppy/pull/109)
* Improvement launch files by [@godardt](https://github.com/godardt) in [#112](https://github.com/Peppy-bot/peppy/pull/112)
* Fix yanked file by [@godardt](https://github.com/godardt) in [#114](https://github.com/Peppy-bot/peppy/pull/114)
* Optimize modules by [@godardt](https://github.com/godardt) in [#113](https://github.com/Peppy-bot/peppy/pull/113)
* Release 0.5.0 by [@godardt](https://github.com/godardt) in [#115](https://github.com/Peppy-bot/peppy/pull/115)
**Full Changelog**: [`v0.4.0...v0.5.0`](https://github.com/Peppy-bot/peppy/compare/v0.4.0...v0.5.0)
**[v0.5.1 (Alpha)](/releases/v0-5-1/)** — *Add more explanatory logs for add\_cmd and start\_cmd failures*
## What's Changed
* Include command name in spawn and execution failure error messages by [@godardt](https://github.com/godardt) in [#116](https://github.com/Peppy-bot/peppy/pull/116)
* Add more explanatory logs for add\_cmd and start\_cmd failures by [@godardt](https://github.com/godardt) in [#117](https://github.com/Peppy-bot/peppy/pull/117)
**Full Changelog**: [`v0.5.0...v0.5.1`](https://github.com/Peppy-bot/peppy/compare/v0.5.0...v0.5.1)
**[v0.5.2 (Alpha)](/releases/v0-5-2/)** — *Fix daemon installation in Linux*
## What's Changed
* Fix daemon installation in Linux by [@godardt](https://github.com/godardt) in [#119](https://github.com/Peppy-bot/peppy/pull/119)
* Release v0.5.2 by [@godardt](https://github.com/godardt) in [#120](https://github.com/Peppy-bot/peppy/pull/120)
**Full Changelog**: [`v0.5.1...v0.5.2`](https://github.com/Peppy-bot/peppy/compare/v0.5.1...v0.5.2)
**[v0.5.3 (Alpha)](/releases/v0-5-3/)** — *Fix Linux .so Python lib not available for x86\_64 systems*
## What's Changed
* Fix Linux .so Python lib not available for x86\_64 systems by [@godardt](https://github.com/godardt) in [#121](https://github.com/Peppy-bot/peppy/pull/121)
* Release v0.5.3 by [@godardt](https://github.com/godardt) in [#122](https://github.com/Peppy-bot/peppy/pull/122)
**Full Changelog**: [`v0.5.2...v0.5.3`](https://github.com/Peppy-bot/peppy/compare/v0.5.2...v0.5.3)
**[v0.5.4 (Alpha)](/releases/v0-5-4/)** — *Add various fixes to the install script*
## What's Changed
* Various fixes to the install script by [@godardt](https://github.com/godardt) in [#123](https://github.com/Peppy-bot/peppy/pull/123)
* Add various fixes to the install script by [@godardt](https://github.com/godardt) in [#124](https://github.com/Peppy-bot/peppy/pull/124)
**Full Changelog**: [`v0.5.3...v0.5.4`](https://github.com/Peppy-bot/peppy/compare/v0.5.3...v0.5.4)
**[v0.4.0 (Alpha)](/releases/v0-4-0/)** — *Containers support*
## What's Changed
* Feature/fix cross compilation by [@godardt](https://github.com/godardt) in [#90](https://github.com/Peppy-bot/peppy/pull/90)
* Add fakeroot pre-flight check and service stop/uninstall commands by [@godardt](https://github.com/godardt) in [#91](https://github.com/Peppy-bot/peppy/pull/91)
* Final implementation for containers by [@godardt](https://github.com/godardt) in [#88](https://github.com/Peppy-bot/peppy/pull/88)
* fix: auto-create host-side bind mount source directories by [@godardt](https://github.com/godardt) in [#92](https://github.com/Peppy-bot/peppy/pull/92)
* Replace fixed timeouts with idle + max timeout model for node by [@godardt](https://github.com/godardt) in [#93](https://github.com/Peppy-bot/peppy/pull/93)
* Fix python libs by [@godardt](https://github.com/godardt) in [#95](https://github.com/Peppy-bot/peppy/pull/95)
* Optimize codegen by [@godardt](https://github.com/godardt) in [#96](https://github.com/Peppy-bot/peppy/pull/96)
* Fix containers warnings by [@godardt](https://github.com/godardt) in [#97](https://github.com/Peppy-bot/peppy/pull/97)
* fix: move DEBIAN\_FRONTEND export to %post section in apptainer templates by [@godardt](https://github.com/godardt) in [#98](https://github.com/Peppy-bot/peppy/pull/98)
* rename: daemon-node crate and related identifiers renamed to core-node by [@godardt](https://github.com/godardt) in [#99](https://github.com/Peppy-bot/peppy/pull/99)
* Add Lima VM cross-compilation for multi-target releases by [@godardt](https://github.com/godardt) in [#100](https://github.com/Peppy-bot/peppy/pull/100)
* Release v0.4.0 by [@godardt](https://github.com/godardt) in [#101](https://github.com/Peppy-bot/peppy/pull/101)
**Full Changelog**:
**[v0.3.6 (Alpha)](/releases/v0-3-6/)** — *Optimize PeppyOS internal behavior*
* sccache support
* Optimize `node add` command
* Update Python node template to use direct venv execution
* Add external JSON parameter loading for standalone nodes in Python and Rust
**[v0.3.5 (Alpha)](/releases/v0-3-5/)** — *Trim Rust nodes size*
**[v0.3.4 (Alpha)](/releases/v0-3-4/)** — *Optimize crates boundary crossing with Rust nodes*
**[v0.3.0 (Alpha)](/releases/v0-3-0/)** — *Python support*
* Add python support
* Rust codegen refactor
* Rename master-node to daemon-node
* Remove extra deps in nodes
**[v0.3.1 (Alpha)](/releases/v0-3-1/)** — *Add Python support with macOS (aarch64) and Linux (x86\_64/aarch64) support*
* Add python support
* Rust codegen refactor
* Rename master-node to daemon-node
* Remove extra deps in nodes
**[v0.3.2 (Alpha)](/releases/v0-3-2/)** — *Fix missing binaries for Python*
**[v0.3.3 (Alpha)](/releases/v0-3-3/)** — *Support dataclass instances in with\_parameters method in Python*
**[v0.2.17 (Alpha)](/releases/v0-2-17/)** — *Fix for names-generator*
**[v0.2.18 (Alpha)](/releases/v0-2-18/)** — *Update all dependencies*
**[v0.2.15 (Alpha)](/releases/v0-2-15/)** — *Add interfaces integrity*
**[v0.2.13 (Alpha)](/releases/v0-2-13/)** — *Add dependency check to add command*
**[v0.2.14 (Alpha)](/releases/v0-2-14/)** — *Add user defined timeouts to add/start and launch cmd*
**[v0.2.12 (Alpha)](/releases/v0-2-12/)** — *Fix add\_cmd and start\_cmd user vars*
Fixed user variables not being properly applied in `add_cmd` and `start_cmd` operations.
**[v0.2.11 (Alpha)](/releases/v0-2-11/)** — *Update docs & add optimizations*
Implement internal code optimizations
**[v0.2.10 (Alpha)](/releases/v0-2-10/)** — *Initial alpha release of PeppyOS*
##### Features
* Core node system with Rust support
* Topic-based communication between nodes
* Service and action patterns
* Parameter system for node configuration
* Launch files for multi-node orchestration
* Node stack management
* Standalone node execution mode
* CLI tools for project management
# Concepts
> The different concepts of PeppyOS
## Node
[Section titled “Node”](#node)
A **Node** is the fundamental unit of computation in PeppyOS. It represents a runnable application or service that can expose interfaces (topics, services, actions) and consume interfaces from other nodes.
A node is defined by its **configuration** file (`peppy.json5`) which includes:
* **Manifest**: The node’s identity
* **Build**: Commands to build and launch the node
* **Parameters**: Configuration values passed to the node
* **Interfaces**: What the node exposes and consumes
### Manifest
[Section titled “Manifest”](#manifest)
The manifest defines a node’s identity:
| Field | Description |
| ------------ | --------------------------------------------------------- |
| `name` | A validated identifier (ASCII letters, digits, `_`, `-`) |
| `tag` | A version or variant identifier (e.g., `donut`, `v1.0.0`) |
| `labels` | Optional metadata labels |
| `variants` | Optional list of alternative node implementations |
| `depends_on` | Optional dependency declarations on other nodes |
A node is uniquely identified by its `name:tag` combination.
### Execution
[Section titled “Execution”](#execution)
The execution section defines how to prepare and launch the node:
| Field | Description |
| ------------ | -------------------------------------------------------------------------------- |
| `language` | The programming language of the node (`rust`, `python`) |
| `parameters` | Optional parameter schema for runtime configuration |
| `build_cmd` | Command to run during the node build phase |
| `run_cmd` | Command to launch the node |
| `container` | Optional container configuration (mutually exclusive with `build_cmd`/`run_cmd`) |
### Interfaces
[Section titled “Interfaces”](#interfaces)
Nodes communicate through three types of interfaces:
| Type | Description |
| ----------- | --------------------------------------------------------- |
| **Topic** | Publish/subscribe messaging for streaming data |
| **Service** | Request/response communication for synchronous calls |
| **Action** | Long-running tasks with feedback and cancellation support |
A node can **expose** interfaces (make them available to others) and **consume** interfaces from other nodes.
## Node Instance
[Section titled “Node Instance”](#node-instance)
A **Node Instance** represents a single running execution of a node. Each instance has:
* **Instance ID**: A unique identifier for this running process
* **PID**: The process ID (when running locally)
A node can have multiple instances running simultaneously. For example, you might run multiple instances of a camera node, each connected to separate physical devices.
Note
For nodes running on remote systems (e.g., embedded devices), the PID may not be available.
## Node Stack
[Section titled “Node Stack”](#node-stack)
The **Node Stack** is the central data structure that manages all active nodes in the system. It maintains a directed acyclic graph (DAG) where:
* **Vertices** are node entities
* **Edges** represent dependency relationships, pointing from a dependent node to its dependency. These edges are derived from interface connections between nodes (exposers and consumers).
### Key Responsibilities
[Section titled “Key Responsibilities”](#key-responsibilities)
1. **Dependency Management**: Tracks which nodes depend on which other nodes
2. **Interface Validation**: Ensures nodes expose the interfaces their dependents require
3. **Instance Lifecycle**: Manages the creation and removal of node instances
4. **Root Node**: Always contains a root node (the core node) that cannot be removed
### How Dependencies Work
[Section titled “How Dependencies Work”](#how-dependencies-work)
1. Validates that all required dependencies exist
2. Checks that dependencies expose the required interfaces
3. Tracks pending requirements when dependencies are not yet available
4. Resolves pending requirements when dependencies are added
### Visualization
[Section titled “Visualization”](#visualization)
The node stack can be visualized in multiple formats:
* **DOT format**: For Graphviz visualization of the dependency graph
* **Serialized graph**: JSON representation for programmatic access
## The Core Node
[Section titled “The Core Node”](#the-core-node)
The **Core Node** is a special node that serves as the root of the node stack and is always present. It is responsible for:
* **Dependency Creation**: When a node consumes an interface, the core node creates a dependency on the node that exposes that interface
* **Stack Management**: Managing the lifecycle of all other nodes in the system
* **System Coordination**: Acting as the central coordinator for the local PeppyOS runtime
Each PeppyOS runtime has exactly one core node that manages its local DAG of nodes. In a distributed deployment, multiple runtimes — each with their own core node — can run on separate machines. The core nodes operate independently, managing their local node stacks without a centralized controller. Nodes communicate across runtimes through the shared messaging layer, enabling a fully decentralized system where each core node is responsible only for its own set of nodes.
## Summary
[Section titled “Summary”](#summary)
```plaintext
Node Stack
├── Core Node (always present)
│ └── Instance (always a single instance)
│
└── Other Nodes
├── Node A
│ ├── Configuration
│ └── Instances (1 or more)
│
└── Node B
├── Configuration
└── Instances (1 or more)
```
# FAQ
> Frequently asked questions about PeppyOS
Will PeppyOS be open source?
Yes! PeppyOS will be fully open source under a BSL license before the end of this year. Once the software is mature, everyone will be able to contribute and participate in its development.
What languages will PeppyOS support?
Since PeppyOS is built in Rust, Rust will be the first supported language, followed by Python and C.
Will PeppyOS support embedded/`no_std` nodes?
Yes! While not available yet, embedded support is on the roadmap. The goal is to enable nodes running on microcontrollers like the ESP32 to be fully integrated with PeppyOS.
What tech are you using under the hoods?
PeppyOS is written in [Rust](https://rust-lang.org/) and uses [Zenoh](https://zenoh.io/) for node communication.
Can PeppyOS instances on different machines communicate with each other?
Yes! PeppyOS is designed to be highly modular. You’ll be able to connect core nodes from different locations into a unified network, where each core node manages a single robot and its components. This feature is on the roadmap for an upcoming release.
Will PeppyOS maintain backward compatibility?
Not until version 1.0. Since PeppyOS is still in alpha/beta, maintaining backward compatibility would introduce additional complexity and divert effort away from core development.
What features are on the roadmap?
Here are the upcoming priorities (in no particular order):
1. Python support
2. Multi-node networking for core node communication
3. Simulation environment integration, starting with NVIDIA Isaac Sim, followed by Mujoco/Genesis
4. Dataset recording with LeRobot format support
5. Action replay via “PeppyBag” for recorded robot actions
6. Embedded chip support, starting with ESP32
7. Full [OpenArm](https://openarm.dev/) humanoid support, enabling users to connect the robot or launch a simulation and start working within an hour. PeppyOS serves as the abstraction layer, allowing seamless switching between real hardware and simulation.
Any plans to make PeppyOS compatible with Windows?
Windows support is not currently planned. Our focus remains on improving PeppyOS core functionality, with official support limited to Linux (x86/ARM) and macOS (Apple Silicon).
How will you make money?
PeppyOS will always be free, including for commercial use — our goal is to make it as widely accessible as possible. We are currently building a SaaS platform that will provide centralized monitoring and management of nodes through a web dashboard. This is why PeppyOS is not yet open source. The SaaS will be entirely optional and include a free tier for hobbyists. Paid plans will only become relevant when scaling to hundreds or thousands of nodes, which typically applies to business use cases.
Once the SaaS is ready, PeppyOS will become fully open source under a BSL 1.0 license, which only restricts creating a competing hosted service from the source code for a limited period.
Is there an LLM-friendly version of the documentation?
Yes! LLM-optimized versions of the documentation are available at [`/llms.txt`](/llms.txt) (index with links) and [`/llms-full.txt`](/llms-full.txt) (full content). Those are always in sync with the latest version of the documentation.
# Message Format
> Reference for all message format field types used in topics, services, and actions
The `message_format` defines the structure of messages exchanged between nodes through [topics](/advanced_guides/topics/), [services](/advanced_guides/services/), and [actions](/advanced_guides/actions/). It is a map of field names to schema types, declared inline in `peppy.json5`.
```json5
message_format: {
temperature: "f32",
label: "string",
}
```
Each field value is a **schema type** — either a bare type token, or a structured schema with modifiers.
***
## Primitive types
[Section titled “Primitive types”](#primitive-types)
A bare type string is the simplest form. These map directly to language-native types.
| Type | Alias | Rust type | Python type |
| ---------- | ---------- | ----------------------- | ----------- |
| `"bool"` | | `bool` | `bool` |
| `"u8"` | | `u8` | `int` |
| `"u16"` | | `u16` | `int` |
| `"u32"` | | `u32` | `int` |
| `"u64"` | | `u64` | `int` |
| `"i8"` | | `i8` | `int` |
| `"i16"` | | `i16` | `int` |
| `"i32"` | | `i32` | `int` |
| `"i64"` | | `i64` | `int` |
| `"f32"` | `"float"` | `f32` | `float` |
| `"f64"` | `"double"` | `f64` | `float` |
| `"string"` | `"str"` | `String` | `str` |
| `"bytes"` | | `Vec` | `bytes` |
| `"time"` | | `std::time::SystemTime` | `float` |
Aliases can be used interchangeably with their canonical name (e.g. `"float"` is equivalent to `"f32"`).
***
## Optional modifier
[Section titled “Optional modifier”](#optional-modifier)
Any primitive type can be made optional by using the structured form with `$optional`:
```json5
error_msg: {
$type: "string",
$optional: true
}
```
| Field | Required | Description |
| ----------- | -------- | --------------------------------------------------------- |
| `$type` | Yes | Any primitive type token |
| `$optional` | No | When `true`, the field may be absent. Defaults to `false` |
Note
`$optional` can only be used on **root-level fields** of a `message_format` — direct children, not fields nested inside objects or array items. If a parent structure is present, all its fields must be present too.
In practice, this is used on **service and action response fields** where a value may or may not be present depending on the outcome — for example, an `error_msg` that is only set when the operation fails.
***
## Object
[Section titled “Object”](#object)
An object groups related fields into a nested structure. It generates a nested struct in Rust and a dataclass in Python.
```json5
header: {
stamp: "time",
frame_id: "u32"
}
```
| Field | Required | Description |
| ------------ | -------- | -------------------------------------------------------------------------------------------- |
| `$type` | No | Only valid value is `"object"`. Can be omitted since the parser infers it from the structure |
| `$optional` | No | When `true`, the entire object may be absent |
| *other keys* | No | Each additional key is a field with its own schema type |
Object fields can be any schema type, including arrays and nested objects:
```json5
sensor_reading: {
$type: "object",
header: {
$type: "object",
stamp: "time",
frame_id: "u32"
},
samples: {
$type: "array",
$items: "f32"
}
}
```
***
## Array
[Section titled “Array”](#array)
An array represents a list of items of the same type.
```json5
distances: {
$type: "array",
$items: "f32"
}
```
| Field | Required | Description |
| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `$type` | Yes | Must be `"array"` |
| `$items` | Yes | A primitive type or an object schema. Nested arrays (arrays of arrays) are not supported |
| `$length` | No | Fixed number of elements. Only supported when `$items` is a numeric or boolean primitive — not supported for `string`, `time`, or object items. Omit for variable-length |
| `$optional` | No | When `true`, the entire array may be absent |
### Fixed-length array
[Section titled “Fixed-length array”](#fixed-length-array)
When `$length` is provided, the array has an exact number of elements:
```json5
position: {
$type: "array",
$items: "f32",
$length: 3
}
```
This maps to `[f32; 3]` in Rust and `list[float]` in Python.
### Variable-length array
[Section titled “Variable-length array”](#variable-length-array)
Without `$length`, the array can contain any number of elements:
```json5
frame: {
$type: "array",
$items: "u8"
}
```
This maps to `Vec` in Rust and `bytes` in Python (for `u8` items) or `list[T]` for other types.
### Array of objects
[Section titled “Array of objects”](#array-of-objects)
`$items` can be an object, allowing arrays of structured records:
```json5
frames: {
$type: "array",
$items: {
$type: "object",
name: "string",
parent: "string",
position: {
$type: "array",
$items: "i32",
$length: 3
},
orientation: {
$type: "array",
$items: "i32",
$length: 4
}
}
}
```
This accepts messages like:
```json
{
"frames": [
{ "name": "link1", "parent": "world", "position": [0, 0, 1], "orientation": [1, 0, 0, 0] },
{ "name": "link2", "parent": "link1", "position": [0, 1, 0], "orientation": [1, 0, 0, 0] }
]
}
```
***
## Nesting rules
[Section titled “Nesting rules”](#nesting-rules)
Schema types can be nested arbitrarily:
* **Object fields** can be primitives, arrays, or other objects
* **Array items** can be primitives, objects, or other arrays
This enables complex hierarchical message structures. For example, a robotics transform tree:
```json5
message_format: {
timestamp: "time",
root_frame: "string",
transforms: {
$type: "array",
$items: {
$type: "object",
name: "string",
parent: "string",
translation: { $type: "array", $items: "f64", $length: 3 },
rotation: { $type: "array", $items: "f64", $length: 4 }
}
}
}
```
***
## Complete example
[Section titled “Complete example”](#complete-example)
A full `peppy.json5` topic using multiple schema types:
```json5
{
schema_version: 1,
manifest: {
name: "arm_controller",
tag: "0.1.0"
},
execution: {
language: "rust",
run_cmd: ["./target/release/uvc_camera"],
},
interfaces: {
topics: {
emits: [
{
name: "arm_state",
qos_profile: "sensor_data",
message_format: {
timestamp: "time",
joint_positions: { $type: "array", $items: "f64", $length: 6 },
joint_velocities: { $type: "array", $items: "f64", $length: 6 },
end_effector: {
$type: "object",
position: { $type: "array", $items: "f64", $length: 3 },
orientation: { $type: "array", $items: "f64", $length: 4 },
gripper_open: "bool"
}
}
}
]
}
}
}
```