This is the full developer documentation for Peppy # Peppy Guide > A guide to start using Peppy This guide covers the installation of Peppy and introduces you to creating nodes so you can understand how the system operates. Peppy 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. (For a *continuous* bidirectional exchange with no defined end, an inner control loop rather than a discrete job, use a [pairing](/advanced_guides/pairing/#pairing-vs-actions) instead.) For the full map of mechanisms, see [Choosing a communication pattern](/advanced_guides/communication_patterns/). A node can drive **multiple goals concurrently** for the same action. Each accepted goal yields its own `GoalContext` (owning that goal’s feedback stream, cancel signal, and result), so one server can route by a discriminator in the request (e.g. `arm_id`) and drive several independent resources in parallel. The framework routes each client’s cancel and result requests to the right goal by `goal_id`; deciding whether to accept a second concurrent goal is up to your goal handler. ## 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`: * Rust ```json5 { peppy_schema: "node/v1", manifest: { name: "brain", tag: "v1", }, 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"] }, } ``` * Python ```json5 { peppy_schema: "node/v1", manifest: { name: "brain", tag: "v1", }, 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: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "brain"] }, } ``` Every payload on `goal_service` and `result_service` is optional: any of `goal_service.request_message_format`, `goal_service.response_message_format`, `result_service.request_message_format`, and `result_service.response_message_format` can be omitted (or set to `{}`) when the corresponding payload is empty. For example, a `calibrate` action can omit `goal_service.request_message_format` because it has no goal parameters, and a `result_service` that only needs to signal completion can omit `request_message_format`. The `feedback_topic` block is optional: actions that don’t stream progress can omit it entirely. When you do declare `feedback_topic`, however, its `message_format` is required and cannot be empty: every feedback message must carry a non-empty payload, since empty payloads are reserved as the framework’s per-goal end-of-stream signal (see [Receiving feedback](#receiving-feedback)). Code generation will fail if `feedback_topic` is declared without a `message_format`. ### 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 loop accepting goals. `handle_goal_next_request` returns the next *accepted* goal as a `GoalContext`; rejected goals are answered and skipped for you, so the accept loop ends only when the goal stream closes (the node is shutting down). Spawn a worker per context so goals run concurrently; each context owns that goal’s feedback, cancel signal, and result, so nothing crosses between goals: * Rust ```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?; // Spawn the accept loop so this setup closure returns and the node // starts serving. The loop runs for the life of the node. tokio::spawn(async move { // Each call returns the next accepted goal; rejected goals are // answered and skipped, so the loop ends only when the stream // closes (None) or errors. while let Ok(Some(ctx)) = action .handle_goal_next_request(|request| -> Result { println!( "goal from {}: arm_id={} desired={:?}", request.instance_id, request.data.arm_id, request.data.desired_position ); // The decider sets the concurrency policy: e.g. reject a // goal for a busy `arm_id`. Ok(move_arm::GoalResponse::accept()) }) .await { // Drive this goal concurrently with any others already running. tokio::spawn(async move { // Feedback goes through this goal's context, not a shared slot. ctx.publish_feedback([7, 31, 43]).await.ok(); // Deliver the result for this specific goal; the client's // get_result(handle) is routed back here by goal_id. ctx.complete(true, None, [98, 4, 26]).await.ok(); }); } }); Ok(()) }) } ``` * Python ```python import asyncio from peppygen import NodeBuilder from peppygen.exposed_actions import move_arm # Drive one accepted goal concurrently with any others already running. The # context owns this goal's feedback, cancel signal, and result. async def drive(ctx): # Feedback goes through this goal's context, not a shared slot. await ctx.publish_feedback([7, 31, 43]) # Deliver the result for this specific goal; the client's # get_result(handle) is routed back here by goal_id. await ctx.complete(True, None, [98, 4, 26]) async def run_action(node_runner): action = await move_arm.ActionHandle.expose(node_runner) # The decider sets the concurrency policy: e.g. reject a goal for a busy # arm_id. Rejected goals are answered and skipped internally. def decide(request): print( f"goal from {request.instance_id}: arm_id={request.data.arm_id} " f"desired={request.data.desired_position}", flush=True, ) return move_arm.GoalResponse.accept() while True: ctx = await action.handle_goal_next_request(decide) if ctx is None: break # goal stream closed (node shutting down) asyncio.create_task(drive(ctx)) async def setup(parameters, node_runner): # Spawn the accept loop so setup returns and the node starts serving. return [asyncio.create_task(run_action(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` The decider returns a `GoalResponse`, the framework acknowledgement (`accepted` plus an optional rejection reason): * `GoalResponse::accept()` (`GoalResponse.accept()` in Python) admits the goal, replies to the client, and yields a `GoalContext`. * `GoalResponse::reject(reason)` (`GoalResponse.reject(reason)` in Python) declines it: the client receives the response with `accepted == false` and the reason in `error_message`, no context is created, and the accept loop transparently moves on to the next goal. This is where you enforce per-resource concurrency limits. The `GoalRequest` passed to the decider contains: * `instance_id`: the client instance that sent the goal. The producer is binding-agnostic; it doesn’t know which slot on the client this goal is heading to. * `core_node`: the core node of the caller. * `data`: the deserialized goal parameters (only present when `request_message_format` is defined). The `GoalContext` (`ctx`) is the only handle you need to drive the goal: * `ctx.request()`: the decoded `GoalRequest`. * `ctx.goal_id()`: this goal’s correlation id. * `ctx.publish_feedback(...)`: publish a feedback message on this goal’s stream. * `ctx.cancel_signal()` / `ctx.is_cancelled()`: observe cancellation (see below). * `ctx.complete(...)` / `ctx.complete_cancelled(...)`: deliver the final result. A producer exposes its action once and serves any client that fires a goal at it. Starting the producer before or after the client is equally valid. To enforce a concurrency limit (say, one in-flight goal per arm), reject a goal whose arm is already busy, and release the arm when the goal finishes. The decider and the per-goal workers share the busy set: * Rust ```rust use std::collections::HashSet; use std::sync::{Arc, Mutex}; // Arms currently driving a goal. Shared by the decider and the workers. let busy: Arc>> = Arc::new(Mutex::new(HashSet::new())); while let Ok(Some(ctx)) = action .handle_goal_next_request({ let busy = Arc::clone(&busy); move |request| -> Result { // `HashSet::insert` returns false when the arm is already busy. if busy.lock().unwrap().insert(request.data.arm_id) { Ok(move_arm::GoalResponse::accept()) } else { // The reason rides back to the client in `error_message`. Ok(move_arm::GoalResponse::reject(format!( "arm {} is already moving", request.data.arm_id ))) } } }) .await { let busy = Arc::clone(&busy); tokio::spawn(async move { let arm_id = ctx.request().data.arm_id; ctx.complete(true, None, [98, 4, 26]).await.ok(); // Release the arm so future goals for it are accepted again. busy.lock().unwrap().remove(&arm_id); }); } ``` * Python ```python # Arms currently driving a goal. Shared by the decider and the workers; # asyncio is single-threaded, so a plain set is safe here. busy: set[int] = set() async def drive(ctx): arm_id = ctx.request().data.arm_id await ctx.complete(True, None, [98, 4, 26]) # Release the arm so future goals for it are accepted again. busy.discard(arm_id) def decide(request): arm_id = request.data.arm_id if arm_id in busy: # This arm is already moving; reject with a reason the caller can read. return move_arm.GoalResponse.reject(f"arm {arm_id} is already moving") busy.add(arm_id) return move_arm.GoalResponse.accept() while True: ctx = await action.handle_goal_next_request(decide) if ctx is None: break asyncio.create_task(drive(ctx)) ``` ### Handling cancellation [Section titled “Handling cancellation”](#handling-cancellation) A worker reacts to a cancel for **its** goal via `ctx.cancel_signal()`, which resolves when a cancel request arrives for that `goal_id`. Pair it with the goal’s work and report the outcome with `complete_cancelled`: * Rust ```rust tokio::spawn(async move { tokio::select! { outcome = run_arm(ctx.request().data.arm_id) => { for position in outcome.steps { ctx.publish_feedback(position).await.ok(); } ctx.complete(true, None, outcome.final_position).await.ok(); } _ = ctx.cancel_signal() => { // A cancel arrived for this goal; wind down and report it. ctx.complete_cancelled(false, Some("cancelled".to_owned()), last_known_position) .await .ok(); } } }); ``` * Python ```python async def drive(ctx): cancel_task = asyncio.ensure_future(ctx.cancel_signal()) work_task = asyncio.ensure_future(run_arm(ctx.request().data.arm_id)) done, pending = await asyncio.wait( [cancel_task, work_task], return_when=asyncio.FIRST_COMPLETED ) for task in pending: task.cancel() if cancel_task in done: # A cancel arrived for this goal; wind down and report it. await ctx.complete_cancelled(False, "cancelled", last_known_position) else: outcome = work_task.result() for position in outcome.steps: await ctx.publish_feedback(position) await ctx.complete(True, None, outcome.final_position) ``` Cancellation is **auto-acknowledged** by the framework: the client’s `cancel_goal` returns a typed `CancelState`: `Signalled` when a goal with that `goal_id` is in flight (the signal was delivered), `AlreadyTerminal` when it had already finished, or `Unknown`. `Signalled` means *delivered*, not *will stop*; a worker is free to ignore the signal and keep running. The worker decides the goal’s fate: calling `complete_cancelled` (or `complete`) is what a subsequent `get_result` returns. Whichever completion runs first wins; the framework closes this goal’s feedback stream on completion, so the client’s `on_next_feedback_message` loop ends cleanly. The clean close is not the only way a stream ends: if the producer instance dies mid-goal, the client’s loop ends with a producer-gone error instead (see [Receiving feedback](#receiving-feedback)). A goal’s cancel never affects other concurrent goals. ## 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 `link_id` in the interface: * Rust ```json5 { peppy_schema: "node/v1", manifest: { name: "controller", tag: "v1", depends_on: { nodes: [ { name: "brain", tag: "v1", link_id: "brain" }, ] }, }, interfaces: { actions: { consumes: [ { link_id: "brain", // References depends_on.nodes[].link_id name: "move_arm", // Action name on that node }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/controller"] }, } ``` * Python ```json5 { peppy_schema: "node/v1", manifest: { name: "controller", tag: "v1", depends_on: { nodes: [ { name: "brain", tag: "v1", link_id: "brain" }, ] }, }, interfaces: { actions: { consumes: [ { link_id: "brain", // References depends_on.nodes[].link_id name: "move_arm", // Action name on that node }, ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "controller"] }, } ``` Note By default, the `node add` and `node sync` commands require every dependency node to already be in the node stack so the proper interfaces can be generated. When a dependency node is not present in the stack, pass `peppy node sync --include-repositories` (`-r`) to let the daemon fall back to the [repository cache](/advanced_guides/repositories/#syncing-against-repositories) for dependencies that aren’t in the stack. ### 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 ```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 { // `one`: the accessor returns the slot's sole producer directly. let arm = brain_move_arm::bound_producer(&node_runner); let request = brain_move_arm::GoalRequest { arm_id: 7, desired_position: [10, 20, 30], }; let action_handle = brain_move_arm::ActionHandle::fire_goal( &node_runner, arm, // which bound instance executes the goal Duration::from_secs(5), // timeout request, QoSProfile::SensorData, // QoS for the feedback topic ) .await?; println!("goal accepted={}", action_handle.data.accepted); Ok(()) }) } ``` * Python ```python import asyncio from peppygen import NodeBuilder, QoSProfile from peppygen.consumed_actions import brain_move_arm async def run(node_runner): # `one`: the accessor returns the slot's sole producer directly. arm = brain_move_arm.bound_producer(node_runner) request = brain_move_arm.GoalRequest(arm_id=7, desired_position=[10, 20, 30]) action_handle = await brain_move_arm.ActionHandle.fire_goal( node_runner, arm, # which bound instance executes the goal request, 5.0, # timeout (seconds) QoSProfile.SensorData, # QoS for the feedback topic ) print(f"goal accepted={action_handle.data.accepted}", flush=True) async def setup(parameters, node_runner): return [asyncio.create_task(run(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` `fire_goal` requires the caller to pass one explicit target for every [cardinality](/advanced_guides/topics#dependency-cardinality), including the default `one`; obtain it from the slot’s cardinality-typed accessor (the singular, infallible `bound_producer()` on a `one` slot, `bound_producers()` on the multi cardinalities, never empty for `one_or_more`). The target must belong to the slot’s own set; anything else fails with a runtime error before the goal reaches the wire. Firing a goal at every bound producer of a multi-cardinality slot is a plain loop over `bound_producers()`; each returned handle drives its own feedback stream, cancel, and result, exactly as a single goal does. `fire_goal` returns an `ActionHandle` whose `data` field holds the goal response (e.g. `data.accepted`). The handle retains the selected target: goal submission, feedback, result retrieval, and cancellation all stay pinned to that same producer; no stage uses a wildcard, fallback producer, discovery, or automatic retargeting. The handle is what you use for subsequent feedback, result, and cancel calls. You can fire multiple goals concurrently, and each returns its own handle, so the server can drive them in parallel. ### Receiving feedback [Section titled “Receiving feedback”](#receiving-feedback) Use `on_next_feedback_message` on the handle to receive feedback. The idiomatic pattern is a loop that drains feedback until a terminal error ends the stream: * Rust ```rust use peppygen::Error; loop { match action_handle.on_next_feedback_message().await { Ok(feedback) => println!("new_position={:?}", feedback.new_position), Err(Error::ActionFeedbackProducerGone { .. }) => { break; // producer instance died; get_result yields Abandoned } Err(_) => break, // server has completed (or cancelled) this goal } } ``` * Python ```python while True: try: feedback = await action_handle.on_next_feedback_message() print(f"new_position={feedback.new_position}", flush=True) except ConnectionError: break # producer instance died; get_result yields ABANDONED except Exception: break # server has completed (or cancelled) this goal ``` Each goal has its own feedback stream, addressed by `goal_id`, so feedback for one goal never reaches another handle, even when several goals run concurrently on the same server. Two terminal errors end the drain loop: * **Clean close** (`ActionFeedbackChannelClosed`; `RuntimeError` in Python): the server closed this goal’s stream: its worker completed the goal (`complete` or `complete_cancelled`) or abandoned it without completing it (an early return or a panic). Call `get_result` to learn the outcome. * **Producer gone** (`ActionFeedbackProducerGone`; `ConnectionError` in Python): the producer instance this goal is pinned to died without closing the stream (process killed, host lost). Feedback already in flight (including a clean close that did make it out) is drained first, so a graceful shutdown never surfaces as producer-gone. After this error, `get_result` resolves to the `Abandoned` outcome. Either way, a client draining feedback always terminates rather than hanging. In Python, catch `ConnectionError` before the generic handler when you want to distinguish producer death from a clean close; a bare `except Exception` treats both as end-of-stream. Note Producer death is confirmed via liveliness probes, so a transport outage (e.g. a router restart) that outlasts the confirmation window can surface as producer-gone even though the process survived. The `Abandoned` outcome remains the correct consumer-side interpretation: the goal’s results are unreachable. ### Getting the result [Section titled “Getting the result”](#getting-the-result) Use `get_result` on the handle to request the final result. The call is routed to this goal by `goal_id` and **parks until the goal reaches a definitive terminal state**, then returns a typed outcome: * Rust ```rust let result = action_handle.get_result(Duration::from_secs(5)).await?; match result.outcome { brain_move_arm::ResultOutcome::Completed(data) => println!( "completed: success={} error={:?} final_position={:?}", data.success, data.error_msg.as_deref(), data.final_position, ), brain_move_arm::ResultOutcome::Cancelled(data) => { println!("cancelled at {:?}", data.final_position) } brain_move_arm::ResultOutcome::Abandoned => { println!("the worker abandoned the goal without producing a result") } brain_move_arm::ResultOutcome::Expired => { println!("the result expired before it was fetched") } } ``` * Python ```python result = await action_handle.get_result(5.0) if result.status == brain_move_arm.ResultStatus.COMPLETED: print( f"completed: success={result.data.success} error={result.data.error_msg} " f"final_position={result.data.final_position}", flush=True, ) elif result.status == brain_move_arm.ResultStatus.CANCELLED: print(f"cancelled at {result.data.final_position}", flush=True) elif result.status == brain_move_arm.ResultStatus.ABANDONED: print("the worker abandoned the goal without producing a result", flush=True) elif result.status == brain_move_arm.ResultStatus.EXPIRED: print("the result expired before it was fetched", flush=True) ``` A poll on a still-running goal **parks** until the goal reaches a terminal state, then returns a typed outcome, so you can call `get_result` whenever you like; a forwarding/relaying node does not have to time its poll to the worker’s lifecycle. The returned outcome (a Rust `ResultOutcome` enum, or `result.status` plus optional `result.data` in Python) tells you exactly what happened: * **`Completed`** / **`Cancelled`**: the worker delivered a result via `complete` / `complete_cancelled`; the payload is in `data`. * **`Abandoned`**: the worker dropped the goal without ever delivering a result (an early return or a panic), or the producer instance itself died mid-goal. A poll on a goal whose producer is confirmed gone resolves to `Abandoned` instead of parking forever. * **`Expired`**: the goal finished, but its result was retained only for a bounded window (30 s by default) that has since elapsed, or the result was already evicted. A terminal result stays fetchable for that retention window, and you can fetch it more than once within it, which makes relaying it reliable. If the poll outlives the caller’s own `timeout`, you get a normal timeout error like any other request. ### Cancelling a goal [Section titled “Cancelling a goal”](#cancelling-a-goal) Use `cancel_goal` on the handle to request cancellation of that specific goal. It returns a typed `CancelState`: * Rust ```rust let cancel_response = action_handle.cancel_goal(Duration::from_secs(5)).await?; match cancel_response.state { brain_move_arm::CancelState::Signalled => println!("cancel delivered to a live goal"), brain_move_arm::CancelState::AlreadyTerminal => println!("goal had already finished"), brain_move_arm::CancelState::Unknown => println!("no goal with that id is known"), } ``` * Python ```python cancel_response = await action_handle.cancel_goal(5.0) if cancel_response.state == brain_move_arm.CancelState.SIGNALLED: print("cancel delivered to a live goal", flush=True) elif cancel_response.state == brain_move_arm.CancelState.ALREADY_TERMINAL: print("goal had already finished", flush=True) elif cancel_response.state == brain_move_arm.CancelState.UNKNOWN: print("no goal with that id is known", flush=True) ``` The `CancelState` reports what the cancel found: * **`Signalled`**: a live goal received the cancel signal. *Delivered*, not necessarily *will stop*: the worker may ignore it. * **`AlreadyTerminal`**: the goal had already reached a terminal state, so there was nothing to cancel (best-effort; observable only while the result is still retained). * **`Unknown`**: no goal with that `goal_id` is known (it never existed, or was evicted long ago). The cancel targets only this goal; other concurrent goals are unaffected. To learn the goal’s final state, call `get_result`: if the worker reacted with `complete_cancelled` you get a `Cancelled` outcome; if it ignored the cancel and finished normally you get `Completed`. ## Bindings and routing [Section titled “Bindings and routing”](#bindings-and-routing) Routing for actions uses the same consumer-side model as topics and services. A binding `KEY: VALUE` creates a private channel from producer instance `VALUE` to one of the client’s declared slots; the action server itself is binding-agnostic. An action slot resolves through its bindings: the generated `fire_goal` checks the caller-selected target against the slot’s bound set and pins the goal, cancel, result, and feedback channels to that one producer’s full `(core_node, instance_id)` wire address. How many producers may be bound to the slot is its declared [cardinality](/advanced_guides/topics#dependency-cardinality) (`one` when omitted); because the whole goal cycle needs exactly one server, every fired goal selects exactly one member of the bound set, and a `one` / `one_or_more` slot with no binding is rejected at launch validation, before anything spawns. In a launcher / stack config: ```json5 { source: { local: "./consumer" }, instances: [{ instance_id: "my_consumer", bindings: { brain: "left-arm-1" }, }], } ``` or, when launching a single node during development: ```sh peppy node run --bind brain@left-arm-1 . ``` ### Worked example: `openarm01_backbone` [Section titled “Worked example: openarm01\_backbone”](#worked-example-openarm01_backbone) A client that wires two depth cameras to two dedicated slots: openarm01\_backbone/peppy.json5 ```json5 { manifest: { name: "openarm01_backbone", tag: "v1", depends_on: { contracts: [ { name: "depth_camera", tag: "v1", link_id: "wrist_left_camera" }, { name: "depth_camera", tag: "v1", link_id: "wrist_right_camera" }, ], }, }, // ... } ``` peppy\_launcher.json5 ```json5 { deployments: [ { source: { name: "depth_camera:v1" }, instances: [ { instance_id: "left_cam" }, { instance_id: "right_cam" }, ]}, { source: { name: "openarm01_backbone:v1" }, instances: [ { instance_id: "backbone_inst_1", bindings: { wrist_left_camera: "left_cam", wrist_right_camera: "right_cam", }}, ]}, ], } ``` Three contract statements follow from this manifest: 1. `fire_goal` on the `wrist_left_camera_` module reaches `left_cam`. 2. `fire_goal` on the `wrist_right_camera_` module reaches `right_cam`. 3. If the `wrist_right_camera` binding line were removed, validation would reject the launch (every declared slot must have a binding): a goal cycle has no wildcard fallback. ### Why an explicit single target? [Section titled “Why an explicit single target?”](#why-an-explicit-single-target) Without a pinned target, a goal request would be broadcast to every matching producer and each one would execute the goal concurrently, which for state-changing actions (motor commands, file writes, payment dispatches) is a real-world hazard. Requiring every `fire_goal` to name one member of the slot’s validated bound set removes that hazard by construction: the selected target carries the producer’s full `(core_node, instance_id)` wire address, so the goal addresses exactly one producer with no probe round-trip, the goal, cancel, result, and feedback channels all stay on that producer for the whole goal cycle, and an out-of-set target (never checked by plan-time binding validation) is rejected before it reaches the wire. If the selected producer dies mid-cycle, the affected call surfaces `ServiceUnreachable` and the caller can retry once the producer is back; the bound set itself never shrinks or rebinds at runtime. ### Validator rules [Section titled “Validator rules”](#validator-rules) The launcher validator runs these checks before the stack starts: 1. **Every `KEY` must name a declared slot, and every declared slot must resolve.** A binding whose `KEY` matches no `depends_on` `link_id` is rejected; there are no free-form keys. A declared `one` / `one_or_more` slot with no binding entry fails the launch before anything is spawned; a `zero_or_more` slot with no entry resolves to the empty set. 2. **The value’s shape must match the slot’s cardinality.** A `one` slot takes a scalar, a multi slot takes an array, an empty array meets only `zero_or_more`, and duplicate targets within one slot are rejected. Repeated `--bind KEY@…` flags accumulate on a multi-slot and are a hard error on a `one` slot. 3. **Every target must satisfy the slot, checked per bound instance.** A target `instance_id` that deploys a different node than the slot expects (or one that does not implement the requested contract) is rejected. 4. **Stack-wide `instance_id` uniqueness.** Every `instance_id` must be unique across the entire stack, not just within a `(node_name, node_tag)` group. The `--bind` syntax names producers by `instance_id`, so a duplicate would make the binding ambiguous. 5. **Bindings are stamped with the daemon’s `core_node`.** The wire addresses producers by the full `(core_node, instance_id)` pair; the validator stamps the launching daemon’s `core_node` into every resolved binding, preserving application declaration order, so generated calls address exactly the selected producer and never match on `instance_id` alone. ## Concurrent processing [Section titled “Concurrent processing”](#concurrent-processing) A single action server can drive many goals at once. The accept loop only waits for the next goal; each accepted goal runs in its own spawned task with its own `GoalContext`. The framework routes every cancel and result request to the right goal by `goal_id`, and each goal has its own feedback stream, so goals never interfere with one another. This makes the “one server, many resources” pattern natural: include a discriminator in the goal request (e.g. `arm_id` or `device_id`) and route to a per-resource worker. Your goal handler is responsible for the concurrency policy: accept goals to run them in parallel, or reject a goal (with `GoalResponse::reject(reason)`) when its target resource is already busy. A goal that is not accepted yields no `GoalContext` and cannot be cancelled or completed. # Authentication > Log in to the Peppy backend with peppy auth login (OAuth device flow), stay logged in across runs, and authenticate CI with a PEPPY_API_KEY. `peppy auth login` authenticates the CLI against the Peppy backend. Peppy is a public OAuth client of the project’s identity provider (Zitadel): the CLI obtains a bearer token through the browser and sends it to the backend, which validates it. The CLI never sees your Google/passkey credentials. Those stay in the browser. ## Commands [Section titled “Commands”](#commands) | Command | What it does | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `peppy auth login [--api-url ] [--no-browser] [--yes]` | Logs in via the OAuth 2.0 Device Authorization Grant, caches the tokens, and federates this machine to your organization’s cloud router. | | `peppy auth whoami` (alias `status`) | Shows the current identity, backend, and token validity. `--json` for machine-readable output. | | `peppy auth logout [--yes]` | Revokes the access token on the backend (across replicas), deletes the local credentials, and de-federates this machine. | `--yes` (`-y`) skips the daemon-restart confirmation prompt described under [Organization federation](#organization-federation). ## Logging in [Section titled “Logging in”](#logging-in) ```plaintext peppy auth login ``` On a terminal this prints a verification URL and a user code, then opens your browser at that URL (with the code pre-filled). Approve the request in the browser and the CLI stores the tokens. Subsequent commands reuse them: you log in once and stay logged in until the refresh token expires; expired access tokens are refreshed automatically. Over SSH or anywhere without a browser, use `--no-browser`: the CLI prints the URL and code and waits for you to approve them on another device. ```plaintext peppy auth login --no-browser ``` ### How it works [Section titled “How it works”](#how-it-works) 1. The CLI fetches the public `GET {api_url}/cli/config`, which returns the `issuer`, the `client_id`, and the exact `scopes` to request. 2. It runs OIDC discovery against the `issuer` (`{issuer}/.well-known/openid-configuration`) to find the device and token endpoints. 3. It starts the device flow, opens the browser, and polls until you approve. 4. It caches the tokens (and the `issuer`/`client_id`, so refresh works offline) under `~/.peppy/conf/credentials.json5`. ## Organization federation [Section titled “Organization federation”](#organization-federation) Logging in does more than cache a token: it stamps this machine with your **organization namespace** (your account’s stable organization id) and federates the peppy daemon’s local messaging router to your organization’s private cloud router. Robots signed in to the same organization then interoperate across that federation, while different organizations stay routing-isolated. Logged out, the machine falls back to the `local` namespace, which never reaches the cloud router; two logged-out machines on the same LAN still discover each other, but nothing leaves the local network. A session’s namespace is fixed once the daemon opens it, so changing it (logging in or out) **restarts the messaging daemon and wipes the running node stack**. When a daemon is running with user nodes, `login` and `logout` confirm first: ```plaintext Logging in changes this machine's organization namespace, which restarts the messaging daemon and wipes the running node stack. Continue? [y/N] ``` Pass `--yes` (`-y`) to skip the prompt. It is also skipped automatically when stdin is not a terminal (so scripts and CI are never blocked), when no daemon is running, or when the stack holds no user nodes; in each case the restart wipes nothing. `login` is **strict** about federation: after your credentials are saved it waits for the daemon to establish the federation link and exits non-zero if it cannot (no daemon running, the cloud router is unreachable or untrusted, or it times out). You stay authenticated in that case (only the command fails), so re-run it once the daemon is reachable. `logout` is best-effort and never fails on the de-federation step. How long the daemon waits to resolve the cloud router is bounded by [`federation.connect_timeout_secs`](/advanced_guides/daemon_config/#federation-cloud-router-timeout). ## Backend [Section titled “Backend”](#backend) By default the CLI talks to the prod backend, `https://api.peppy.bot`. That URL is stored in the `resource_servers` block of `~/.peppy/conf/peppy_config.json5` (see [Daemon configuration](/advanced_guides/daemon_config/)), seeded on first run and editable in place. To point at a different backend without editing the file, use `--api-url` or `PEPPY_API_URL`. The URL is resolved in precedence order: `--api-url`, then `PEPPY_API_URL`, then `resource_servers.api`. Plain `http` is allowed only for local backends (loopback / `*.localhost`); anything else must be `https`. ## CI and automation [Section titled “CI and automation”](#ci-and-automation) For non-interactive use, set `PEPPY_API_KEY` to a Zitadel service-user personal access token (PAT). It is used directly as the bearer: no browser, no refresh, and it is never written to disk. A PAT short-circuits every other credential source, so CI never opens a browser. If the PAT is revoked, requests start failing with 401 and you must rotate it. A PAT principal shows up as `kind: "machine"` under `peppy auth whoami`. ## Credential storage [Section titled “Credential storage”](#credential-storage) Tokens live at `~/.peppy/conf/credentials.json5`, written owner-only (`0600`). The root honours `PEPPY_HOME`. Tokens are never printed and `Authorization` headers are redacted in verbose output. ## Logging out [Section titled “Logging out”](#logging-out) ```plaintext peppy auth logout ``` This calls `POST {api_url}/logout`, which denylists the presented access token across all backend replicas (sub-second), then deletes the local credentials. The effect is near-immediate for the logged-out token. It revokes only the token you presented; a session on another device keeps working. Logout also returns this machine to the `local` namespace and de-federates its router, so, like login, it restarts the daemon and wipes the running node stack, with the same confirmation prompt and `--yes` bypass (see [Organization federation](#organization-federation)). ## Environment variables [Section titled “Environment variables”](#environment-variables) | Variable | Purpose | | --------------- | -------------------------------------------------------------------- | | `PEPPY_API_KEY` | PAT for non-interactive auth (highest-priority credential). | | `PEPPY_API_URL` | Override the backend base URL. | | `PEPPY_HOME` | Override the `~/.peppy` data root (also moves the credentials file). | | `NO_COLOR` | Disable coloured output. | # Choosing a communication pattern > Pick between topics, services, actions, pairing, contract implementation, and the datastore with a side-by-side decision guide Peppy gives nodes five ways to exchange data: [topics](/advanced_guides/topics/), [services](/advanced_guides/services/), [actions](/advanced_guides/actions/), [pairing](/advanced_guides/pairing/), and the [datastore](/advanced_guides/datastore/). On top of the first three, [contract implementation](/advanced_guides/contract_implementation/) changes not *what* flows but *who* can be on the other end. Each mechanism has its own guide; this page is the map for picking the right one before you write a manifest. The choice comes down to two independent questions: 1. **What shape is the exchange?** A continuous stream, a quick request, a bounded job, a standing two-way conversation, or a value left for later. 2. **How is the peer chosen?** A producer node named in the manifest, any node implementing a shared contract, or exactly one paired instance. ## What shape is the exchange? [Section titled “What shape is the exchange?”](#what-shape-is-the-exchange) | | Exchange | Initiated by | Lifecycle | Reach for it when | | ---------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------- | | [Topics](/advanced_guides/topics/) | One-way stream from a producer to its consumers | The producer, whenever it has data | Continuous while the producer runs | Sensor readings, camera frames, state updates | | [Services](/advanced_guides/services/) | One request, one response | The consumer | One bounded exchange per call | Queries, toggles, quick one-shot computations | | [Actions](/advanced_guides/actions/) | Goal in, feedback stream out, result on completion | The consumer fires a goal; the producer drives it to an end | A discrete job with an explicit start, end, and cancel | Navigation, arm movements, calibration runs | | [Pairing](/advanced_guides/pairing/) | Two independent one-way streams under one two-role contract | Either side, whenever it has data | Continuous while both instances live; dissolves when either dies | Inner control loops between two specific instances | | [Datastore](/advanced_guides/datastore/) | Write a small value, read it back later | Writer and reader, at independent times | The value persists on the core node until overwritten or removed | Calibration results, mode flags, last-known values | The same choices, phrased the way the requirement usually sounds: * “The camera publishes frames and whoever cares can watch.” A **topic**: one producer, any number of consumers, no coordination between them. * “Is the gripper open right now?” A **service**: you need an answer, you wait briefly for it, and the exchange is over. * “Move the arm to this pose, stream progress, tell me when it is done.” An **action**: the job has a defined end, reports feedback along the way, and can be cancelled mid-flight. * “The controller streams setpoints at 100 Hz and the arm streams joint states back at 1 kHz.” A **pairing**: both directions belong to one continuous conversation between two specific instances. * “Leave the calibration result somewhere the planner can pick it up when it starts.” The **datastore**: the reader does not have to be running, or subscribed, at the moment the value is produced. Two boundaries account for most wrong first guesses: * **Service or action?** Duration and observability, not importance. If the caller only needs a result and the work is quick, a service is enough. The moment you want progress updates or a cancel button, it is an action. A node can drive several goals of the same action concurrently; its goal handler sets the acceptance policy. * **Action or pairing?** Whether the exchange ends. “Do this and report when done” is an action. “Keep exchanging state for as long as we are both up” is a pairing. The two coexist happily on the same nodes; see [Pairing vs. actions](/advanced_guides/pairing/#pairing-vs-actions). ## How is the peer chosen? [Section titled “How is the peer chosen?”](#how-is-the-peer-chosen) The shape says nothing about which node sits on the other end. A consumer couples to the other end at one of three levels: | | Declared under | Who can fill the slot | Instances per slot | | ---------------------------------------------------------------- | ---------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Direct node dependency | `depends_on.nodes` | Instances of that exact producer node | The producer instance(s) bound to the slot, sized by its cardinality (one by default) | | [Contract dependency](/advanced_guides/contract_implementation/) | `depends_on.contracts` | Any node whose `manifest.implements` names the contract | The implementing instance(s) bound to the slot, sized by its cardinality (one by default) | | [Pairing slot](/advanced_guides/pairing/) | `depends_on.pairings` | One instance playing the complementary role of the pairing | Exactly one peer at a time, exclusive, both directions | Note Every slot binds an application-selected producer set sized by its declared [cardinality](/advanced_guides/topics#dependency-cardinality): exactly one by default (`one`), at least one for `one_or_more`, possibly empty for `zero_or_more`; a launch that leaves a `one` / `one_or_more` slot unbound is rejected. A topic slot receives from its bound producers and from no one else; a service or action call is answered by the one bound producer the caller selects, addressed directly. A consumer that needs several producers can declare several `one` slots (one role each) or one multi-cardinality slot. See [Why an explicit single target?](/advanced_guides/services/#why-an-explicit-single-target). * Use a **direct node dependency** when exactly one implementation exists and substitution is not a goal. It is the simplest wiring and the default. * Use a **contract** when several nodes implement the same contract (a hardware driver and its sim twin, several camera vendors), or when several producers should feed one consumer (telemetry, monitoring), with one slot declared per producer. The consumer names the contract; the launcher picks the producer behind each slot. See [Pairing vs. contracts](/advanced_guides/pairing/#pairing-vs-contracts) for the boundary on the other side. * Use a **pairing** when two specific instances belong together and both directions form one conversation, like a controller and *its* arm. The pair is established explicitly at start, is exclusive while it lasts, and dissolves when either side dies. The contract’s home follows the same ladder: a direct dependency’s message formats live on the producer’s own manifest, a contract’s live in a standalone `contract/v1` document both sides cite, and a pairing’s live in a standalone `pairing/v1` document naming the two roles. ## Rules that veto a design [Section titled “Rules that veto a design”](#rules-that-veto-a-design) A few system-wide rules reject otherwise-plausible wirings. Check your plan against them early: * **Only caller-driven cycles are cycles.** Topic subscriptions are passive, so two nodes may consume each other’s topics; pairing is built on exactly this property. Mutual service or action relationships deadlock, so the daemon rejects them however they are wired, directly or through contracts; see [Caller-driven cycles are rejected](/advanced_guides/contract_implementation/#caller-driven-cycles-are-rejected). When two nodes need both directions, make at least one direction a topic (or model both directions as a pairing), or model the bounded direction as an action from client to server. * **Pairings carry topics only.** No services or actions inside a pairing contract. Keep the continuous streams in the pairing and expose the bounded jobs as ordinary actions alongside it; the reasoning is in [Why topics only?](/advanced_guides/pairing/#why-topics-only). * **Streams are live, not replayed.** A subscriber receives messages from the moment it attaches; earlier messages are not redelivered, and a pairing slot delivers nothing while unpaired. When the reader must see the latest value regardless of timing, put that value in the [datastore](/advanced_guides/datastore/). * **The datastore is a blackboard, not a database.** It lives in the core node’s memory: it survives node restarts but not a daemon restart, and it holds small values, not history. See [When to use this](/advanced_guides/datastore/#when-to-use-this). ## Worked example: one arm, every mechanism [Section titled “Worked example: one arm, every mechanism”](#worked-example-one-arm-every-mechanism) The mechanisms compose freely on the same nodes. A complete robot arm setup might touch every one of them: * `robot_arm` and `arm_controller` exchange `joint_commands` and `joint_states` through a **pairing**: continuous, both directions, exactly these two instances, and the arm’s slot goes silent if its controller dies. * `robot_arm` exposes a `calibrate` **action**: run on demand, streams progress, ends with a result, can be cancelled. * `robot_arm` exposes an `enable_motors` **service**: a quick toggle with an immediate answer. * `robot_arm` also implements a `joint_state_source` **contract**; a dashboard declares one contract slot per arm on the floor and consumes each arm’s state **topic** through it, feeding one monitor without disturbing any pairing. * The calibration result is written to the **datastore**, so a restarted controller reads it back instead of re-running the calibration. One relationship, one mechanism: the pairing carries the control loop, the action carries the bounded job, the contract slots carry the monitoring feeds, and the datastore carries the state that outlives any single exchange. # 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. Peppy 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 { peppy_schema: "node/v1", manifest: { name: "my_node", tag: "v1", }, interfaces: {}, execution: { language: "python", container: { def_file: "apptainer.def", }, } } ``` * Rust peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "my_node", tag: "v1", }, 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 { peppy_schema: "node/v1", manifest: { name: "my_node", tag: "v1", }, interfaces: {}, execution: { language: "python", build_cmd: ["uv", "sync", "--no-editable"], run_cmd: ["./.venv/bin/python", "-m", "my_node"] } } ``` * Rust peppy.json5 (process node) ```json5 { peppy_schema: "node/v1", manifest: { name: "my_node", tag: "v1", }, 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 v1 %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 v1 %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. Peppy 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:v1 ``` 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, Peppy 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 Peppy’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 ``` Peppy 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 { peppy_schema: "node/v1", manifest: { name: "my_node", tag: "v1", }, 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) | Peppy 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, Peppy 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 { peppy_schema: "node/v1", manifest: { name: "uvc_camera", tag: "v1", }, 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 { peppy_schema: "node/v1", manifest: { name: "my_node", tag: "v1", }, 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. Peppy 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 v1 %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 v1 %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: * Python ```apptainer %environment export PATH="/opt/my_node/.venv/bin:$PATH" export PYTHONUNBUFFERED=1 ``` * Rust ```apptainer %environment export PATH="/root/.cargo/bin:$PATH" export RUST_LOG=info ``` # Contract implementation > Declare reusable message contracts and let nodes implement them explicitly, so consumers can bind any implementing producer at launch time A peppy node can either declare its `topics`, `services` and `actions` natively in its own `peppy.json5`, or it can implement a separately-defined **contract**. A contract is a standalone document with its own `peppy_schema: "contract/v1"` that names a set of topics, services and actions. Producers implement the contract; consumers depend on the contract. Both sides cite the contract by `(name, tag)`, and the launcher binds an implementing producer to the consumer at launch time. Contract implementation is the abstraction you reach for when several nodes provide the same capability. A `realsense_d405` driver, a `zed_2i` driver, and a `mujoco_depth_camera_sim` all expose the same `video_stream` topic. Without contracts, every consumer would have to hard-code one of those producer names. With contracts, the consumer asks for the `depth_camera:v1` contract and the launcher decides which physical driver (or its sim equivalent) fills the slot. For how contract implementation compares with the other ways of wiring nodes together, see [Choosing a communication pattern](/advanced_guides/communication_patterns/). ## The three pieces [Section titled “The three pieces”](#the-three-pieces) ### 1. The contract document [Section titled “1. The contract document”](#1-the-contract-document) A contract lives in its own file under a repository peppy scans (see [Repositories](/advanced_guides/repositories)). It uses `peppy_schema: "contract/v1"` and declares the same `topics` / `services` / `actions` shapes you would put on a node, except the `interfaces` block is the contract itself, with no `emits` / `consumes` split: ```json5 // depth_camera/peppy.json5 (inside a registered repo) { peppy_schema: "contract/v1", manifest: { name: "depth_camera", tag: "v1", }, interfaces: { topics: [ { 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" }, }, }, ], services: [ { name: "video_stream_info", response_message_format: { width: "u32", height: "u32", frames_per_second: "u8", encoding: "string", }, }, ], }, } ``` After `peppy repo refresh`, the contract is cached and addressable by `(name, tag)`. Files use whatever filename you like; peppy identifies a `contract/v1` document by its `peppy_schema` field. ### 2. A producer that implements the contract [Section titled “2. A producer that implements the contract”](#2-a-producer-that-implements-the-contract) A producer node claims a contract in `manifest.implements`. Each entry names a contract by `(name, tag)` and mints a `link_id` for the slot. The producer then lists **every** member of the contract as an explicit contract-backed entry in its `interfaces` section, referencing the slot via that `link_id`: * Python realsense\_d405/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "realsense_d405", tag: "v1", implements: [ { name: "depth_camera", tag: "v1", link_id: "cam" }, ], }, interfaces: { topics: { emits: [ { link_id: "cam", name: "video_stream" }, ], }, services: { exposes: [ { link_id: "cam", name: "video_stream_info" }, ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "realsense_d405"], }, } ``` * Rust realsense\_d405/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "realsense_d405", tag: "v1", implements: [ { name: "depth_camera", tag: "v1", link_id: "cam" }, ], }, interfaces: { topics: { emits: [ { link_id: "cam", name: "video_stream" }, ], }, services: { exposes: [ { link_id: "cam", name: "video_stream_info" }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/realsense_d405"], }, } ``` A contract-backed entry is exactly `{link_id, name}`. The shape and QoS come from the contract document, never from the manifest: an inline `message_format`, `qos_profile`, or service/action payload field on a contract-backed entry is rejected at parse time. The `name` is a strict selector, byte-equal to a member the contract declares. The producer node is named `realsense_d405`, not `depth_camera`. The two names are unrelated. The only thing that makes `realsense_d405` a valid `depth_camera` is the explicit `implements` claim. After `peppy node sync`, code generation emits the same `video_stream` and `video_stream_info` modules that a node declaring the shapes natively would get, nested under the contract’s identity (`emitted_topics/depth_camera/v1/video_stream`). #### Full coverage is mandatory [Section titled “Full coverage is mandatory”](#full-coverage-is-mandatory) The set of contract-backed entries referencing a slot must cover every member of its contract, exactly once, with no extras. A partial implementation is rejected at `node add` / `node sync` with one aggregated diff per broken slot, listing every missing, unknown, duplicated, and wrong-kind entry at once: ```plaintext contract `uvc_camera:v1` (implements slot `cam`) is not fully implemented: every contract member needs exactly one contract-backed entry in `interfaces` referencing link_id `cam`; missing: [video_stream_info (service), set_contrast (service)] ``` This is the point of the explicit-entry design: the node’s `peppy.json5` shows what the node actually emits and exposes, and the daemon enforces that the listing is complete. #### link\_id rules [Section titled “link\_id rules”](#link_id-rules) Implements link\_ids share one flat namespace with `depends_on.{nodes,contracts,pairings}` link\_ids; a collision is rejected at parse time. Direction matters: * A produced entry (`topics.emits`, `services.exposes`, `actions.exposes`) may only reference a `manifest.implements` slot. * A consumed entry (`*.consumes`) may only reference a `depends_on.{nodes,contracts}` slot. Pick semantic short names for implements link\_ids (`cam`, `collision`, `hw_ready`, `moves`), matching the consumer-side style, rather than echoing the contract name. A single node can implement multiple contracts (e.g. `depth_camera:v1` and `uvc_camera:v1` under distinct link\_ids) and then satisfies any consumer slot that asks for either. A node may implement each contract `(name, tag)` at most once; several instances of one capability are the job of node instances and [pairings](/advanced_guides/pairing), not repeated implements entries. A node may also implement a contract *and* depend on the same contract under a different link\_id (the relay shape). A native entry and a contract-backed entry may share a name on the same producer: the two are namespaced apart in generated modules, schema keys, and wire keys. ### 3. A consumer that depends on the contract [Section titled “3. A consumer that depends on the contract”](#3-a-consumer-that-depends-on-the-contract) A consumer references a contract through `manifest.depends_on.contracts` rather than `depends_on.nodes`. Every contract dep carries a `link_id` (exactly like a node dep), but its `(name, tag)` names the contract instead of a concrete producer: * Python video\_reconstruction/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "video_reconstruction", tag: "v1", depends_on: { contracts: [ { name: "depth_camera", tag: "v1", link_id: "rear_camera" }, ], }, }, interfaces: { topics: { consumes: [ { name: "video_stream", link_id: "rear_camera", }, ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "video_reconstruction"], }, } ``` * Rust video\_reconstruction/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "video_reconstruction", tag: "v1", depends_on: { contracts: [ { name: "depth_camera", tag: "v1", link_id: "rear_camera" }, ], }, }, interfaces: { topics: { consumes: [ { name: "video_stream", link_id: "rear_camera", }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/video_reconstruction"], }, } ``` The consumer never names a producer node. It names the contract, and the launcher does the matchmaking. #### Node dependencies expose native interfaces only [Section titled “Node dependencies expose native interfaces only”](#node-dependencies-expose-native-interfaces-only) A consumed entry whose `link_id` names a `depends_on.nodes` slot resolves exclusively against the producer’s **native** (no-`link_id`) entries. A contract-backed interface is consumable solely through a `depends_on.contracts` slot. The two namespaces cannot overlap, so there is no precedence rule to learn: consuming a name the producer provides only contract-backed fails with an error pointing at the contract dependency to declare instead. This keeps the wire addressing unambiguous: node-dep consumption is always node-addressed, contract-dep consumption is always contract-addressed. ## Binding an implementing producer [Section titled “Binding an implementing producer”](#binding-an-implementing-producer) The matching predicate at binding time is: *a producer satisfies a contract slot if its `manifest.implements` includes the slot’s `(name, tag)`*. The producer’s own node name is irrelevant. A `zed_2i:v1` node that implements `depth_camera:v1` is just as valid for a `depth_camera:v1` slot as a `realsense_d405:v1` node that implements it. ### Binding a contract slot [Section titled “Binding a contract slot”](#binding-a-contract-slot) A contract slot is bound with `--bind link_id@producer_instance_id` (or a launcher `bindings:` entry) whose value points at an instance whose node implements the requested contract: peppy\_launcher.json5 ```json5 { peppy_schema: "launcher/v1", deployments: [ { source: { name: "realsense_d405:v1" }, instances: [{ instance_id: "depth_cam_inst1" }], }, { source: { name: "video_reconstruction:v1" }, instances: [{ instance_id: "video_rec_1", bindings: { rear_camera: "depth_cam_inst1" }, }], }, ], } ``` The same example from the command line, launching the producer against an already-running consumer: ```sh peppy node run --instance-id=depth_cam_inst1 realsense_d405:v1 peppy node run --instance-id=video_rec_1 --bind=rear_camera@depth_cam_inst1 video_reconstruction:v1 ``` If `depth_cam_inst1` were instead an instance of a node with no `implements`, the launcher rejects the binding with a `BindingContractNotImplemented` error citing the expected contract and the producer’s actual `(name, tag)`. The producer’s node name does **not** save it: a node called `depth_camera:v1` that fails to declare `implements: [{ depth_camera, v1, link_id }]` is treated like any other non-implementing node. ### One slot per implementing producer [Section titled “One slot per implementing producer”](#one-slot-per-implementing-producer) How many implementing producers a slot binds is its declared [cardinality](/advanced_guides/topics#dependency-cardinality): exactly one by default, an application-selected set for `one_or_more` / `zero_or_more`. A consumer that reads from several implementing producers can either declare one contract slot per producer (giving each a distinct role in code) or one multi-cardinality slot bound to the whole set behind a single API. Both keep contract slots the natural shape for many-to-one consumption whose membership the application controls: telemetry, monitoring, any consumer that reads data from a launch-chosen set of producers. Every declared `one` / `one_or_more` slot must be bound; a launch that leaves one out is rejected. (For an exclusive 1:1 bidirectional relationship between two specific instances, use a [pairing](/advanced_guides/pairing) instead.) ```json5 manifest: { depends_on: { contracts: [ { name: "depth_camera", tag: "v1", link_id: "left_cam" }, { name: "depth_camera", tag: "v1", link_id: "right_cam" }, { name: "depth_camera", tag: "v1", link_id: "overhead_cam" }, ], }, } ``` Each binding key names its slot’s `link_id` directly, and every bound producer must implement the contract. The same routing rules as node deps apply; see [Bindings and routing](/advanced_guides/topics/#bindings-and-routing). #### Multiple producer instances under the same node identity [Section titled “Multiple producer instances under the same node identity”](#multiple-producer-instances-under-the-same-node-identity) A common case is several instances of the same implementing node (say, three `realsense_d405:v1` cameras plugged into the same robot). Each camera gets a slot of its own: the consumer declares one contract dep per camera and consumes each camera’s topic through that slot’s `link_id`: * Python video\_reconstruction/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "video_reconstruction", tag: "v1", depends_on: { contracts: [ { name: "depth_camera", tag: "v1", link_id: "front" }, { name: "depth_camera", tag: "v1", link_id: "back_left" }, { name: "depth_camera", tag: "v1", link_id: "back_right" }, ], }, }, interfaces: { topics: { consumes: [ { name: "video_stream", link_id: "front" }, { name: "video_stream", link_id: "back_left" }, { name: "video_stream", link_id: "back_right" }, ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "video_reconstruction"], }, } ``` * Rust video\_reconstruction/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "video_reconstruction", tag: "v1", depends_on: { contracts: [ { name: "depth_camera", tag: "v1", link_id: "front" }, { name: "depth_camera", tag: "v1", link_id: "back_left" }, { name: "depth_camera", tag: "v1", link_id: "back_right" }, ], }, }, interfaces: { topics: { consumes: [ { name: "video_stream", link_id: "front" }, { name: "video_stream", link_id: "back_left" }, { name: "video_stream", link_id: "back_right" }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/video_reconstruction"], }, } ``` Then the launcher binds each slot to its producer instance: peppy\_launcher.json5 ```json5 { peppy_schema: "launcher/v1", deployments: [ { source: { name: "realsense_d405:v1" }, instances: [ { instance_id: "rs_front" }, { instance_id: "rs_back_left" }, { instance_id: "rs_back_right" }, ], }, { source: { name: "video_reconstruction:v1" }, instances: [ { instance_id: "recon_1", bindings: { front: "rs_front", back_left: "rs_back_left", back_right: "rs_back_right", }, }, ], }, ], } ``` The three bindings feed the three slots, and the consumer receives the streams through the generated `front_video_stream`, `back_left_video_stream`, and `back_right_video_stream` modules. A fourth camera launched without a slot of its own would reach no consumer at all: only bound producers feed a slot. Inside the consumer, every consumed-topic API returns the **producer’s full `(core_node, instance_id)` identity** alongside the message payload. The consumer never hard-codes the launcher’s instance ids; it just uses the returned identity as a runtime key to keep per-producer state (latest frame, frame count, last-seen timestamp, etc.) when merging its slots into one aggregate, so the launcher can re-point a slot at a different implementing instance with no consumer code change. * Python ```python import asyncio from peppylib import ProducerRef from peppygen.consumed_topics import ( back_left_video_stream, back_right_video_stream, front_video_stream, ) frames_by_producer: dict[ProducerRef, Frame] = {} async def pump(subscription): async for producer, frame in subscription: # Key by the returned ProducerRef (its full core_node/instance_id # identity); do not compare against a hard-coded name. frames_by_producer[producer] = frame reconstruct(frames_by_producer) await asyncio.gather( pump(await front_video_stream.subscribe(node_runner)), pump(await back_left_video_stream.subscribe(node_runner)), pump(await back_right_video_stream.subscribe(node_runner)), ) ``` * Rust ```rust use peppygen::consumed_topics::{ back_left_video_stream, back_right_video_stream, front_video_stream, }; use peppylib::messaging::ProducerRef; use std::collections::HashMap; let mut frames_by_producer: HashMap = HashMap::new(); let mut front = front_video_stream::subscribe(&node_runner).await?; let mut back_left = back_left_video_stream::subscribe(&node_runner).await?; let mut back_right = back_right_video_stream::subscribe(&node_runner).await?; loop { let next = tokio::select! { next = front.next() => next?, next = back_left.next() => next?, next = back_right.next() => next?, }; let Some((producer, frame)) = next else { break }; // Key by the returned producer identity; do not compare against a // hard-coded name. frames_by_producer.insert(producer, frame); reconstruct(&frames_by_producer); } ``` The same applies when the producers are a mix of node identities, as long as each implements `depth_camera:v1`: a launcher can bind `front` to a `realsense_d405:v1` instance and `back_left` to a `zed_2i:v1` instance, since the matching predicate is the implements claim, not node identity. The `(core_node, instance_id)` identity returned with each message still pinpoints the exact producer, regardless of which node implementation it came from. ## sha256 pinning [Section titled “sha256 pinning”](#sha256-pinning) Both sides can optionally pin a specific contract revision by `sha256`: ```json5 // consumer side depends_on: { contracts: [{ name: "depth_camera", tag: "v1", sha256: "aaaa…", link_id: "rear_camera" }], } // producer side manifest: { implements: [{ name: "depth_camera", tag: "v1", sha256: "aaaa…", link_id: "cam" }], } ``` Each side independently verifies its pinned `sha256` against the on-disk contract document at cache-resolution time. Peppy refuses to start a node whose pinned contract revision is not in the cache. The two sides are not cross-checked: a producer that pins `sha256` against the cached contract and a consumer that does not pin can still bind, as long as both pass their own checks. ## Worked example: multi-camera reconstruction [Section titled “Worked example: multi-camera reconstruction”](#worked-example-multi-camera-reconstruction) Combining the pieces above, a typical setup looks like this: ```plaintext ┌─ depth_camera:v1 (contract) │ topics: [video_stream] │ services: [video_stream_info] │ ├─ realsense_d405:v1 (node) implements: [{depth_camera, v1, link_id: cam}] ├─ zed_2i:v1 (node) implements: [{depth_camera, v1, link_id: cam}] └─ mujoco_depth_camera_sim:v1 (node) implements: [{depth_camera, v1, link_id: cam}] video_reconstruction:v1 (consumer) depends_on.contracts: [ { depth_camera, v1, link_id: left_cam }, { depth_camera, v1, link_id: right_cam }, { depth_camera, v1, link_id: overhead_cam }, ] ``` A launcher can wire the three slots to any mix of implementing producers: ```json5 { peppy_schema: "launcher/v1", deployments: [ { source: { name: "realsense_d405:v1" }, instances: [ { instance_id: "rs_left" }, { instance_id: "rs_right" }, ], }, { source: { name: "zed_2i:v1" }, instances: [ { instance_id: "zed_overhead" }, ], }, { source: { name: "video_reconstruction:v1" }, instances: [ { instance_id: "recon_1", bindings: { left_cam: "rs_left", right_cam: "rs_right", overhead_cam: "zed_overhead", }, }, ], }, ], } ``` Every binding key names its slot’s `link_id`, and the launcher checks each target implements `depth_camera:v1`: `rs_left` and `rs_right` do via the `realsense_d405:v1` producer node, and `zed_overhead` does via `zed_2i:v1`. Node identity never matters, only the implements claim. Swapping `realsense_d405:v1` for `mujoco_depth_camera_sim:v1` in the launcher requires no change to the consumer node, since both producers implement the same contract and the binding works unchanged. Once the stack is running, these contract-resolved dependencies surface in the tooling alongside direct ones. `peppy stack list` includes them in its **Dependencies** section, annotated `(via depth_camera:v1 contract implementation)`, and [`peppy stack benchmark`](/advanced_guides/stack_benchmark/) measures each consumed topic, service, and action across the resolved edge. Both draw a heavy `➔` arrow for a contract-implementation edge to distinguish it from a direct `depends_on.nodes` edge (`→`). ## Caller-driven cycles are rejected [Section titled “Caller-driven cycles are rejected”](#caller-driven-cycles-are-rejected) A contract dependency is deliberately invisible to the node dependency graph, which is what lets two nodes depend on each other’s contracts without forming a structural cycle (the same property [pairings](/advanced_guides/pairing) rely on). For **topics** this is always safe: a topic dependency is passive (“I receive whatever is published”), so two nodes can each receive from the other with no runtime ordering between them. **Services and actions are different.** Consuming a service or action contract means “I will actively call the provider and wait for its reply.” If two nodes each call a service (or action) the other provides, neither can make progress until the other does: a request/response deadlock. Routing the calls through contracts hides that cycle from the static graph, but it does not make the deadlock go away. Peppy therefore rebuilds the *caller-driven* edges separately, resolving each contract dep to its implementing providers via `manifest.implements`, and rejects any service/action cycle it finds. ### An example that is rejected [Section titled “An example that is rejected”](#an-example-that-is-rejected) Two service contracts, one provided by each node: ```json5 // pose_service/peppy.json5 (contract) { peppy_schema: "contract/v1", manifest: { name: "pose_service", tag: "v1" }, interfaces: { services: [ { name: "current_pose", response_message_format: { x: "f64", y: "f64", theta: "f64" }, }, ], }, } // obstacle_service/peppy.json5 (contract) { peppy_schema: "contract/v1", manifest: { name: "obstacle_service", tag: "v1" }, interfaces: { services: [ { name: "nearest_obstacle", request_message_format: { x: "f64", y: "f64" }, response_message_format: { distance: "f64", bearing: "f64" }, }, ], }, } ``` `localizer` provides `pose_service` and *calls* `obstacle_service`; `mapper` provides `obstacle_service` and *calls* `pose_service`. Each node implements the contract it provides and consumes the service it calls through a `link_id`, exactly like a mutual-topic wiring, except both consumed links are **services**: * Python localizer/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "localizer", tag: "v1", implements: [ { name: "pose_service", tag: "v1", link_id: "pose_out" }, // provides current_pose ], depends_on: { contracts: [ { name: "obstacle_service", tag: "v1", link_id: "obstacles" }, ], }, }, interfaces: { services: { exposes: [ { link_id: "pose_out", name: "current_pose" }, ], consumes: [ { name: "nearest_obstacle", link_id: "obstacles" }, // calls mapper ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "localizer"], }, } // mapper/peppy.json5 { peppy_schema: "node/v1", manifest: { name: "mapper", tag: "v1", implements: [ { name: "obstacle_service", tag: "v1", link_id: "obstacles_out" }, // provides nearest_obstacle ], depends_on: { contracts: [ { name: "pose_service", tag: "v1", link_id: "pose" }, ], }, }, interfaces: { services: { exposes: [ { link_id: "obstacles_out", name: "nearest_obstacle" }, ], consumes: [ { name: "current_pose", link_id: "pose" }, // calls localizer ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "mapper"], }, } ``` * Rust localizer/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "localizer", tag: "v1", implements: [ { name: "pose_service", tag: "v1", link_id: "pose_out" }, // provides current_pose ], depends_on: { contracts: [ { name: "obstacle_service", tag: "v1", link_id: "obstacles" }, ], }, }, interfaces: { services: { exposes: [ { link_id: "pose_out", name: "current_pose" }, ], consumes: [ { name: "nearest_obstacle", link_id: "obstacles" }, // calls mapper ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/localizer"], }, } // mapper/peppy.json5 { peppy_schema: "node/v1", manifest: { name: "mapper", tag: "v1", implements: [ { name: "obstacle_service", tag: "v1", link_id: "obstacles_out" }, // provides nearest_obstacle ], depends_on: { contracts: [ { name: "pose_service", tag: "v1", link_id: "pose" }, ], }, }, interfaces: { services: { exposes: [ { link_id: "obstacles_out", name: "nearest_obstacle" }, ], consumes: [ { name: "current_pose", link_id: "pose" }, // calls localizer ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/mapper"], }, } ``` The two caller-driven edges close a cycle: ```plaintext localizer ── calls obstacle_service (nearest_obstacle) ──▶ mapper ▲ │ └────────── calls pose_service (current_pose) ◀──────────┘ ``` Whichever node is added second is rejected at add time with a `ServiceActionContractCycle` error. The offending node is **not** committed and the stack is left unchanged: ```plaintext service dependency cycle through contracts involving localizer:v1 -> mapper:v1 (closing dependency `obstacle_service:v1`). service request/response cycles deadlock and are not allowed; only topics may be bidirectional. If these providers are not actually cross-bound, pin the binding or split the contract. ``` The same rejection applies however you wire it: directly through `depends_on.nodes`, through contracts, through the launcher (`peppy stack launch`), or incrementally across separate commands. Bringing the nodes up one at a time does not relax the rule: the first node is added because its provider is not present yet, but the moment the second node makes both directions resolvable, the daemon’s stack-wide check sees the cycle and rejects that second node. ### Two properties of the check [Section titled “Two properties of the check”](#two-properties-of-the-check) * **It is conservative (type-level).** The check reasons about node identities and their declared `implements`, not about which producer a specific `--bind` resolves to. If a service or action contract has several implementing providers and only one of them closes a cycle, the configuration is still rejected, because *any* implementing provider could be bound into the cyclic slot. If you hit this, pin the binding to a non-cyclic provider or split the contract so the cyclic capability is not declared on the shared contract. * **Topics never count.** Only links consumed as a *service* or *action* contribute a caller-driven edge. The very same two nodes wired to consume each other’s `link_id` as a **topic** are accepted, because passive subscriptions cannot deadlock, which is exactly what [pairing](/advanced_guides/pairing) relies on. ### How to fix it [Section titled “How to fix it”](#how-to-fix-it) If the two nodes genuinely need to exchange data, model it without a caller-driven cycle: * Make at least one direction a **topic** stream rather than a service or action: passive subscriptions never deadlock. If the two nodes form an exclusive 1:1 relationship, model both directions as a [pairing](/advanced_guides/pairing). * Or model the request/response exchange as an [action](/advanced_guides/actions), which is one-directional by construction (the client depends on the server), so it never needs a back-edge. See [Why topics only?](/advanced_guides/pairing/#why-topics-only) for the full rationale behind the topic/service/action distinction. ## What contract implementation is not [Section titled “What contract implementation is not”](#what-contract-implementation-is-not) * **Implementation is explicit.** A node that natively emits `video_stream` but does not declare `implements: [{ depth_camera, v1, link_id }]` does **not** satisfy a `depth_camera:v1` contract slot, even if every field matches. The contract is the explicit `implements` claim plus the explicit per-member entries, not structural duck typing. * **Node identity is irrelevant.** A producer whose node name and tag coincidentally equal a contract’s `(name, tag)` is treated like any other producer: it only satisfies the slot if it declares `implements`. The reverse is also true: a producer named `realsense_d405:v1` satisfies a `depth_camera:v1` slot as soon as it implements the contract. * **Implementation does not flow through node deps.** `depends_on.nodes` still names a specific producer node by `(name, tag)` and exposes only that node’s *native* interfaces; `depends_on.contracts` is what introduces implementation-based matching and contract-addressed consumption. The two coexist on the same consumer. # Core node functions > Inspect a running peppy stack from your own code; uptime, host, and the live node graph. Every peppy stack has a **core node** that tracks which nodes have been added, which instances are running, and a few facts about the host it is running on. `peppylib` exposes two helpers that let any node query the core node at runtime: * `info`: a typed snapshot of the core node (uptime, hostname, node count, git version, container runtimes). * `stack.list`: the live node graph, with every node’s stage and running instances, and optionally a Graphviz DOT rendering. `info` is a single top-level call; `stack.list` and the `StackList` type it returns live in the `stack` submodule. They are available from Rust as `peppylib::info`, `peppylib::stack::list`, and `peppylib::stack::StackList`, and from Python as `from peppylib import info` and `from peppylib import stack` (then `stack.list`, `stack.StackList`). They are not re-exported by `peppygen`; import them from `peppylib` directly. Both take a `NodeRunner`, the same handle your node receives from `NodeBuilder::new().run(...)` (see the [services guide](/advanced_guides/services/) for a typical setup). ## When to use these [Section titled “When to use these”](#when-to-use-these) * **Runtime introspection of instances.** A node can call `stack.list` to discover, at runtime, how many instances of a given dependency are currently up and what their instance IDs are. A router or load-balancer node, for example, can iterate `graph.nodes`, find the entry whose `name` matches its target, and read `instances` (keeping only those whose `state` is `"running"`) to decide where to dispatch work. * **Health checks and dashboards.** `info` returns uptime, hostname, node count, git version, and container runtime versions, enough to drive a status page or a liveness probe without scraping logs. * **Graph visualisation.** `stack.list` with `with_dot_graph: true` returns a Graphviz DOT string you can pipe straight into `dot -Tsvg` or any renderer that accepts DOT. * **Tests and tooling.** Integration tests or CLI utilities that need to assert “node X is up with N instances” or “edge A→B exists in the graph” can use the same helpers production code does. ## `info` [Section titled “info”](#info) `info` polls the core node’s `INFO` service and returns a typed response. The response carries: * `uptime_secs`: how long the core node has been up, in seconds. * `core_node_name`, `core_node_instance_id`: identity of the core node. * `host_name`: the machine hosting the core node. * `node_count`: how many nodes are currently in the stack. * `git_version`: the peppy build the core node was compiled from. * `container_info.apptainer_version`, `container_info.lima_version`: container runtime versions. * `messaging_port`: the port the messaging layer is listening on. The second argument is a timeout. Rust accepts anything that converts to `Option`; Python accepts a `float` in seconds. Pass `None` (or omit it in Python) to use the default of 10 seconds. * Python src/my\_node/\_\_main\_\_.py ```python from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppylib import info async def setup(_params: Parameters, node_runner: NodeRunner) -> None: response = await info(node_runner, 3.0) print( f"{response.core_node_name} on {response.host_name}: " f"{response.node_count} nodes, up {response.uptime_secs}s" ) def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use std::time::Duration; use peppygen::{NodeBuilder, Parameters, Result}; use peppylib::info; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { let response = info(&node_runner, Duration::from_secs(3)).await?; println!( "{} on {}: {} nodes, up {}s", response.core_node_name, response.host_name, response.node_count, response.uptime_secs, ); Ok(()) }) } ``` ## `stack.list` [Section titled “stack.list”](#stacklist) `stack.list` returns a `StackList` with two fields: * `graph`: the node graph, with every node’s metadata and its tracked instances. * `dot_graph`: an optional Graphviz DOT rendering, populated only when the second argument (`with_dot_graph`) is `true`. Pass `false` to skip the rendering when you only need the structured graph. Each node entry carries its `name`, `tag`, `config_path`, optional `artifact_path`, `stage` (`Added`, `Building`, `Ready`, or `Root`), and its `instances`. Each instance has an `instance_id` and a `state` (`starting`, `running`, or the terminal `finished` / `failed`). A router or load-balancer keeps only the `running` ones, which excludes both still-warming and already-exited instances. The graph’s `edges` list the dependency relationships, with each edge pointing from one node entry to another. The list covers both direct `depends_on.nodes` dependencies and those resolved through [contract implementation](/advanced_guides/contract_implementation): a contract edge carries a `via_contract` of `name:tag` (the contract it was resolved through), while a direct edge has none. Rust and Python expose the graph differently: * In Rust, `result.graph` is a typed `SerializedNodeGraph` with `nodes: Vec` and `edges: Vec`. Field access is direct (`node.name`, `node.instances`). * In Python, `result.graph` is a plain `dict` of the same shape: `{"nodes": [...], "edges": [...]}`. Access fields by key (`node["name"]`, `node["instances"][0]["state"]`). The example below uses `stack.list` to count the running instances of each node and print their IDs, a typical pattern for a router or load-balancer node that needs to dispatch work across instances of a dependency. * Python src/my\_node/\_\_main\_\_.py ```python from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppylib import stack async def setup(_params: Parameters, node_runner: NodeRunner) -> None: result = await stack.list(node_runner, True, 3.0) for node in result.graph["nodes"]: running = [i for i in node["instances"] if i["state"] == "running"] print(f"{node['name']} ({node['stage']}): {len(running)} running instance(s)") for instance in running: print(f" - {instance['instance_id']}") if result.dot_graph is not None: print(result.dot_graph) def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use std::time::Duration; use peppygen::{NodeBuilder, Parameters, Result}; use peppylib::stack; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { let result = stack::list(&node_runner, true, Duration::from_secs(3)).await?; for node in &result.graph.nodes { println!( "{} ({:?}): {} instance(s)", node.name, node.stage, node.instances.len(), ); for instance in &node.instances { // `state` displays as "starting", "running", "finished", or "failed". println!(" - {} ({})", instance.instance_id, instance.state); } } if let Some(dot) = result.dot_graph { println!("{dot}"); } Ok(()) }) } ``` ### Looking up instances by `(name, tag)` [Section titled “Looking up instances by (name, tag)”](#looking-up-instances-by-name-tag) When you know which dependency you want, skip the manual iteration with `StackList.running_instance_ids_by_node(name, tag)`. The Rust signature is `Result, NodeNotFound>`; Python raises `KeyError` for the same condition. In both languages, an empty list (not an error) means the node is present but every instance is still `starting`, useful for a router that wants to back off during warmup rather than fail loudly. * Python src/my\_node/\_\_main\_\_.py ```python result = await stack.list(node_runner, False, 3.0) try: ids = result.running_instance_ids_by_node("router_target", "v1") except KeyError as e: print(e) else: if ids: print(f"dispatch to {ids}") else: print("router_target:v1 is present but all instances are still starting") ``` * Rust src/main.rs ```rust let result = stack::list(&node_runner, false, Duration::from_secs(3)).await?; match result.graph.running_instance_ids_by_node("router_target", "v1") { Ok(ids) if !ids.is_empty() => println!("dispatch to {ids:?}"), Ok(_) => println!("router_target:v1 is present but all instances are still starting"), Err(err) => println!("{err}"), } ``` Note Both `info` and `stack.list` default to a 10-second timeout. Pass an explicit timeout when calling from a latency-sensitive path so a slow or unreachable core node does not stall your node. # Daemon configuration > Tune the daemon's messaging topology, peer-mode buffer sizes, and node lifecycle grace periods through peppy_config.json5. The peppy daemon reads one global configuration file, `~/.peppy/conf/peppy_config.json5`. It sets the daemon’s core-node name, controls the messaging topology of the whole stack, the subscriber channel buffer sizes, the grace periods that govern node lifecycle, and the timeout for federating to your organization’s cloud router. The daemon applies it to its own core-node session and to every node it spawns. The same file also records the backend resource-server URL the `peppy auth login` / `whoami` / `logout` commands talk to; that block is read by the CLI, not the daemon. Note The daemon reads its settings (`core_node_name`, `mode`, `peer`, `lifecycle`, `federation`) **once, at daemon startup**. Editing them has no effect on a running stack; restart the daemon (`peppy service serve`, or `systemctl restart` your service) to apply changes. The `resource_servers` block is read fresh by each CLI auth command, so an edit there takes effect on the next `peppy auth login` without a daemon restart. ## How the file is managed [Section titled “How the file is managed”](#how-the-file-is-managed) You never need to create or migrate this file by hand: * **First start.** If the file does not exist, the daemon creates it with every setting at its default value, annotated with explanatory comments. * **Missing settings.** If the file exists but omits settings (typically a file written by an older peppy, before a newer knob existed), the daemon appends each missing setting with its default value and comments. Your own values, comments, formatting, and any unrecognized keys are preserved exactly as you wrote them. * **Malformed file.** If the file cannot be parsed, or a value is out of range, the daemon refuses to start and reports the error instead of silently falling back to defaults. A file that fails to load is never modified. A setting you delete from the file therefore comes back with its default on the next daemon start. To change a setting, edit its value instead of removing it. ## The default file [Section titled “The default file”](#the-default-file) ```json5 // Read once when the peppy daemon starts, so any edit below (mode or buffer // sizes) takes effect only after you restart the daemon. { // Fixed name for this daemon's core node, or null to derive a // machine-specific default (core-node-...). Names must be unique across all // daemons reachable over the same router/federation: a daemon whose name is // already in use refuses to boot. At most 63 characters from the node-name // character set (start with a letter; letters, digits, `_`, `-`). // `peppy service serve --core-node-name` overrides this for one run. core_node_name: null, // "peer" - Zenoh peer sessions with gossip: nodes form direct // peer-to-peer links and data stops relaying through the router. // "router" - gossip off: all traffic relays through the central zenohd // router. // Container nodes in a separate network namespace (Lima on macOS) always use // the router path regardless of this setting. mode: "peer", // Subscriber channel buffer sizes (number of in-flight messages) per QoS // tier, used in peer mode where there is no router relay to buffer between a // publisher and a subscriber. Defaults match peppy's built-in behavior; only // edit to tune backpressure. peer: { standard_buffer_size: 128, high_throughput_buffer_size: 1024, }, lifecycle: { // Node lifecycle knobs. `daemon_grace_secs` is the grace period a spawned node // waits, after the daemon's heartbeat goes silent, before shutting itself down // to avoid orphaning. daemon_grace_secs: 180, // How long a clean shutdown (ctrl+C / `systemctl stop`) and `peppy node // stop` wait for a node to exit cooperatively before force-killing its // process group. Seconds; minimum 1. A robot node uses this window to park // actuators and release hardware before it is killed. shutdown_grace_secs: 5, }, // Backend resource-server URL the `peppy auth login` / `whoami` / `logout` // commands talk to. Baked in at compile time (the dev backend in debug // builds, prod in release); --api-url / PEPPY_API_URL override it at runtime. resource_servers: { api: "https://api.peppy.bot", }, // Per-user zenoh-router federation: how the daemon links its local router to // your private cloud router. Only tuned to bound a slow/unreachable backend // during the federation step. federation: { // Seconds the daemon spends resolving your per-user cloud router before // giving up for this attempt (it retries in the background). Bounds the // federation done at startup and on each `peppy auth login`/`logout`; // minimum 1. If the backend is unreachable within this window the daemon // stays standalone rather than blocking. connect_timeout_secs: 30, }, } ``` ## `core_node_name`: the daemon’s core-node name [Section titled “core\_node\_name: the daemon’s core-node name”](#core_node_name-the-daemons-core-node-name) `core_node_name` fixes the name of this daemon’s [core node](/reference/concepts/#the-core-node). Leave it `null` (the default) and the daemon derives a stable, machine-specific name of the form `core-node-...`; set a string to pin an explicit one. Core-node names must be **unique across every daemon reachable over the same router or federation**. On boot the daemon probes its own name and, if another daemon already answers under it, refuses to start rather than break the name-based routing that every core-node call relies on. If two daemons end up sharing a name, give one of them a unique `core_node_name` (or pass `peppy service serve --core-node-name ` for a single run) and restart it. This most commonly matters when logging in federates several machines together, or when cloning a disk image reuses another machine’s derived name. The value must be non-empty, at most **63 characters**, and use only the node-name character set (start with a letter; letters, digits, `_`, `-`). An invalid value stops the daemon at startup with an error instead of failing later when the core node boots. `peppy service serve --core-node-name ` overrides the config for one run (the flag wins over the file); both absent falls back to the derived default. ## `mode`: messaging topology [Section titled “mode: messaging topology”](#mode-messaging-topology) `mode` selects how nodes exchange data: * **`"peer"`** (default): nodes run Zenoh peer sessions with gossip discovery enabled. After discovery, nodes form direct peer-to-peer links and data stops relaying through the central router, which removes a network hop from every message. * **`"router"`**: gossip is off and all traffic relays through the central `zenohd` router. Use this when direct node-to-node connectivity is unreliable or undesirable, or to simplify debugging by funneling all traffic through one process. [Container nodes](/advanced_guides/containers/) that live in a separate network namespace (such as the Lima VM peppy uses on macOS) always take the router path, regardless of this setting: gossip cannot establish direct links across the namespace boundary. ## `peer`: subscriber buffer sizes [Section titled “peer: subscriber buffer sizes”](#peer-subscriber-buffer-sizes) Each subscriber buffers in-flight messages in a bounded local channel. The `peer` block sets that channel’s capacity (number of messages, not bytes) per QoS tier: * **`standard_buffer_size`** (default `128`): the buffer for topics on the standard QoS tier, which most topics use. The same capacity also sizes the channels behind service requests and replies. * **`high_throughput_buffer_size`** (default `1024`): the buffer for topics on the high-throughput QoS tier, such as sensor-data streams, where short bursts well above the average rate are normal. The capacities apply in every mode, but they matter most in peer mode, which is why they live under the `peer` block: with nodes peering directly there is no router relay between a publisher and a subscriber, so this buffer is all that absorbs a burst. When a subscriber falls behind and its buffer fills up, what happens next depends on the topic’s QoS: topics published with a reliable profile block delivery so backpressure propagates to the publisher, while best-effort topics (including sensor-data streams on the high-throughput tier) drop messages instead. Raise a buffer size to absorb longer bursts at the cost of memory and worst-case latency; lower it to surface backpressure, or message loss, sooner. The defaults match peppy’s built-in behavior, so you only need to touch this block to tune backpressure. Both values must be greater than `0`; the daemon rejects a zero buffer size at startup. ## `lifecycle`: grace periods [Section titled “lifecycle: grace periods”](#lifecycle-grace-periods) The `lifecycle` block tunes the two windows peppy uses to guarantee that no node outlives the daemon. The full mechanics are described in [Daemon shutdown and orphan prevention](/guides/node_stack/#daemon-shutdown-and-orphan-prevention). * **`daemon_grace_secs`** (default `180`, minimum `30`): every spawned node runs a watchdog that listens for the daemon’s periodic heartbeat (published every 5 seconds). If the heartbeat goes silent for this many seconds, the node shuts itself down rather than lingering as an orphan. This only governs **unclean** daemon death (crash, OOM, `SIGKILL`); a clean shutdown does not wait for it. The minimum exists so a brief daemon blip or a quick restart never trips every node’s watchdog. * **`shutdown_grace_secs`** (default `5`, minimum `1`): the node’s cooperative-cleanup budget. A clean daemon shutdown (`Ctrl+C`, `systemctl stop`) and `peppy node stop` wait this window, plus a fixed allowance for the node’s runtime to finish tearing down (in Rust, the async runtime dropping; in Python, the event-loop join and interpreter finalize), before force-killing its process group. Raise it for nodes that need longer to park actuators or release hardware before dying; the force-kill deadline rises with it. ## `resource_servers`: backend URL [Section titled “resource\_servers: backend URL”](#resource_servers-backend-url) This block holds the platform-backend base URL the CLI auth commands talk to. The daemon ignores it; the `peppy auth login` / `whoami` / `logout` commands read it. See [Authentication](/advanced_guides/authentication/) for the full login flow. * **`api`** (default `https://api.peppy.bot` in release builds, `http://127.0.0.1:3000` in debug): the backend the auth commands talk to. There is no dev/prod selection at runtime; the file stores exactly the build’s backend. The URL is resolved in precedence order: `--api-url`, then `PEPPY_API_URL`, then `api` here. An empty block falls back to the build’s default backend. Plain `http` is accepted only for local backends (loopback / `*.localhost`); any other host must be `https`, validated when the command runs. ## `federation`: cloud-router timeout [Section titled “federation: cloud-router timeout”](#federation-cloud-router-timeout) When you are logged in, the daemon links (“federates”) its local messaging router to your organization’s private cloud router so that robots signed in to the same organization interoperate across the federation (see [Authentication](/advanced_guides/authentication/#organization-federation)). Resolving that cloud router involves a backend round-trip, and this block bounds it. * **`connect_timeout_secs`** (default `30`, minimum `1`): how long the daemon spends resolving the cloud router (once at startup, and again each time `peppy auth login` / `logout` pokes the daemon) before giving up for that attempt. If the backend is unreachable within the window, the daemon leaves its router **standalone** and retries federation in the background rather than blocking startup. A logged-out daemon never federates, so this timeout does not apply to it. ## Reference [Section titled “Reference”](#reference) | Setting | Default | Constraint | Effect | | ---------------------------------- | ------------------------------------------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `core_node_name` | `null` (derived `core-node-...`) | Non-empty, ≤ 63 chars, node-name charset | Fixed name for this daemon’s core node; must be unique across all daemons on the same router/federation or the daemon refuses to boot. Overridable per run with `--core-node-name`. | | `mode` | `"peer"` | `"peer"` or `"router"` | Messaging topology: direct peer links with gossip, or relay everything through the central router. | | `peer.standard_buffer_size` | `128` | `> 0` | Subscriber channel capacity (messages) for standard-QoS topics; also sizes service request/reply channels. | | `peer.high_throughput_buffer_size` | `1024` | `> 0` | Subscriber channel capacity (messages) for high-throughput-QoS topics such as sensor-data streams. | | `lifecycle.daemon_grace_secs` | `180` | `>= 30` | Seconds without a daemon heartbeat before a spawned node self-terminates (unclean daemon death only). | | `lifecycle.shutdown_grace_secs` | `5` | `>= 1` | Seconds a clean shutdown and `peppy node stop` wait for cooperative exit (plus a fixed runtime-teardown allowance) before force-killing. | | `resource_servers.api` | `https://api.peppy.bot` (release), `http://127.0.0.1:3000` (debug) | `https`, or `http` for local hosts | Backend the CLI auth commands talk to. Read by the CLI, not the daemon. | | `federation.connect_timeout_secs` | `30` | `>= 1` | Seconds the daemon spends resolving your organization’s cloud router (at startup and on each auth login/logout) before falling back to a standalone router. | A value outside its constraint, an unknown `mode`, or a syntax error all stop the daemon at startup with an error pointing at the problem (an out-of-range value names the offending field; a parse error reports the bad value or its position), so a typo can never silently revert your stack to defaults. # Datastore > Share small values across nodes through the core node's in-memory key/value store. Topics and services move data between nodes that are up at the same time. Sometimes you instead want a place to *leave* a value so another node can pick it up later, or so a node can read back what it wrote before it restarted. The **datastore** is that place: a small in-memory key/value store that lives in the core node and is shared by every node in the stack. `peppylib::datastore` exposes four helpers: * `store`: upsert a value under a key. * `get`: read the value back, or `None` if the key was never set. * `list`: list every key’s metadata (encoding and last writer), without the value bytes. * `remove`: delete a key, reporting whether it existed. Each value is a pair of *raw bytes* and an *encoding tag* (a short string such as `"text/plain"`, `"application/json"`, or `"application/octet-stream"`). This mirrors Zenoh’s `(payload, encoding)` value model: the store keeps your bytes verbatim and hands back the tag you chose, so any value type round-trips faithfully. The store never inspects either field. The store also records, for each key, the `instance_id` of the node that last wrote it. Both `get` (on the returned `StoredValue`) and `list` surface this as `last_modified_by`, so you can tell which node owns a value. `peppylib::datastore` ships an `Encoding` helper with constants for the common tags: `Encoding::TEXT_PLAIN`, `Encoding::APPLICATION_JSON`, `Encoding::APPLICATION_OCTET_STREAM` in Rust, and the same members on Python’s `Encoding` (a `StrEnum`). Like Zenoh’s own encoding, the set is **open**: the constants are a convenience, but any string is a valid tag, so you are never boxed in to the listed ones. They are available from Rust as `peppylib::datastore::{store, get, list, remove, StoredValue, DatastoreEntry, Encoding}`, and from Python as `from peppylib.datastore import store, get, list, remove, StoredValue, DatastoreEntry, Encoding`. Like the other core node helpers, they are not re-exported by `peppygen`; import them from `peppylib::datastore` directly. Each takes a `NodeRunner`, the same handle your node receives from `NodeBuilder().run(...)` in Python / `NodeBuilder::new().run(...)` in Rust (see the [services guide](/advanced_guides/services/) for a typical setup), and talks to the node’s bound core node. ## When to use this [Section titled “When to use this”](#when-to-use-this) * **A shared blackboard.** One node computes a value (a calibration result, a chosen target, a mode flag) and another reads it later. Unlike a topic, the reader does not have to be subscribed at the moment the value is produced; it can ask for the key whenever it needs it. * **Last-known-value handoff.** A node that restarts can read back the value it stored before, as long as the core node stayed up, instead of recomputing from scratch. * **Small cross-node state that does not warrant a service contract.** When defining a request/response service is more ceremony than the data is worth, a well-known key is often enough. It is **not** a database. The store is in-memory and process-local to the core node (see the notes below), so reach for topics when you need streaming data, for services when you need a node to *act* on a request, and for a real persistence layer when you need durability. The full map of mechanisms is in [Choosing a communication pattern](/advanced_guides/communication_patterns/). ## `store` [Section titled “store”](#store) `store` writes `value` (arbitrary bytes) under `key`, tagged with `encoding`, on the node’s bound core node. It returns once the core node acknowledges the write. Storing a key that already exists overwrites the previous value and encoding. The final argument is a response timeout. Rust accepts anything that converts to `Option`; Python accepts a `float` in seconds. Pass `None` (or omit it in Python) to use the default of 10 seconds. * Python src/my\_node/\_\_main\_\_.py ```python from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppylib.datastore import store, Encoding async def setup(_params: Parameters, node_runner: NodeRunner) -> None: await store( node_runner, "calibration/wrist_offset", b'{"x": 0.1, "y": -0.4}', Encoding.APPLICATION_JSON, 3.0, ) print("stored wrist offset") def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use std::time::Duration; use peppygen::{NodeBuilder, Parameters, Result}; use peppylib::datastore::{store, Encoding}; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { store( &node_runner, "calibration/wrist_offset", br#"{"x": 0.1, "y": -0.4}"#.to_vec(), Encoding::APPLICATION_JSON, Duration::from_secs(3), ) .await?; println!("stored wrist offset"); Ok(()) }) } ``` ## `get` [Section titled “get”](#get) `get` reads the value stored under `key` from the node’s bound core node. A key that was never stored (or that no node has stored yet) reads as `None` rather than an error: the helper folds the wire response’s `found` flag into `Option::None` (Rust) / `None` (Python), so you never get back an empty `StoredValue` you have to second-guess. When the key is present you get a `StoredValue` with three fields: * `value`: the raw bytes, exactly as stored (`Vec` in Rust, `bytes` in Python). * `encoding`: the encoding tag they were stored with. * `last_modified_by`: the `instance_id` of the node that last wrote this key. The second argument is the same response timeout as above. * Python src/my\_node/\_\_main\_\_.py ```python from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppylib.datastore import get async def setup(_params: Parameters, node_runner: NodeRunner) -> None: stored = await get(node_runner, "calibration/wrist_offset", 3.0) if stored is None: print("no wrist offset stored yet") else: print(f"wrist offset ({stored.encoding}): {stored.value!r}") def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use std::time::Duration; use peppygen::{NodeBuilder, Parameters, Result}; use peppylib::datastore::get; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { let key = "calibration/wrist_offset"; match get(&node_runner, key, Duration::from_secs(3)).await? { Some(stored) => println!("wrist offset ({}): {:?}", stored.encoding, stored.value), None => println!("no wrist offset stored yet"), } Ok(()) }) } ``` ## `list` [Section titled “list”](#list) `list` returns the metadata of **every** key currently in the store: one entry per key carrying the `key`, its `encoding` tag, and `last_modified_by` (the `instance_id` of the node that last wrote it). The value bytes are deliberately **not** included. A list stays cheap no matter how large your values are; fetch the bytes for a specific key with `get`. The order is unspecified, and the result is a point-in-time snapshot (another node may store or remove keys immediately after). The only argument is the same response timeout as above. * Python src/my\_node/\_\_main\_\_.py ```python from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppylib.datastore import list async def setup(_params: Parameters, node_runner: NodeRunner) -> None: entries = await list(node_runner, 3.0) for entry in entries: print(f"{entry.key} ({entry.encoding}) last written by {entry.last_modified_by}") def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use std::time::Duration; use peppygen::{NodeBuilder, Parameters, Result}; use peppylib::datastore::list; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { for entry in list(&node_runner, Duration::from_secs(3)).await? { println!( "{} ({}) last written by {}", entry.key, entry.encoding, entry.last_modified_by ); } Ok(()) }) } ``` ## `remove` [Section titled “remove”](#remove) `remove` deletes (unsets) `key` from the store. It returns a boolean: `true` if the key existed and was removed, `false` if it was already absent, so removing a missing key is a no-op, not an error. The second argument is the same response timeout as above. * Python src/my\_node/\_\_main\_\_.py ```python from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppylib.datastore import remove async def setup(_params: Parameters, node_runner: NodeRunner) -> None: removed = await remove(node_runner, "calibration/wrist_offset", 3.0) print("removed" if removed else "key was already absent") def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use std::time::Duration; use peppygen::{NodeBuilder, Parameters, Result}; use peppylib::datastore::remove; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { let key = "calibration/wrist_offset"; if remove(&node_runner, key, Duration::from_secs(3)).await? { println!("removed {key}"); } else { println!("{key} was already absent"); } Ok(()) }) } ``` ## Behavior notes [Section titled “Behavior notes”](#behavior-notes) * **In-memory, never written to disk.** The store lives inside the core node process. Values last exactly as long as the core node does; restarting the core node starts from an empty store. Treat it as fast shared scratch space, not durable storage. * **Shared across the whole stack, with no namespacing.** Every node bound to the same core node reads and writes one flat keyspace. Coordinate on key names (a prefix convention like `calibration/...` works well) so two nodes do not clobber each other by accident. * **Keys are arbitrary strings.** A key rides inside the request payload, not as a Zenoh keyexpr, so any character is allowed: slashes, spaces, `*`, `{}`, anything. `"robot/state**{1}"` is a perfectly valid key. * **Store is an upsert; last writer wins.** A later store under an existing key overwrites the value, the encoding, and the recorded writer. The store records the storing node’s `instance_id` on every write and surfaces it as `last_modified_by` on `get` and `list`. There is no atomic read-modify-write, so two nodes that both do get-then-store on the same key can race; design keys so a single writer owns each one when that matters. * **`list` is a metadata snapshot.** It returns every key’s `encoding` and `last_modified_by` but never the value bytes (fetch those with `get`), in unspecified order, reflecting the store at the moment it was answered. * **The encoding tag is yours to interpret.** The store treats it as an opaque label and returns it unchanged. Reach for the `Encoding` constants for the common tags and pass any string for the rest (the Zenoh-style MIME-like tags are a good default), then have readers branch on it when you store more than one value type. A tag read back from `StoredValue.encoding` compares equal to its `Encoding` member, so `stored.encoding == Encoding.APPLICATION_JSON` (Python) / `stored.encoding == Encoding::APPLICATION_JSON` (Rust) works. * **Default timeout is 10 seconds.** Pass an explicit timeout from a latency-sensitive path so a slow or unreachable core node does not stall your node. # Lockfiles > Validate dependency interfaces with hashes to prevent consuming from a wrong node ## Introduction [Section titled “Introduction”](#introduction) Coming soon! # Pairing > First-class bidirectional communication between two node instances through a named, two-role contract with explicit 1:1 pairing In a standard Peppy setup, a node subscribes to topics from its declared dependencies using `link_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 a return dependency would make the graph circular. **Pairing** is Peppy’s first-class mechanism for the cases where two nodes genuinely talk to each other. A *pairing* is a named, versioned contract with exactly **two roles**; two node **instances** (one per role) are *paired* 1:1 over it, and each side’s topics flow only to its paired peer: * **Explicit**: a pair is established by an operator action (`--pair` on `peppy node run`, or `pairings:` in a launcher), never by discovery. * **Exclusive**: a pairing slot holds at most one peer at a time. A second instance trying to pair at a taken slot is rejected. * **Pair-before-traffic**: pairs are established when an instance starts. While unpaired, a slot is silent: publishes go nowhere and subscriptions receive nothing. * **Unpair on death**: when either instance dies, the pair dissolves automatically. The survivor keeps running with its slot unpaired until a new peer instance is started against it. A pairing dependency is deliberately **invisible to the node dependency graph**: two nodes paired with each other never form a cycle, and neither requires the other to be present at build time. Pairings are **topics only**; see [Why topics only?](#why-topics-only). For where pairing sits among the other mechanisms, see [Choosing a communication pattern](/advanced_guides/communication_patterns/). ## 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 (role: controller) robot_arm (role: arm) │ │ slot "arm" │─ emits joint_commands ───────────────▶│ slot "controller" │ │ │◀─────────────────── emits joint_states│ │ │ └─────────────── ⇌ ───────────────────┘ one pair over arm_link/v1 ``` One pairing document models both directions. Each topic declares which **role** emits it; the other role consumes it. ## Define the pairing [Section titled “Define the pairing”](#define-the-pairing) A pairing is a standalone `pairing/v1` document in a repository peppy scans (see [Repositories](/advanced_guides/repositories)). Like a contract, the `message_format` lives here once and both sides inherit it by reference, but unlike a contract, a pairing names *both* directions and the two roles playing them. pairings/arm\_link/peppy.json5 ```json5 { peppy_schema: "pairing/v1", manifest: { name: "arm_link", tag: "v1", }, // Exactly two roles. Each paired instance plays one of them. roles: ["controller", "arm"], // One flat topic list; `emitted_by` names the role that publishes it, // and the other role consumes it. Pairings are topics-only. topics: [ { emitted_by: "controller", name: "joint_commands", qos_profile: "reliable", message_format: { target_positions: { $type: "array", $items: "f64", $length: 3 }, max_velocity: "f64", }, }, { emitted_by: "arm", 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", }, }, ], } ``` The rules: * `roles` declares **exactly two** distinct roles. * `topics` is one flat list; every topic’s `emitted_by` must name one of the two roles, and topic names must be unique across the whole list. * Pairings declare **topics only**: no services, no actions. After `peppy repo refresh`, the pairing is cached and addressable by `(name, tag)`. ## Configure the nodes [Section titled “Configure the nodes”](#configure-the-nodes) Each node declares a **pairing slot** under `depends_on.pairings`: the contract, the role this node plays, and a `link_id` naming the slot. The `link_id` is how the node’s own code and the pairing commands refer to *the other end*; while paired, exactly one peer instance sits behind it. * Python robot\_arm/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "robot_arm", tag: "v1", depends_on: { pairings: [ // One pairing slot: this node plays the `arm` role of arm_link/v1. // The slot's link_id names the OTHER end from this node's point of // view; while paired, exactly one controller instance sits behind it. { name: "arm_link", tag: "v1", role: "arm", link_id: "controller" }, ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "robot_arm"], }, } ``` * Rust robot\_arm/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "robot_arm", tag: "v1", depends_on: { pairings: [ // One pairing slot: this node plays the `arm` role of arm_link/v1. // The slot's link_id names the OTHER end from this node's point of // view; while paired, exactly one controller instance sits behind it. { name: "arm_link", tag: "v1", role: "arm", link_id: "controller" }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/robot_arm"], }, } ``` - Python arm\_controller/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "arm_controller", tag: "v1", depends_on: { pairings: [ // The complementary slot: this node plays the `controller` role of // the same contract, and its slot points at one arm. { name: "arm_link", tag: "v1", role: "controller", link_id: "arm" }, ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "arm_controller"], }, } ``` - Rust arm\_controller/peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "arm_controller", tag: "v1", depends_on: { pairings: [ // The complementary slot: this node plays the `controller` role of // the same contract, and its slot points at one arm. { name: "arm_link", tag: "v1", role: "controller", link_id: "arm" }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/arm_controller"], }, } ``` | Node | Role in `arm_link` | Slot (`link_id`) | Emits to the peer | Consumes from the peer | | ---------------- | ------------------ | ---------------- | ----------------- | ---------------------- | | `robot_arm` | `arm` | `controller` | `joint_states` | `joint_commands` | | `arm_controller` | `controller` | `arm` | `joint_commands` | `joint_states` | A slot is **required** by default: starting the instance without pairing it (or explicitly deferring it) fails loudly. Mark a slot `optional: true` to let the instance boot unpaired with no ceremony. ## Establishing pairs [Section titled “Establishing pairs”](#establishing-pairs) Pairs are established **when an instance starts**; there is no runtime “pair” command. The instance being started names the peer it pairs with; the peer must already be running (or starting) with a complementary unpaired slot. ```sh peppy repo refresh peppy node sync ./robot_arm peppy node sync ./arm_controller # The first instance has no peer yet, so its required slot must be # explicitly deferred; it boots with the slot silent. peppy node run --instance-id arm_1 robot_arm:v1 --defer-pair controller # The second instance pairs its `arm` slot with arm_1 at start. peppy node run --instance-id ctrl_1 arm_controller:v1 --pair arm@arm_1 ``` The `--pair` value is `LINK_ID@PEER_INSTANCE[/PEER_LINK]`: your slot, the running peer instance, and (only when the peer declares several complementary slots of the same pairing) which of the peer’s slots to claim. Running a node whose required slot is neither `--pair`ed nor `--defer-pair`ed is a hard error naming the missing slot and both flags: ```sh $ peppy node run arm_controller:v1 Error: required pairing slot(s) not covered: [arm]. Pass `--pair @` to pair each at start, or `--defer-pair ` to explicitly start unpaired ``` In a launcher, the pair is declared once, on either instance, with `pairings:`; a slot left intentionally unpaired goes in `defer_pairings:`: peppy\_launcher.json5 ```json5 { peppy_schema: "launcher/v1", deployments: [ { source: { name: "robot_arm:v1" }, instances: [{ instance_id: "arm_1" }], }, { source: { name: "arm_controller:v1" }, instances: [{ instance_id: "ctrl_1", // My `arm` slot pairs with the arm_1 instance. Declaring the same // pair from arm_1's side instead (or as well) is equivalent. pairings: { arm: "arm_1" }, }], }, ], } ``` `peppy stack launch` validates the whole pair plan up front (coverage, complementary roles, exclusivity, ambiguity) and establishes each pair as its second endpoint comes up. `peppy stack list` shows every pairing slot, paired or not, with a bidirectional `⇌` arrow: ```plaintext Instance pairings NODE INSTANCE PAIRINGS robot_arm:v1 arm_1 controller ⇌ ctrl_1:arm@core-node-adoring-wiles-7286 (arm_link:v1) arm_controller:v1 ctrl_1 arm ⇌ arm_1:controller@core-node-adoring-wiles-7286 (arm_link:v1) ``` ## Using the generated API [Section titled “Using the generated API”](#using-the-generated-api) `peppy node sync` generates a module per slot topic under `peppygen.pairings..` (Python) / `peppygen::pairings::::` (Rust); both directions of a slot live under the same `link_id`. Emitting and consuming look exactly like ordinary topics, plus two pin-state helpers on every module: `paired()` returns the current peer’s identity (or `None`/`None` while unpaired), and `wait_paired()` awaits one. * Python src/robot\_arm/\_\_main\_\_.py ```python import asyncio import sys import time from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppygen.pairings.controller import joint_commands, joint_states # `robot_arm` plays the `arm` role of the `arm_link` pairing. Both # directions of its `controller` slot live under # `peppygen.pairings.controller`: it consumes `joint_commands` from and emits # `joint_states` to whichever single controller instance is currently # paired on the slot. Unpaired, the subscription stays silent and # publishes go nowhere; the code does not change either way. async def handle_commands(node_runner: NodeRunner): # Declare the publisher once, then publish each state on it. try: publisher = await joint_states.declare_publisher(node_runner) except Exception as e: print(f"Failed to declare joint_states publisher: {e}", file=sys.stderr) return # Subscribing while unpaired is legal: the held subscription yields # nothing until a controller pairs, then only that controller's messages. try: subscription = await joint_commands.subscribe(node_runner) except Exception as e: print(f"Failed to subscribe to joint_commands: {e}", file=sys.stderr) return # Optional: block until a controller is paired and log who it is. try: peer = await joint_commands.wait_paired(node_runner) print(f"paired with controller {peer.producer.core_node}/{peer.producer.instance_id}") except Exception as e: print(f"Failed to wait for a paired controller: {e}", file=sys.stderr) return while True: try: received = await subscription.next() except Exception as e: # Log the failure, then pause before retrying so a persistent # receive error does not spin the loop at full speed. print(f"Error receiving joint command: {e}", file=sys.stderr) await asyncio.sleep(1.0) continue if received is None: break # subscription closed producer, command = received # `producer` is always the paired controller's identity. print( f"command from {producer.core_node}/{producer.instance_id}: " f"target={command.target_positions} max_vel={command.max_velocity}" ) # Drive the joints, then report the resulting state back to the # paired controller. try: await publisher.publish( joint_states.build_message( command.target_positions, [0.0, 0.0, 0.0], time.time(), ) ) except Exception as e: print(f"Failed to publish joint state: {e}", file=sys.stderr) 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::pairings::controller::{joint_commands, joint_states}; use peppygen::{NodeBuilder, Parameters, Result}; // `robot_arm` plays the `arm` role of the `arm_link` pairing. Both // directions of its `controller` slot live under // `peppygen::pairings::controller`: it consumes `joint_commands` from and // emits `joint_states` to whichever single controller instance is // currently paired on the slot. Unpaired, the subscription stays silent // and publishes go nowhere; the code does not change either way. fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { tokio::spawn(async move { // Declare the publisher once; every publish below is then lock-free. let publisher = match joint_states::declare_publisher(&node_runner).await { Ok(publisher) => publisher, Err(e) => { eprintln!("Failed to declare joint_states publisher: {e}"); return; } }; // Subscribing while unpaired is legal: the held subscription // yields nothing until a controller pairs, then only that // controller's messages. let mut subscription = match joint_commands::subscribe(&node_runner).await { Ok(subscription) => subscription, Err(e) => { eprintln!("Failed to subscribe to joint_commands: {e}"); return; } }; // Optional: block until a controller is paired and log who it is. match joint_commands::wait_paired(&node_runner).await { Ok(peer) => println!( "paired with controller {}/{}", peer.producer.core_node, peer.producer.instance_id ), Err(e) => { eprintln!("Failed to wait for a paired controller: {e}"); return; } } loop { let (producer, command) = match subscription.next().await { Ok(Some(received)) => received, Ok(None) => break, Err(e) => { eprintln!("Error receiving joint command: {e}"); continue; } }; // `producer` is always the paired controller's identity. println!( "command from {}/{}: target={:?} max_vel={}", producer.core_node, producer.instance_id, command.target_positions, command.max_velocity ); // Drive the joints, then report the resulting state back to // the paired controller. match joint_states::build_message( command.target_positions, [0.0, 0.0, 0.0], std::time::SystemTime::now(), ) { Ok(payload) => { if let Err(e) = publisher.publish(payload).await { eprintln!("Failed to publish joint state: {e}"); } } Err(e) => eprintln!("Failed to build joint_states message: {e}"), } } }); Ok(()) }) } ``` On the other side, `arm_controller` emits `joint_commands` and consumes `joint_states` through its `arm` slot: * Python src/arm\_controller/\_\_main\_\_.py ```python import asyncio import sys from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppygen.pairings.arm import joint_commands, joint_states # `arm_controller` plays the `controller` role of the `arm_link` pairing. # Both directions of its `arm` slot live under `peppygen.pairings.arm`: it # emits `joint_commands` to and consumes `joint_states` from the single # arm instance currently paired on the slot. If that arm dies, the slot # unpairs and the loop simply stops receiving until a new arm is paired. 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): # Declare the publisher once, then publish each command on it. try: publisher = await joint_commands.declare_publisher(node_runner) except Exception as e: print(f"Failed to declare joint_commands publisher: {e}", file=sys.stderr) return # Subscribing while unpaired is legal: the subscription follows the # slot's live pin, silent until an arm is paired. try: subscription = await joint_states.subscribe(node_runner) except Exception as e: print(f"Failed to subscribe to joint_states: {e}", file=sys.stderr) return # Optional: block until an arm is paired and log who it is. try: peer = await joint_states.wait_paired(node_runner) print(f"paired with arm {peer.producer.core_node}/{peer.producer.instance_id}") except Exception as e: print(f"Failed to wait for a paired arm: {e}", file=sys.stderr) return while True: try: received = await subscription.next() except Exception as e: # Log the failure, then pause before retrying so a persistent # receive error does not spin the loop at full speed. print(f"Error receiving joint state: {e}", file=sys.stderr) await asyncio.sleep(1.0) continue if received is None: break # subscription closed producer, state = received # `producer` is always the paired arm's identity. print( f"state from {producer.core_node}/{producer.instance_id}: " f"positions={state.positions}" ) # Compute the next target from the reported state, then command it. target = compute_next_target(state.positions) try: await publisher.publish( joint_commands.build_message( target, 1.0, # max_velocity ) ) except Exception as e: print(f"Failed to publish joint command: {e}", file=sys.stderr) 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::pairings::arm::{joint_commands, joint_states}; use peppygen::{NodeBuilder, Parameters, Result}; // `arm_controller` plays the `controller` role of the `arm_link` pairing. // Both directions of its `arm` slot live under `peppygen::pairings::arm`: it // emits `joint_commands` to and consumes `joint_states` from the single // arm instance currently paired on the slot. If that arm dies, the slot // unpairs and the loop simply stops receiving until a new arm is paired. fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { tokio::spawn(async move { // Declare the publisher once; every publish below is then lock-free. let publisher = match joint_commands::declare_publisher(&node_runner).await { Ok(publisher) => publisher, Err(e) => { eprintln!("Failed to declare joint_commands publisher: {e}"); return; } }; // Subscribing while unpaired is legal: the subscription follows // the slot's live pin, silent until an arm is paired. let mut subscription = match joint_states::subscribe(&node_runner).await { Ok(subscription) => subscription, Err(e) => { eprintln!("Failed to subscribe to joint_states: {e}"); return; } }; // Optional: block until an arm is paired and log who it is. match joint_states::wait_paired(&node_runner).await { Ok(peer) => println!( "paired with arm {}/{}", peer.producer.core_node, peer.producer.instance_id ), Err(e) => { eprintln!("Failed to wait for a paired arm: {e}"); return; } } loop { let (producer, state) = match subscription.next().await { Ok(Some(received)) => received, Ok(None) => break, Err(e) => { eprintln!("Error receiving joint state: {e}"); continue; } }; // `producer` is always the paired arm's identity. println!( "state from {}/{}: positions={:?}", producer.core_node, producer.instance_id, state.positions ); // Compute the next target from the reported state, then command it. let target = compute_next_target(&state.positions); match joint_commands::build_message(target, 1.0) { Ok(payload) => { if let Err(e) = publisher.publish(payload).await { eprintln!("Failed to publish joint command: {e}"); } } Err(e) => eprintln!("Failed to build joint_commands message: {e}"), } } }); Ok(()) }) } fn compute_next_target(current: &[f64; 3]) -> [f64; 3] { // Trajectory planning logic [current[0] + 0.1, current[1], current[2]] } ``` The key behavioral guarantees, all handled by the runtime with no application code: * **Silent while unpaired.** Publishing on an unpaired slot is a legal no-op, and a subscription on an unpaired slot just waits. Nodes never need pairing-state conditionals around their control loops. * **Only the paired peer.** A subscription delivers messages from the paired peer instance and nothing else: not from other instances of the same node, not from implementing third parties. The slot *is* the identity; there is no payload-level demultiplexing (`arm_id` fields and the like) to write. * **A pairing is a live stream, not a mailbox.** Messages published before the pair was established are never delivered. * **Re-pinning is seamless.** When the daemon re-pairs a surviving slot to a new peer, the held subscription switches over internally; no stale messages from the old peer leak through. ## Lifecycle: death, failover, exclusivity [Section titled “Lifecycle: death, failover, exclusivity”](#lifecycle-death-failover-exclusivity) A pair lives exactly as long as both endpoints. When an instance dies (crashes, is stopped with `peppy node stop`, or is torn down by a stack operation), the daemon dissolves its pairs and live-notifies each survivor that its slot is now unpaired. The survivor keeps running; its slot goes silent. Failover is then just another `--pair` at start: ```sh # ctrl_1 died (or was stopped). arm_1 keeps running, slot unpaired. peppy node run --instance-id ctrl_2 arm_controller:v1 --pair arm@arm_1 ``` `ctrl_2` pairs with the surviving `arm_1`, whose subscription and publisher switch to the new peer live; the arm’s code never restarts and never notices beyond `paired()` reporting a new identity. Exclusivity is enforced at establishment: while `arm_1`’s slot is paired, a third instance running with `--pair arm@arm_1` is rejected naming the existing pair. To hand an arm to a different controller, stop the old controller first (or run the new one against a different arm). Pairs do not survive a daemon restart Pairing state lives in the daemon, alongside the node stack itself. Like the stack, it is in-memory: after a daemon restart, instances are gone and so are their pairs; a fresh launch re-establishes them. ## Multiple slots: the two-arm commander [Section titled “Multiple slots: the two-arm commander”](#multiple-slots-the-two-arm-commander) Because the slot, not the node, is the unit of pairing, a node can declare several slots of the *same* pairing and hold one peer per slot, each a fully isolated stream: ```json5 // commander/peppy.json5 (manifest excerpt) depends_on: { pairings: [ { name: "arm_link", tag: "v1", role: "controller", link_id: "left_arm" }, { name: "arm_link", tag: "v1", role: "controller", link_id: "right_arm" }, ], }, ``` ```sh peppy node run --instance-id arm_l robot_arm:v1 --defer-pair controller peppy node run --instance-id arm_r robot_arm:v1 --defer-pair controller peppy node run --instance-id cmd_1 two_arm_commander:v1 \ --pair left_arm@arm_l --pair right_arm@arm_r ``` The generated code addresses each arm through its slot module (`pairings.left_arm.joint_commands` vs `pairings.right_arm.joint_commands` in Python, `pairings::left_arm::joint_commands` vs `pairings::right_arm::joint_commands` in Rust), and each subscription receives only its own arm’s states. When a peer instance declares several complementary slots (pairing two commanders’ arms to each other, say), disambiguate the target with `--pair left_arm@cmd_2/right_arm`. ## Why topics only? [Section titled “Why topics only?”](#why-topics-only) Peppy has three communication patterns (topics, services, and actions), but a pairing may declare **topics only**. The reason is the distinction that shapes the rest of the dependency model: a topic dependency is passive, while service and action dependencies are caller-driven. | | Topics | Services | Actions | | --------------------- | ------------------------------------ | -------------------------------------------------- | ---------------------------------------------------- | | Pattern | Publish-subscribe | Request-response | Goal-feedback-result | | Runtime data flow | One-way: producer → consumer | Two-way: consumer calls provider, provider replies | Multi-step: consumer drives the provider’s lifecycle | | Consuming means | ”I passively receive these messages" | "I actively call this provider" | "I actively orchestrate this provider” | | Allowed in a pairing? | Yes | No | No | A pairing’s two directions are two independent one-way streams: each side publishes whenever it has data, and neither ever *blocks on* the other at the protocol level. That is what makes the mutual relationship safe to hide from the static dependency graph. Services and actions are caller-driven: each side would actively invoke the other, forming a real request-response cycle at runtime, which is exactly the deadlock hazard the acyclic dependency model exists to prevent. The same rule applies to [contract implementation](/advanced_guides/contract_implementation#caller-driven-cycles-are-rejected): mutual service or action relationships are rejected, however they are wired. If two paired nodes also need a bounded request-response exchange, keep the continuous streams in the pairing and model the bounded job as an [action](/advanced_guides/actions) in one direction (the client depends on the server, no cycle). ## Pairing vs. contracts [Section titled “Pairing vs. contracts”](#pairing-vs-contracts) Pairing and [contract implementation](/advanced_guides/contract_implementation) both decouple nodes from each other through a shared contract. They answer different questions: | | Pairing | Contract | | -------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Cardinality | Exactly 1:1 per slot, exclusive (no `cardinality` key) | Declared per slot: exactly one by default, an application-selected set for `one_or_more` / `zero_or_more` | | Directionality | Both directions in one contract (two roles) | One direction per contract | | Establishment | Explicit (`--pair` / `pairings:`), at instance start | Explicit (`--bind` / `bindings:`), at instance start | | Peer identity | The slot *is* the identity; the runtime guarantees whose messages you get | Per-message `ProducerRef`; the consumer tells producers apart itself | | Lifecycle | Pair dissolves on death; survivor’s slot goes silent until re-paired | Producers come and go freely | | Natural fit | Control loops, teleoperation, any “these two specific instances belong together” relationship | Telemetry, monitoring, swappable sources (dedicated slots or one multi-cardinality slot) | Use a **pairing** when the relationship is exclusive and both directions belong to one conversation, like a controller and *its* arm. Use **contracts** when several loosely-coupled producers should feed a consumer, like every arm on the floor publishing diagnostics to one observer (one `one_or_more` slot bound to all arms, or one slot per arm when each has a distinct role). The two compose: `robot_arm` can be paired with its controller *and* implement a one-way `joint_state_source` contract so dashboards can watch it. ## Pairing vs. actions [Section titled “Pairing vs. actions”](#pairing-vs-actions) Pairing and [actions](/advanced_guides/actions/) can both look like “two nodes talking back and forth”, but they model different shapes of interaction. The distinction is **lifecycle**: does the exchange have a defined start and end, or does it run continuously for as long as the nodes are up? | | Pairing | Actions | | --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | Lifecycle | Continuous; runs for as long as the pair is live | Bounded; each goal has an explicit start (goal accepted) and end (result delivered, cancelled, or errored) | | Initiator | Either side publishes whenever it has data | Client issues a goal; server responds | | Concurrency | Both streams flow in parallel, indefinitely | Concurrent goals allowed; the server’s goal handler sets the acceptance policy | | Per-exchange feedback | Implicit (each side publishes its own stream) | Explicit feedback channel during the goal | | Cancellation | Unpair (stop an endpoint); or just stop publishing | First-class cancel request mid-flight | | Natural fit | Inner control loops, continuous state mirroring | Discrete jobs with a clear “done” condition | A useful test: if you find yourself wanting to say *“the controller asks the arm to do X and waits until it’s done”*, that is an action. If you find yourself saying *“the controller streams setpoints at 100 Hz and the arm streams state back at 1 kHz”*, that is a pairing. Both shapes can coexist on the same pair of nodes. A `robot_arm` might expose a `calibrate` action (run once at startup, has a definite end) **and** exchange `joint_commands` / `joint_states` continuously with its paired controller. # Repositories > Manage where peppy discovers nodes Repositories tell peppy where to look for nodes, launchers, contracts, and pairings. When you run `peppy repo refresh`, peppy walks every configured repository and indexes: * **Nodes**, identified by filename: every `peppy.json5` file is treated as a node config. * **Launchers**, identified by content: every `.json5` file whose body declares `peppy_schema: "launcher/v1"` is treated as a launcher, regardless of its filename. The launcher is keyed by the file stem (e.g. `openarm01_sim_teleop.json5` becomes the launcher named `openarm01_sim_teleop`). * **Contracts**, identified by content: every `.json5` file whose body declares `peppy_schema: "contract/v1"` is treated as a contract, regardless of its filename. A contract is a reusable declaration of topics, services, and actions, keyed by the `name:tag` declared in its manifest; nodes claim contracts by `name:tag` in `manifest.implements`. * **Pairings**, identified by content: every `.json5` file whose body declares `peppy_schema: "pairing/v1"` is treated as a pairing, regardless of its filename. A pairing is a two-role, topics-only contract that two node instances pair 1:1 over, keyed by the `name:tag` declared in its manifest; see [Pairing](/advanced_guides/pairing/). Out of the box, four repositories are configured: * **`nodes-hub`** (`https://github.com/Peppy-bot/nodes-hub.git`, tracked on `main`): a curated collection of ready-to-use nodes. * **`launchers-hub`** (`https://github.com/Peppy-bot/launchers-hub.git`, tracked on `main`): community launch files that compose nodes from the hubs. * **`contracts-hub`** (`https://github.com/Peppy-bot/contracts-hub.git`, tracked on `main`): shared contract definitions that nodes claim by `name:tag` in `manifest.implements`. * **`openarm-nodes`** (`https://github.com/Peppy-bot/openarm-nodes.git`, tracked on `main`): nodes specific to the OpenArm01 robot. Add your own local directory with `peppy repo add /path/to/my/nodes` so peppy can discover nodes you create locally. ## Configuration files [Section titled “Configuration files”](#configuration-files) Repository configuration lives in `~/.peppy/conf/`, and the indexes built by `peppy repo refresh` are written to `~/.peppy/cache/`: | File | Purpose | | ---------------------------------- | ------------------------------------------------------------ | | `conf/repositories.json5` | Repositories to scan | | `conf/excluded_repositories.json5` | Repositories (or subdirectories) to skip | | `cache/nodes.json5` | Index of nodes discovered across repositories | | `cache/launchers.json5` | Index of launch files discovered across repositories | | `cache/contracts.json5` | Index of contract definitions discovered across repositories | | `cache/pairings.json5` | Index of pairing definitions discovered across repositories | The two `conf/` files are JSON5 arrays. Each entry has an `id` (auto-assigned if missing), a `type`, and source-specific fields: repositories.json5 ```json5 [ { id: 1, type: "fs", path: "/home/user" }, { id: 2, type: "git", url: "https://github.com/Peppy-bot/nodes-hub.git", ref: "main" }, { id: 3, type: "url", url: "https://example.com/packages" }, ] ``` ### Source types [Section titled “Source types”](#source-types) | Type | Fields | Description | | ----- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fs` | `path` | A local directory. Peppy recursively walks it, indexing every `peppy.json5` (node) and every `.json5` file whose body declares `peppy_schema: "launcher/v1"` (launcher), `peppy_schema: "contract/v1"` (contract), or `peppy_schema: "pairing/v1"` (pairing). | | `git` | `url`, `ref` (optional) | A git repository. Peppy shallow-clones it and scans it the same way as an `fs` source. Use `ref` to pin a branch, tag, or commit. | | `url` | `url` | An HTTP endpoint (not yet implemented). | ## Commands [Section titled “Commands”](#commands) ### Initialize the defaults [Section titled “Initialize the defaults”](#initialize-the-defaults) ```sh peppy repo init ``` Syncs `repositories.json5` with the bundled default template. If the file does not yet exist it is created verbatim; otherwise any missing default entries are appended without touching your existing entries. Use this after upgrading peppy to pick up new defaults (for example when `launchers-hub` was added) without having to restart the daemon. The command operates directly on the local config file; no daemon connection is required. ### List repositories [Section titled “List repositories”](#list-repositories) ```sh peppy repo list ``` Shows all discovered nodes grouped by the repository that provides them. Each group is headed by the repository’s display label (path for `fs`, `url (ref: r)` for `git`) followed by the node count and source kind, then lists each node’s name, tag, and path. Duplicate nodes (same `name:tag` provided by multiple repositories) are flagged so you can see which repository currently wins resolution. ### Refresh the index [Section titled “Refresh the index”](#refresh-the-index) ```sh peppy repo refresh ``` Re-scans all configured repositories and rebuilds the node, launcher, contract, and pairing indexes. `peppy repo update` is accepted as an alias. During refresh, peppy: * Reads `repositories.json5` (creates it with defaults on first run). * Skips any repository or path listed in `excluded_repositories.json5`. * Walks local directories and shallow-clones git repositories. * Records every `peppy.json5` as a node, every `.json5` file whose body declares `peppy_schema: "launcher/v1"` as a launcher (file stem becomes the launcher name), every `.json5` file whose body declares `peppy_schema: "contract/v1"` as a contract (keyed by the `name:tag` in its manifest), and every `.json5` file whose body declares `peppy_schema: "pairing/v1"` as a pairing (also keyed by its manifest `name:tag`). * Reports each discovered node, launcher, contract, pairing, and excluded repository in real time. * Caches results in `~/.peppy/cache/nodes.json5`, `~/.peppy/cache/launchers.json5`, `~/.peppy/cache/contracts.json5`, and `~/.peppy/cache/pairings.json5`. On completion it prints a summary, for example `Repository refresh complete. 12 node(s), 3 launcher(s), 5 contract(s), 2 pairing(s) found.` When multiple repositories provide the same `name:tag` pair, the repository with the lower `id` takes priority. The duplicate is still recorded and shown in `repo list` but does not override the primary source. ### Add a repository [Section titled “Add a repository”](#add-a-repository) ```sh peppy repo add [--ref ] [--top] ``` Adds a new repository to `repositories.json5`. The source format is auto-detected: ```sh # Local directory peppy repo add /path/to/my/nodes # Git repository peppy repo add https://github.com/org/repo.git # Git repository pinned to a branch or tag peppy repo add https://github.com/org/repo.git --ref v2.0 # Plain URL peppy repo add https://example.com/packages # Give the new repo top priority (lower `id` than every existing entry) peppy repo add /path/to/my/nodes --top ``` The `--ref` flag is only valid for git sources. By default the new repository is appended with `id = max(existing ids) + 1`, so it has the lowest priority among configured repositories; pass `--top` to assign it an `id` just below the current minimum, giving it the highest priority. The priority `id` decides which repository wins when several of them provide the same `name:tag` (see [Refresh the index](#refresh-the-index)). ### Remove a repository [Section titled “Remove a repository”](#remove-a-repository) ```sh peppy repo remove ``` Removes a repository by its numeric ID (shown by `peppy repo list`). The repository index is automatically refreshed after removal. ### Exclude a repository [Section titled “Exclude a repository”](#exclude-a-repository) ```sh peppy repo exclude [--ref ] ``` Adds a source to `excluded_repositories.json5`. Excluded repositories are skipped during `peppy repo refresh`. You can exclude an entire repository or a specific subdirectory within a local repository: ```sh # Exclude a whole git repository peppy repo exclude https://github.com/org/repo.git # Exclude a subdirectory of a local repository peppy repo exclude /home/user/projects/private-nodes ``` ## Using a repository-indexed node [Section titled “Using a repository-indexed node”](#using-a-repository-indexed-node) Once a node appears in `peppy repo list`, you can add it by its `name:tag` without supplying a path or URL; peppy resolves the source through the cached index at `~/.peppy/cache/nodes.json5`: ```sh peppy node add uvc_camera:v1 ``` This is the shortest form of `peppy node add`. It works for any node provided by any repository listed in `repositories.json5`, including the default [nodes-hub](https://github.com/Peppy-bot/nodes-hub.git) community repository. When several repositories provide the same `name:tag`, the [resolution rule from `repo refresh`](#refresh-the-index) applies: the repository with the lower `id` wins. One constraint applies to this source shape: * `--ref` is rejected. The git ref (if any) is pinned once in `repositories.json5` when you register the repo, not per-add. If the node you want to add lives in a repository that is **not** in `repositories.json5`, keep using the full git URL or HTTP archive form shown in [Sharing nodes](/guides/sharing_nodes/). ## Syncing against repositories [Section titled “Syncing against repositories”](#syncing-against-repositories) `peppy node sync` regenerates a node’s interface code (peppygen) from its `peppy.json5`. By default, every dependency the node declares must already be in the [node stack](/guides/node_stack/), otherwise the sync fails with an “X does not exist in the stack” error. Pass `-r` (or `--include-repositories`) to let peppy fall back to the repository cache when a dependency is missing from the stack: ```sh peppy node sync -r ``` The lookup order is: 1. **Node stack**: wins whenever the dependency is already in the stack. Stack-resolved deps are listed under `Synchronized from node stack:` in the command output. 2. **Repository cache** (`~/.peppy/cache/nodes.json5`): consulted only when the stack does not have the dependency. Repo-resolved deps are listed under `Synchronized from repositories:`, each tagged with its source kind (`fs`, `git`, or `http`). Repository-resolved git dependencies reuse the same persistent checkout cache as `peppy node add`, so the same repository is never cloned more than once during a single sync run. Example output when both layers contribute: ```text Syncing node from /workspace/my_robot_brain via daemon 'core-node-...' Synced node interfaces at /workspace/my_robot_brain Synchronized from node stack: - already_added_dep:v1 Synchronized from repositories: - uvc_camera:v1 (git) - lidar_sensor:v1 (fs) ``` A dependency that is missing from **both** the node stack and every configured repository is a hard failure: ```text dep `gps_module:v2` not found in node stack or repository cache; run `peppy repo refresh` ``` ### Tip: register your local workspace as a repository [Section titled “Tip: register your local workspace as a repository”](#tip-register-your-local-workspace-as-a-repository) When a node depends on another node you maintain locally, registering your workspace directory as a `fs` repository removes the need to `peppy node add` every dependency just to regenerate peppygen for a downstream node: ```sh peppy repo add ~/code/my-nodes peppy repo refresh peppy node sync -r # picks up uvc_camera, lidar_sensor, ... from ~/code/my-nodes ``` Particularly useful during the early phase of a multi-node project, when the dependency graph is still in flux and you don’t want to re-add nodes after every interface change. ## Directory pruning [Section titled “Directory pruning”](#directory-pruning) When scanning local and git repositories, peppy automatically skips the following directories: * `.git` * `.peppy` * `target` * `node_modules` * `.venv` * `__pycache__` # 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. For long-running work that needs progress feedback or cancellation, use an [action](/advanced_guides/actions/) instead; for the full map of mechanisms, see [Choosing a communication pattern](/advanced_guides/communication_patterns/). ## 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`: * Python ```json5 { peppy_schema: "node/v1", manifest: { name: "uvc_camera", tag: "v1", }, 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: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "uvc_camera"] }, } ``` * Rust ```json5 { peppy_schema: "node/v1", manifest: { name: "uvc_camera", tag: "v1", }, 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` (Python) / `peppygen::exposed_services` (Rust). 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: * Python ```python import asyncio from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppygen.exposed_services import enable_camera async def handle_requests(node_runner: NodeRunner): def handler(request): print( f"enable_camera request from {request.instance_id}: " f"enable = {request.data.enable}" ) return enable_camera.Response( enabled=request.data.enable, error_msg="ok", ) await enable_camera.handle_next_request(node_runner, handler) async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(handle_requests(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` The handler may be a plain function or an `async def`; an async handler is awaited before the response is sent. * Rust ```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 consumer instance that sent the request, read straight from the request context. The producer is binding-agnostic; it doesn’t know which slot on the consumer this call is heading to. * `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: * Python ```python async def serve_requests(node_runner: NodeRunner): while True: await enable_camera.handle_next_request(node_runner, handler) # in setup: return [asyncio.create_task(serve_requests(node_runner))] ``` * Rust ```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`: * Python ```python from peppygen.exposed_services import get_camera_info async def handle_info_requests(node_runner: NodeRunner): def handler(request): print(f"get_camera_info request from {request.instance_id}") return get_camera_info.Response( card_type="UVC Webcam", size="1920x1080", interval="30fps", ) await get_camera_info.handle_next_request(node_runner, handler) ``` * Rust ```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 }); ``` A producer exposes its service exactly once and serves any consumer that calls it. Starting the producer before any consumer (or after them) is equally valid. ## 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 `link_id` in the interface: * Python ```json5 { peppy_schema: "node/v1", manifest: { name: "robot_brain", tag: "v1", depends_on: { nodes: [ { name: "uvc_camera", tag: "v1", link_id: "uvc_camera" }, ] }, }, interfaces: { services: { consumes: [ { link_id: "uvc_camera", // References depends_on.nodes[].link_id name: "enable_camera", // Service name on that node }, ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "robot_brain"] }, } ``` * Rust ```json5 { peppy_schema: "node/v1", manifest: { name: "robot_brain", tag: "v1", depends_on: { nodes: [ { name: "uvc_camera", tag: "v1", link_id: "uvc_camera" }, ] }, }, interfaces: { services: { consumes: [ { link_id: "uvc_camera", // References depends_on.nodes[].link_id name: "enable_camera", // Service name on that node }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/robot_brain"] }, } ``` Note By default, 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:v1` depends on `uvc_camera:v1`, but it does not exist in the stack ``` Pass `peppy node sync --include-repositories` (`-r`) to let the daemon fall back to the [repository cache](/advanced_guides/repositories/#syncing-against-repositories) for dependencies that aren’t 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` (Python) / `peppygen::consumed_services` (Rust). Use `poll` to send a request and wait for a response. The caller selects which bound producer handles the call by passing one explicit `target`, obtained from the slot’s [cardinality](/advanced_guides/topics#dependency-cardinality)-typed accessor: on a `one` slot `bound_producer()` returns the sole producer directly, on a `one_or_more` slot `bound_producers()` returns a never-empty set whose `first()` needs no unwrap, and on a `zero_or_more` slot it returns a possibly empty slice whose empty case the caller must handle. The explicit `target` parameter itself has the same shape for every cardinality. The candidates are fixed by the application bindings at launch, so by the time `poll` runs the route set is already validated. * Python ```python import asyncio from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppygen.consumed_services import uvc_camera_enable_camera async def call_service(node_runner: NodeRunner): # `one`: launch resolved the slot to exactly one producer, so the # accessor is singular and infallible; no emptiness handling exists. # The target is still passed explicitly, same discipline as the # multi cardinalities. camera = uvc_camera_enable_camera.bound_producer(node_runner) request = uvc_camera_enable_camera.Request(enable=True) response = await uvc_camera_enable_camera.poll( node_runner, camera, # the slot's sole producer request, 5.0, # timeout (seconds) ) error_msg = response.data.error_msg if response.data.error_msg is not None else "" print( f"enable_camera result: instance={response.instance_id} " f"enabled={response.data.enabled} error={error_msg}" ) async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(call_service(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust ```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 { // `one`: launch resolved the slot to exactly one producer, so the // accessor is singular and infallible; no emptiness handling exists. // The target is still passed explicitly, same discipline as the // multi cardinalities. let camera = uvc_camera_enable_camera::bound_producer(&node_runner); let request = uvc_camera_enable_camera::Request::new(true); let response = uvc_camera_enable_camera::poll( &node_runner, camera, // the slot's sole producer Duration::from_secs(5), // timeout 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 `target` must be a member of the slot’s own bound set, the one its bound-producer accessor returns; a target outside the set (including a producer bound to a different slot of the same consumer) fails with a runtime error before anything reaches the wire. A `ProducerRef` yielded by the slot’s own topic subscription is a member by construction, so the natural receive-a-frame-then-call-that-camera flow needs no defensive code. The response contains: * `instance_id`: the producer instance that handled the request, read from the response context. * `data`: the deserialized response payload. For a service without a request body, `poll` simply takes no request: * Python ```python from peppygen.consumed_services import uvc_camera_get_camera_info camera = uvc_camera_get_camera_info.bound_producer(node_runner) response = await uvc_camera_get_camera_info.poll(node_runner, camera, 5.0) print(f"Camera: {response.data.card_type} {response.data.size}") ``` * Rust ```rust use peppygen::consumed_services::uvc_camera_get_camera_info; let camera = uvc_camera_get_camera_info::bound_producer(&node_runner); let response = uvc_camera_get_camera_info::poll( &node_runner, camera, Duration::from_secs(5), ).await?; println!("Camera: {} {}", response.data.card_type, response.data.size); ``` ### Calling every bound producer [Section titled “Calling every bound producer”](#calling-every-bound-producer) Codegen provides only single-target operations; calling every member of a multi-cardinality slot is a plain loop at the call site (a `one` slot has no set to loop over: its singular `bound_producer()` is the sole target). Sequential versus concurrent execution, partial-result collection, and whether one failure cancels the other calls are application decisions; there is no atomic broadcast. Here the `camera` slot declares `cardinality: "one_or_more"`: * Python ```python # Runs once per bound producer, in binding declaration order. # `one_or_more`: the list is never empty, so the body runs at least once. # `zero_or_more`: an empty list makes the loop a no-op; no request is sent. for camera in camera_enable_camera.bound_producers(node_runner): request = camera_enable_camera.Request(enable=True) response = await camera_enable_camera.poll(node_runner, camera, request, 5.0) print(f"{response.instance_id}@{response.core_node}: enabled={response.data.enabled}") ``` * Rust ```rust // Runs once per bound producer, in binding declaration order. // `one_or_more`: the set is never empty, so the body runs at least once. // `zero_or_more`: an empty slice makes the loop a no-op; no request is sent. let cameras = camera_enable_camera::bound_producers(&node_runner); for camera in cameras { let request = camera_enable_camera::Request::new(true); let response = camera_enable_camera::poll( &node_runner, camera, Duration::from_secs(5), request, ) .await?; println!( "{}@{}: enabled={}", response.instance_id, response.core_node, response.data.enabled, ); } ``` To address one member of a multi slot instead of all of them, pick it from the same set; on a `one_or_more` slot the set is never empty, so selecting the first member needs no unwrap: ```rust let camera = camera_enable_camera::bound_producers(&node_runner).first(); ``` ## Bindings and routing [Section titled “Bindings and routing”](#bindings-and-routing) Routing for services is the same consumer-side model used by topics. A binding `KEY: VALUE` creates a private channel from producer instance `VALUE` to one of the consumer’s declared slots; the producer itself doesn’t know or care about bindings. A service slot resolves through its bindings: the generated `poll` checks the caller-selected target against the slot’s bound set and sends the wire request directly to it, carrying the producer’s full `(core_node, instance_id)` wire address. No discovery is involved. How many producers may be bound to the slot is its declared [cardinality](/advanced_guides/topics#dependency-cardinality) (`one` when omitted); a multi-cardinality slot takes an array of instance ids, and a `one` / `one_or_more` slot with no binding is rejected at launch validation, before anything spawns. Because a request/response call needs exactly one responder, every call selects exactly one member of the bound set. In a launcher / stack config: ```json5 { source: { local: "./consumer" }, instances: [{ instance_id: "my_consumer", bindings: { uvc_camera: "my-camera-instance" }, }], } ``` or, when launching a single node during development: ```sh peppy node run --bind uvc_camera@my-camera-instance . ``` ### Worked example: `openarm01_backbone` [Section titled “Worked example: openarm01\_backbone”](#worked-example-openarm01_backbone) A consumer that wires two depth cameras to two dedicated slots: openarm01\_backbone/peppy.json5 ```json5 { manifest: { name: "openarm01_backbone", tag: "v1", depends_on: { contracts: [ { name: "depth_camera", tag: "v1", link_id: "wrist_left_camera" }, { name: "depth_camera", tag: "v1", link_id: "wrist_right_camera" }, ], }, }, // ... } ``` peppy\_launcher.json5 ```json5 { deployments: [ { source: { name: "depth_camera:v1" }, instances: [ { instance_id: "left_cam" }, { instance_id: "right_cam" }, ]}, { source: { name: "openarm01_backbone:v1" }, instances: [ { instance_id: "backbone_inst_1", bindings: { wrist_left_camera: "left_cam", wrist_right_camera: "right_cam", }}, ]}, ], } ``` Three contract statements follow from this manifest: 1. `poll` on the `wrist_left_camera_` module reaches `left_cam`. 2. `poll` on the `wrist_right_camera_` module reaches `right_cam`. 3. If the `wrist_right_camera` binding line were removed, validation would reject the launch (every declared `one` / `one_or_more` slot must be bound): a service call has no wildcard fallback. ### Why an explicit single target? [Section titled “Why an explicit single target?”](#why-an-explicit-single-target) The underlying Zenoh transport would broadcast an untargeted service query to every matching producer (`QueryTarget::All`), and every producer’s user handler would run even though the consumer only ever consumes the first reply. For idempotent reads that wastes work; for state-changing services it can cause real-world side effects on producers the consumer never intended to reach. Requiring every call to name one member of the slot’s validated bound set removes that hazard by construction: the selected target carries the producer’s full `(core_node, instance_id)` wire address, so the call addresses exactly one producer with no probe round-trip and no discovery race, both the request and the response stay pinned to it, and an out-of-set target (never checked by plan-time binding validation) is rejected before it reaches the wire. If the selected producer dies, the call surfaces `ServiceUnreachable` (a `ConnectionError` in Python) and the caller can retry once the producer is back; the bound set itself never shrinks or rebinds at runtime. ### Validator rules [Section titled “Validator rules”](#validator-rules) The launcher validator runs these checks before the stack starts: 1. **Every `KEY` must name a declared slot, and every declared slot must resolve.** A binding whose `KEY` matches no `depends_on` `link_id` is rejected; there are no free-form keys. A declared `one` / `one_or_more` slot with no binding entry fails the launch before anything is spawned; a `zero_or_more` slot with no entry resolves to the empty set. 2. **The value’s shape must match the slot’s cardinality.** A `one` slot takes a scalar, a multi slot takes an array, an empty array meets only `zero_or_more`, and duplicate targets within one slot are rejected. Repeated `--bind KEY@…` flags accumulate on a multi slot and are a hard error on a `one` slot. 3. **Every target must satisfy the slot, checked per bound instance.** A target `instance_id` that deploys a different node than the slot expects (or one that does not implement the requested contract) is rejected. 4. **Stack-wide `instance_id` uniqueness.** Every `instance_id` must be unique across the entire stack, not just within a `(node_name, node_tag)` group. The `--bind` syntax names producers by `instance_id`, so a duplicate would make the binding ambiguous. 5. **Bindings are stamped with the daemon’s `core_node`.** The wire addresses producers by the full `(core_node, instance_id)` pair; the validator stamps the launching daemon’s `core_node` into every resolved binding, preserving application declaration order, so generated calls address exactly the selected producer and never match on `instance_id` alone. ## Error handling [Section titled “Error handling”](#error-handling) Service calls can fail with three error types: * **ServiceUnreachable** (`ConnectionError` in Python): no instance is listening for that service. * **ServiceTimeout** (`TimeoutError` in Python): no response was received within the timeout. * **ServiceError** (`RuntimeError` in Python): the handler returned an error, which is propagated back to the caller. If the service handler returns an `Err` in Rust or raises an exception in Python, 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. # Shutdown lifecycle > How a node stops: the stop paths, the cancellation token, shutdown hooks, grace windows, and the force-kill backstops. A node instance rarely gets to decide when it stops, and a robot node that stops carelessly leaves motors energised, instance locks held, and state unflushed. This guide is the full contract for how a node shuts down: which events trigger it, what the runtime guarantees (and does not guarantee) about your code during it, and how the grace windows bound every step. For the quick-start version, see [Graceful shutdown](/guides/first_node/#graceful-shutdown) in the first node guide; for the daemon-side operations view, see [Daemon shutdown and orphan prevention](/guides/node_stack/#daemon-shutdown-and-orphan-prevention). The runtime gives your node two shutdown primitives, and they are deliberately not the same thing: * The **cancellation token** (`node_runner.cancellation_token()`) is a *signal*: it resolves when shutdown begins, so in-flight work can notice and stop. Nothing waits for the code that follows it. * **Shutdown hooks** (`node_runner.on_shutdown(...)`) are *awaited obligations*: registered cleanup that the runtime itself runs to completion (bounded by a grace window) before `run()` returns. Use the token to stop working; use a hook to finish cleaning up. ## The stop paths [Section titled “The stop paths”](#the-stop-paths) Every way a node can be asked to stop converges on the same cancellation token, and therefore on the same sequence below: * [`peppy node stop `](/guides/node_stack/#stopping-your-node): the daemon sends an in-band shutdown request over messaging. No unix signal is involved. * **Daemon teardown**: a clean daemon shutdown (`Ctrl+C`, `systemctl stop`) sends the same in-band request to every spawned node, as does `peppy node add` when it replaces a node that has running instances. * **`SIGINT` / `SIGTERM` delivered to the node process**: the runtime installs its own signal handlers, so a plain `kill` (or `Ctrl+C` on a standalone node) is just another route to the token. Your node needs no signal handling of its own. * **Daemon-liveness loss**: the node’s watchdog cancels the token after `daemon_grace_secs` without a daemon heartbeat, so an orphaned node tears itself down. * **A setup error**: if your setup function returns an error, the runtime still cancels the token and runs the hooks registered up to that point (so a lock acquired early in setup is released even when bringup fails halfway). * **Programmatic cancel**: your own code may cancel the token to request shutdown from inside the node. This is how a one-shot node ends itself once its work is done. Unlike the daemon-driven paths above (which remove the instance from the stack), a node that exits on its own stays listed in a terminal state: `finished` for the clean exit that follows a cancel-and-return, or `failed` if it exits with an error. See [Instance health and lifecycle](/guides/node_stack/#instance-health-and-lifecycle). ## The shutdown sequence [Section titled “The shutdown sequence”](#the-shutdown-sequence) Once any of those paths fires, the runtime drives one ordered sequence: 1. **The cancellation token is cancelled.** Every `token.cancelled()` resolves; loops that select on it should stop doing work. Background tasks keep running for now; services (health, your own endpoints) remain reachable. 2. **Shutdown hooks run**, sequentially, in **reverse registration order** (last registered, first run), all within **one shared grace window** (`lifecycle.shutdown_grace_secs`). The messenger is still connected, so hooks can use the [datastore](/advanced_guides/datastore/), services, and topics. 3. **Task teardown.** In Python, the runtime now cancels the node’s remaining asyncio tasks and waits for them to finish (their `try`/`finally` blocks run, best effort). In Rust, `run()` returns and the tokio runtime is dropped: spawned tasks are simply dropped wherever they last yielded, which is why cleanup must not live in them. 4. **The process exits.** On the stop paths driven by the daemon, the daemon has been waiting in parallel since step 1. It does not force-kill at the hook deadline: it allows for the node’s whole cooperative exit (the hook grace window, then task teardown, and in Python the event-loop join and interpreter finalize) and only `SIGKILL`s the process group of a node still alive at that later force-kill deadline, reporting it as force-killed. Reverse registration order mirrors how resources are acquired: setup acquires the lock first and brings hardware up second, so teardown disables hardware first and releases the lock last, like destructors. ## Registering hooks [Section titled “Registering hooks”](#registering-hooks) Register hooks during setup, as soon as the resource they release exists. A hook registered after shutdown has begun may never run. * Python The callback may be a plain function or an `async def`; a returned awaitable runs on the node’s event loop. Exceptions raised by a hook are printed and the remaining hooks still run: ```python async def setup(params, node_runner: NodeRunner): await store(node_runner, LOCK_KEY, b"locked", Encoding.TEXT_PLAIN, 3.0) async def release_lock(): await remove(node_runner, LOCK_KEY, response_timeout_secs=2.0) node_runner.on_shutdown(release_lock) ``` * Rust The hook is any `Future + Send + 'static`. Capture an `Arc` clone if the cleanup needs messaging; a panicking hook is contained and logged, and the remaining hooks still run: ```rust NodeBuilder::new().run(|params: Parameters, node_runner| async move { datastore::store(&node_runner, LOCK_KEY, b"locked".to_vec(), Encoding::TEXT_PLAIN, TIMEOUT).await?; let runner = node_runner.clone(); node_runner.on_shutdown(async move { if let Err(e) = datastore::remove(&runner, LOCK_KEY, TIMEOUT).await { tracing::warn!("failed to release lock: {e}"); } }); Ok(()) }) ``` Long-running work still belongs in spawned tasks that watch the token: * Python ```python token = node_runner.cancellation_token() while not token.is_cancelled(): do_work() await asyncio.sleep(interval) # stop working; cleanup happens in hooks ``` * Rust ```rust loop { tokio::select! { _ = token.cancelled() => break, // stop working; cleanup happens in hooks _ = interval.tick() => { /* do work */ } } } ``` Inside a hook the token is already cancelled, so `token.is_cancelled()` reports true and awaiting `token.cancelled()` returns immediately. ## Grace windows [Section titled “Grace windows”](#grace-windows) Two settings in `~/.peppy/conf/peppy_config.json5` bound the lifecycle (see [Daemon configuration](/advanced_guides/daemon_config/)); the daemon resolves both once and ships them to every node it spawns: * **`lifecycle.shutdown_grace_secs`** (default **5**, minimum 1) is the node’s cooperative-cleanup budget, and it bounds two nested windows. Node-side, the runtime bounds the *entire hook phase* by this value, so a stuck hook is abandoned at the deadline and the node still exits on its own. Daemon-side, a stop path waits this window **plus** a fixed runtime-teardown allowance (the asyncio event-loop join, bounded by an internal 5s backstop, plus interpreter finalize) before force-killing, so a node that spends its full hook budget and then tears down cleanly is never mistaken for stuck. Raise it if your node legitimately needs longer to park actuators; the daemon’s force-kill deadline rises with it. * **`lifecycle.daemon_grace_secs`** (default **180**, minimum 30) decides *when* shutdown starts on the daemon-death path (how long a node tolerates a silent daemon before tearing itself down). It does not change how long cleanup gets once shutdown starts. Caution On the daemon-death path there is no force-kill backstop: the daemon that would have delivered the `SIGKILL` is gone. The node-side bound on the hook phase is the only thing standing between a hung cleanup and an orphaned process, which is why all hooks share one hard window rather than getting one each. The window is enforced at await points. A hook that blocks synchronously (a stuck CAN read, a `while True: pass`) cannot be interrupted by the node itself; on daemon-driven stop paths the force-kill covers it, on the daemon-death path it cannot. Keep synchronous work inside hooks short. ## Python specifics [Section titled “Python specifics”](#python-specifics) Python nodes get the same sequence with a few extra mechanics: * Hook coroutines run on the node’s own asyncio event loop, which is still serving background tasks at that point. Tasks are cancelled only **after** the last hook finishes, so a hook can still await results produced by the rest of the node. * After the hooks, the runtime cancels the remaining tasks and gathers them: `try`/`finally` blocks run, but as cancelled code racing process exit they are best effort. Cleanup that must happen (or that needs messaging) belongs in `on_shutdown`, not in `finally`. * A node whose setup function is synchronous has no persistent event loop; its async hooks run on a dedicated one-off loop (`asyncio.run`) instead. Sync hooks are called directly in both cases. * The event-loop thread is joined (bounded by an internal 5 second backstop) before `run()` returns, so no Python frame is executing native code when the interpreter finalizes. * One limitation: an **in-band stop that arrives while an async setup is still running** takes effect only once setup completes, because the runner is blocked waiting for the setup coroutine. Signals and the daemon watchdog do interrupt a stuck async setup; a stuck setup asked to stop in-band is ended by the daemon’s force-kill instead. ## Force backstops [Section titled “Force backstops”](#force-backstops) Cooperative shutdown is bounded at every level, so a misbehaving node can always be removed: * **Daemon force-kill**: any daemon-driven stop path `SIGKILL`s the node’s whole process group once the force-kill deadline (the grace window plus the runtime-teardown allowance, see [Grace windows](#grace-windows)) elapses. `peppy node stop` reports whether the instance exited gracefully or had to be force-killed. * **Second signal**: while a signal-initiated shutdown is in flight, a second `SIGINT`/`SIGTERM` makes the node exit immediately with the conventional `128 + signo` code (130 or 143), skipping the remaining cleanup. Pressing `Ctrl+C` twice always works. ## What a node never needs [Section titled “What a node never needs”](#what-a-node-never-needs) The runtime owns the whole lifecycle, so a node should contain none of the following; each one either duplicates the runtime or defeats its guarantees: * **Signal handlers.** `SIGINT`/`SIGTERM` are routed to the cancellation token for you. * **Cleanup in a spawned task.** A task watching the token races the teardown in step 3 of [the sequence](#the-shutdown-sequence) and is not guaranteed to run again after the token fires; only `on_shutdown` hooks are awaited. * **`std::process::exit` / `sys.exit` after cleanup.** `run()` returns once the hooks finish and the process exits normally; an explicit exit skips the remaining teardown. To stop the node from inside, cancel the token and return. Exiting cleanly this way has the daemon record the instance as terminal `finished`; a non-zero exit or crash is recorded as `failed` ([Instance health and lifecycle](/guides/node_stack/#instance-health-and-lifecycle)). # Stack benchmark > Measure the messaging latency of every interface in your running stack `peppy stack benchmark` reports the **latency** of the topics, services, and actions that wire each node to its dependencies, measured against the **already-running** stack. It prints p50 / p90 / mean per interface as **two separate tables**, because the two kinds of numbers answer different questions and must not be read side by side: * **Synthetic probes**: handler-free round-trips with payloads sized from the message schema. This is the fixed cost of the messaging plumbing, per edge, for every topic, service, and action. * **Real traffic**: the observe-only one-way delivery latency of a topic’s live messages, full payload included. This is what a consumer actually experiences: a camera topic streaming multi-megabyte frames can legitimately read 30x above its own plumbing cost, and that difference is payload, not overhead. It covers **both** ways a node can depend on another: * **direct** dependencies (`depends_on.nodes`), drawn with a light arrow `→`; and * dependencies resolved through **contract implementation** (`depends_on.contracts` matched by a producer’s `manifest.implements`), drawn with a heavy arrow `➔`. See [Contract implementation](/advanced_guides/contract_implementation/). Use it to answer questions like “is this service round-trip as fast as I expect?”, “how long does a frame take to reach its consumer?”, or “did my last change make a hot path slower?” (each run is compared against the previous run on the same machine). ## Running it [Section titled “Running it”](#running-it) The daemon must be running with a launched stack (see [Core node functions](/advanced_guides/core_node_functions/)). ```shell # Benchmark every dependency edge with the defaults (200 samples, 20 warmup). peppy stack benchmark # Tune the sample counts and per-sample timeout. peppy stack benchmark --samples 500 --warmup 50 --per-sample-timeout-ms 1000 ``` | Flag | Default | Meaning | | ------------------------- | ------- | --------------------------------------------------------- | | `--samples` | `200` | Timed samples per interface, after warmup. | | `--warmup` | `20` | Warmup samples per interface, discarded before measuring. | | `--per-sample-timeout-ms` | `2000` | Per-sample probe/observe timeout. | ## Reading the tables [Section titled “Reading the tables”](#reading-the-tables) One row is emitted **per consumed artifact** (each topic, service, or action a consumer is wired to), so a node that consumes the same producer as both a topic and a service gets one row for each. A **topic edge appears in both tables**: a synthetic `node-probe` row for its plumbing cost and a `delivery` row for its real traffic. The `edge` and `binding` columns together keep rows distinct even when several share a producer. The **synthetic table** collects every handler-free round-trip probe. All of its rows are timed on a single clock (no `clock` column needed) and carry payloads sized from the message schema, so they are comparable with each other: ```plaintext Synthetic probes: handler-free round-trips, schema-sized payloads (200 samples/interface) ┌──────────────────┬──────────┬────────────┬────────┬────────┬────────┬─────┬──────┬────────────────────┐ │ edge │ binding │ measure │ p50 │ p90 │ mean │ n │ Δp50 │ note │ ├──────────────────┼──────────┼────────────┼────────┼────────┼────────┼─────┼──────┼────────────────────┤ │ brain:v1 │ left_arm │ act-probe │ 240µs │ 310µs │ 255µs │ 200 │ - │ payload 32B → 16B │ │ → arm:v1 │ │ │ │ │ │ │ │ │ │ /move_arm │ │ │ │ │ │ │ │ │ │ brain:v1 │ camera │ node-probe │ 195µs │ 250µs │ 207µs │ 200 │ - │ camera:v1; payload │ │ ➔ camera_mock:v1 │ │ │ │ │ │ │ │ 0B → ≥56B │ │ /frames │ │ │ │ │ │ │ │ │ │ brain:v1 │ camera │ svc-probe │ 180µs │ 220µs │ 191µs │ 200 │ - │ camera:v1; payload │ │ ➔ camera_mock:v1 │ │ │ │ │ │ │ │ 0B → ≥32B │ │ /frame_info │ │ │ │ │ │ │ │ │ └──────────────────┴──────────┴────────────┴────────┴────────┴────────┴─────┴──────┴────────────────────┘ ``` The **real-traffic table** collects the observe-only measurements of live messages, full payload included. All of its rows are one-way `delivery` measurements (no `measure` column needed), so it carries the `clock` column instead: ```plaintext Real traffic: observe-only one-way delivery of live topic messages (200 samples/interface) ┌──────────────────┬──────────┬───────────┬────────┬────────┬────────┬─────┬──────┬────────────┐ │ edge │ binding │ clock │ p50 │ p90 │ mean │ n │ Δp50 │ note │ ├──────────────────┼──────────┼───────────┼────────┼────────┼────────┼─────┼──────┼────────────┤ │ brain:v1 │ camera │ same-host │ 1.20ms │ 1.80ms │ 1.31ms │ 200 │ - │ camera:v1 │ │ ➔ camera_mock:v1 │ │ │ │ │ │ │ │ │ │ /frames │ │ │ │ │ │ │ │ │ └──────────────────┴──────────┴───────────┴────────┴────────┴────────┴─────┴──────┴────────────┘ ``` * **edge**: the dependency, wrapped over three lines: the **consumer**, then the kind arrow + **producer**, then the consumed `/interface` (topic, service, or action name). `→` is a direct `depends_on.nodes` edge; `➔` is one resolved through contract implementation (the `note` names the contract). * **binding**: the dependency binding (`link_id`) this edge was measured through. A node can consume the same interface from one producer via several bindings; this column tells those rows apart. * **measure** (synthetic table): how the row was probed, color-coded in the terminal for a quick scan: **svc-probe** (service round-trip, blue), **act-probe** (action round-trip, magenta), and **node-probe** (topic edge’s producer-node round-trip, cyan). What each one measures is detailed below. * **clock** (real-traffic table): clock-alignment confidence for the one-way delivery measurement. * **p50 / p90 / mean**: the latency distribution, in ns / µs / ms. * **n**: how many samples were collected (an unreachable or idle edge shows `0`). * **Δp50**: change in the median versus the previous run **on this machine** (baselines are machine-local, so numbers are never compared across machines). * **note**: for `➔` rows, the contract the edge was resolved through; for probe rows, the measured payload sizes (see [Payload sizing](#payload-sizing)); plus any diagnostic (e.g. a suppressed cross-host value, or a topic with no live traffic). A legend repeating all of this prints beneath the tables: ```plaintext Legend: edge → direct dependency (depends_on.nodes) ➔ resolved through contract implementation (the note names the contract) synthetic round-trips on a single clock; the producer's framework replies and handlers never run, with payloads sized from the message schema svc-probe round-trip to the service act-probe round-trip to the action's goal service (no goal is created) node-probe topic edge: round-trip to the producer node's framework, reply sized from the topic schema (the topic itself is never published; topic QoS does not apply) real observe-only: delivery is the one-way receive−source latency of the topic's own live messages, full payload included binding the dependency binding this edge was measured through; a node can consume the same interface from one producer via several bindings clock same-host exact (producer shares this host's clock) corrected cross-host, adjusted via the producer's measured offset flagged implausible delta, suppressed (deploy PTP/NTP) note the contract (➔ edges) and, for probe rows, the measured payload sizes (request → response; `≥` = schema lower bound) Δp50 median vs the previous run on this machine Benchmarking never triggers a real handler, never publishes onto a real topic, and never creates a goal. ``` ## What each metric means (and what it does not) [Section titled “What each metric means (and what it does not)”](#what-each-metric-means-and-what-it-does-not) * **`svc-probe` / `act-probe` (services and actions)**: the round-trip time of a framework **probe** to the endpoint: caller → router → producer → framework reply → back. The probe carries a **real-payload-sized** request and asks the producer to reply with a real-payload-sized body, so the round-trip reflects real serialization and transport. But the framework answers it **before** your handler runs, so it **excludes the handler’s own execution time** (and probing an action’s goal service **does not create a goal**). It is clock-independent (a single-clock round-trip), so it is trustworthy regardless of host or clock sync. * **`node-probe` (topic edges, synthetic table)**: a topic is fire-and-forget on the wire, so there is nothing on the topic path that can answer a probe without publishing real traffic. Instead, the topic edge’s synthetic row probes the **producer node’s always-on framework service** (`node_health`) over the same session and links the topic uses, asking for a reply sized from the **topic’s message schema**. Like the other probes it is clock-independent and no handler runs. Two honesty caveats: it rides the query path, so the topic’s QoS (priority / congestion / express) does not apply; and for a schema with variable-length fields the payload is a lower bound, so a `≥56B` probe says nothing about moving a real 6MB frame. That cost is exactly what the `delivery` row shows. * **`delivery` (topics, real-traffic table)**: the **real** producer → consumer one-way delivery latency on live traffic (`receive_time − source_time`). This is observe-only: the benchmark subscribes to the producer’s actual stream and times real messages, so for a camera it reflects the cost of moving a full frame, not a token-sized probe. It is **exact when the producer and the core node share a host**. Across hosts it depends on clock synchronization (see below). This is why the report is two tables: a one-way `delivery` row carrying a multi-megabyte frame can easily read 30× above the same edge’s `node-probe`, because the probe prices the plumbing while delivery prices the payload. Comparing across the tables tells you *where the time goes* (fixed messaging cost vs payload transfer); comparing within a table compares like with like. Services and actions have no real-traffic rows **by design**: passively timing a real service call would require real callers, and issuing one ourselves would run your handler (see [Safety](#safety-benchmarking-never-triggers-your-handlers)). ### Payload sizing [Section titled “Payload sizing”](#payload-sizing) Probe rows note the payload they measured in a `payload request → response` form (for example `payload 32B → 16B`), sized from the interface’s message schema. A `node-probe` row’s response side is sized from the **topic’s** message schema (a topic has no request leg, so its request side reads `0B`): * A `≥` prefix (e.g. `≥32B`) marks a **schema lower bound**: the format has a variable-length field (a string, bytes, or unbounded array), so the real message is at least that big. * `payload 0B → …` means the request side carries no schema fields (the probe still sends a tiny framing header on the wire). If a row adds **`(rebuild producer for sized replies)`**, the producer node never returned the requested response size, because it is built against a framework version that predates sized probes, so its response side wasn’t really measured. Rebuild that producer node and re-run. (The daemon and CLI versions don’t matter for this; it’s keyed off the *producer node’s* framework version.) ## Clocks and cross-host timing [Section titled “Clocks and cross-host timing”](#clocks-and-cross-host-timing) One-way **topic delivery** is the only measurement that depends on clocks: * **Same host**: `receive − source` is exact. The `clock` column reads `same-host`. * **Multiple hosts**: the two hosts’ clock offset can equal or exceed sub-millisecond latencies. The benchmark handles this in layers: * With **PTP** (gPTP / IEEE-1588) deployed on your network, the system clocks are disciplined to each other and cross-host numbers are trustworthy with no extra work, so peppy benefits transparently. * Otherwise, the benchmark asks each producer for its measured offset to the core node (an NTP-style exchange) and **corrects** the number. The `clock` column reads `corrected`. Accuracy is roughly the LAN sync asymmetry (tens of µs to sub-ms); running NTP/chrony keeps this bounded. * If a corrected delta still comes back **negative or implausibly large**, the clocks are not adequately synchronized: the number is **suppressed** and the `clock` column reads `flagged`. Rely on the round-trip probe rows for that edge, and deploy PTP or NTP. The round-trip probe rows never depend on clocks, so they are always reported. ## Safety: benchmarking never triggers your handlers [Section titled “Safety: benchmarking never triggers your handlers”](#safety-benchmarking-never-triggers-your-handlers) A benchmark message can **never** run the real handler of any topic, service, or action, by construction: * Services and actions are measured only with framework **probe** queries, which the framework auto-answers, so your service handler is never called, and probing an action’s goal service **does not create a goal** or start the action engine. The probe carries a real-payload-sized body purely to size the transport; the framework reads only its small size header to shape the reply and never decodes the body into a request or passes it to your handler. * A topic edge’s **node-probe** targets the producer node’s built-in `node_health` framework service with the same auto-answered probe queries: it **never publishes onto the topic**, so no subscriber can ever receive a synthetic message, and no user code runs on the producer. * Real topic latency is **observe-only**: the benchmark subscribes but never publishes onto a real topic. So you can safely benchmark a live, production stack without side effects. # System clock > Synchronize a node's wall clock against the core node, or subscribe to periodic clock ticks for continuous correlation. Every node in a peppy stack runs against its own OS wall clock. That’s fine on a single host. The trouble starts when two nodes need to compare timestamps. For example, matching a sensor reading to the controller command that triggered it, or merging logs from multiple machines into a single timeline. Their clocks drift independently, so “1.7 s ago” on one node no longer points to the same instant as “1.7 s ago” on another. `peppylib::clock` exposes two helpers that let any node measure the offset between its local clock and the core node’s clock: * `synchronize`: a one-shot NTP-style request/response. Returns the offset and the observed round-trip delay. * `subscribe`: a long-lived subscription to the periodic `clock` topic the core node publishes. Each `ClockTick` carries the core node’s clock at emission. They are available from Rust as `peppylib::clock::synchronize` and `peppylib::clock::subscribe`, and from Python as `from peppylib.clock import synchronize, subscribe`. Note Neither helper adjusts the local OS clock. They measure and report; aligning a timestamp is left to the caller (`local_now() + sync.offset_ns`). ## When to use each [Section titled “When to use each”](#when-to-use-each) | | `synchronize` | `subscribe` | | ------------ | ------------------------------------------------------------- | -------------------------------------------------------------- | | Pattern | Request/response | Topic subscription | | Result | Offset **and** observed RTT | Snapshot of the core node’s clock | | Staleness | Bounded by the RTT you just measured | \~one one-way network delay (uncorrected) | | Cost per use | One round trip | Passive (ticks arrive at 10 Hz) | | Use when | You need a precise offset before stamping a recorded artifact | You want continuous correlation, e.g. driving a status display | If unsure, start with `synchronize`; it gives you both numbers and you can call it once at startup. Reach for `subscribe` when you need the steady drumbeat. ## `synchronize` [Section titled “synchronize”](#synchronize) `synchronize` performs the standard NTP 4-timestamp exchange against the core node’s `CLOCK` service: the helper stamps `t0` before sending, the core node stamps `t1` on receive and `t2` before responding, and the helper stamps `t3` on receive. It returns a `ClockSync` with three fields: * `offset_ns` (`i64` in Rust, `int` in Python): signed nanoseconds. `local + offset_ns ≈ core_node`. Negative means the local clock leads the core node. * `round_trip_delay_ns` (`u64` in Rust, `int` in Python): the round-trip delay observed on this exchange. * `raw`: the wire response with `server_recv_time` (t1), `server_send_time` (t2), and the echoed `client_send_time` (t0), exposed for callers that want to do their own analysis. The second argument is a response timeout. Rust accepts `Option`; Python accepts `float | None` in seconds. Pass `None` (or omit it in Python) to use the default of 10 seconds. * Python src/my\_node/\_\_main\_\_.py ```python import time from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppylib.clock import synchronize async def setup(_params: Parameters, node_runner: NodeRunner) -> None: sync = await synchronize(node_runner, response_timeout_secs=3.0) local_ns = time.time_ns() aligned_ns = local_ns + sync.offset_ns print( f"offset {sync.offset_ns} ns, RTT {sync.round_trip_delay_ns} ns; " f"local={local_ns}, aligned={aligned_ns}" ) def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use std::time::{Duration, SystemTime, UNIX_EPOCH}; use peppygen::{NodeBuilder, Parameters, Result}; use peppylib::clock::synchronize; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { let sync = synchronize(&node_runner, Some(Duration::from_secs(3))).await?; let local_ns = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock before unix epoch") .as_nanos() as i128; let aligned_ns = local_ns + sync.offset_ns as i128; println!( "offset {} ns, RTT {} ns; local={}, aligned={}", sync.offset_ns, sync.round_trip_delay_ns, local_ns, aligned_ns, ); Ok(()) }) } ``` ## `subscribe` [Section titled “subscribe”](#subscribe) `subscribe` opens a long-lived subscription to the core node’s `clock` topic and returns a `ClockSubscription`. Call `on_next_tick` in a loop to receive each tick; it returns `None` when the subscription closes (typically because the core node went away). The core node publishes ticks at **10 Hz** (every 100 ms) by default: high enough to correlate logs across nodes, low enough not to flood the bus. The rate is set on the core node side via `CoreNodeArguments.clock_publish_interval`. Each `ClockTick` carries a single field, `time` (`u64` in Rust, `int` in Python): nanoseconds since the Unix epoch, stamped by the core node at emission. Because the tick is one-way, the value is stale by roughly one one-way network delay on receipt; there is no RTT correction. Use `synchronize` if you need bounded staleness. * Python src/my\_node/\_\_main\_\_.py ```python import asyncio from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppylib.clock import subscribe async def follow_clock(node_runner: NodeRunner) -> None: sub = await subscribe(node_runner) while True: tick = await sub.on_next_tick() if tick is None: # Core node closed the subscription. return print(f"core node wall time: {tick.time} ns") async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(follow_clock(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use peppygen::{NodeBuilder, Parameters, Result}; use peppylib::clock::subscribe; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { tokio::spawn(async move { let mut sub = subscribe(&node_runner) .await .expect("subscribe should succeed"); while let Some(tick) = sub .on_next_tick() .await .expect("on_next_tick should not error") { println!("core node wall time: {} ns", tick.time); } // Subscription closed; core node went away. }); Ok(()) }) } ``` ## Behavior notes [Section titled “Behavior notes”](#behavior-notes) * **QoS profile is `SensorData`.** Stale ticks are dropped on slow subscribers rather than back-pressuring the publisher: a clock value the network couldn’t deliver fresh is not worth delivering late. * **`synchronize` is hardened against a misbehaving server.** Offset and delay are computed in widened arithmetic and saturated when narrowed back to `i64` / `u64`, so a peer returning extreme `t1`/`t2` cannot wrap the result or flip its sign. * **`on_next_tick` returning `None` is terminal.** Treat it as “core node went away” and rebuild the subscription if you need to keep listening. * **Default timeout for `synchronize` is 10 seconds.** Pass an explicit timeout from latency-sensitive paths so a slow or unreachable core node does not stall your node. ## Sim and replay clocks [Section titled “Sim and replay clocks”](#sim-and-replay-clocks) Most of the time the core node feeds these helpers from its own OS wall time. For simulators and bag replay you usually want the same node binaries to read a virtual clock instead, without rebuilding. peppy makes this a deployment-time choice through two knobs: * A typed `framework` block on each per-instance launcher entry, sibling to `arguments` and `env_vars`. * A daemon-wide CLI flag `--clock-source=wall|sim` that decides the default for instances that omit the override. Resolution order, applied once in the daemon before each spawned node receives its runtime config: 1. The instance’s `framework.use_sim_time` if it is set. 2. The daemon’s `--clock-source` flag (`wall` is the default). The wire format does not change. `ClockTick` and `ClockResponse` carry only timestamps; the daemon decides internally which source feeds them. In wall mode the daemon stamps every `t1`/`t2` and every published tick from its own OS clock. In sim mode the daemon stops publishing, subscribes to the `clock` topic, and answers `synchronize` from a cache populated by an external publisher. Subscribers see the same shape either way. ### Launcher syntax [Section titled “Launcher syntax”](#launcher-syntax) peppy\_launcher.json5 ```json5 { peppy_schema: "launcher/v1", deployments: [ { source: { local: "./uvc_camera" }, instances: [ { instance_id: "camera_front", arguments: { device: "/dev/video0" }, framework: { use_sim_time: true }, }, ], }, ], } ``` Omit `framework` (or omit `use_sim_time` inside it) to fall through to the daemon default. Setting `use_sim_time: false` on an instance forces wall mode even when the daemon flag is `--clock-source=sim`. ### `PeppyClock` and the generated `clock` module [Section titled “PeppyClock and the generated clock module”](#peppyclock-and-the-generated-clock-module) For hot-path code that just wants “what time is it now” without caring whether the node was launched in wall or sim mode, peppylib exposes `PeppyClock` (in `peppylib.clock` / `peppylib::clock`) and the generator emits a pre-bound clock module (`peppygen.clock` in Python, `peppygen::clock` in Rust): * `init(node_runner)`: async; opens a `clock` subscription in sim mode (a no-op in wall mode) so the first `now_ns` after a tick lands returns immediately. Idempotent. Call it once at the top of your setup function. * `now_ns()`: sync; reads the current core-node-aligned time. Returns `Err(PeppyError::ClockNotReady)` (Rust) or raises `RuntimeError` (Python) before `init`, and in sim mode before any `ClockTick` has been observed. - Python src/my\_node/\_\_main\_\_.py ```python from peppygen import NodeBuilder, NodeRunner, clock from peppygen.parameters import Parameters async def setup(_params: Parameters, node_runner: NodeRunner) -> None: # Idempotent. No-op in wall mode; in sim mode this opens a # `clock` subscription so the background feeder is already # running before `now_ns()` is called below. await clock.init(node_runner) try: # Wall mode always returns the local OS time. Sim mode # raises RuntimeError until the feeder has cached at least # one ClockTick from the topic. now = clock.now_ns() print(f"core-node-aligned time: {now} ns") except RuntimeError: # Only reachable in sim mode, and only before the first # tick lands. Real apps would retry or wait on a tick. print("clock not ready yet") def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` - Rust src/main.rs ```rust use peppygen::{NodeBuilder, NodeRunner, Parameters, Result}; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { // Idempotent. No-op in wall mode; in sim mode this opens a // `clock` subscription so the background feeder is already // running before `now_ns()` is called below. peppygen::clock::init(&node_runner).await?; match peppygen::clock::now_ns() { // Wall mode always lands here. Sim mode reaches this // arm once at least one ClockTick has been observed. Ok(t) => println!("core-node-aligned time: {t} ns"), // Only reachable in sim mode, and only before the // first tick lands. Real apps would retry or wait on // a tick. Err(e) => println!("clock not ready yet: {e}"), } Ok(()) }) } ``` Prefer `synchronize` over `PeppyClock` when you need a bounded-staleness offset, since it observes a round trip and reports the RTT. `subscribe` is the right pick if you want every raw tick. For everything else, reach for the generated clock module’s `now_ns` (`peppygen.clock.now_ns` in Python, `peppygen::clock::now_ns` in Rust). # 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 *consume* 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. Topics also underpin two higher-level constructs: [contract implementation](/advanced_guides/contract_implementation), where a topic’s contract lives in a standalone contract document that producers implement, and [pairing](/advanced_guides/pairing), where two node instances exchange topics 1:1 in both directions over a two-role contract. For a side-by-side decision guide across every mechanism, see [Choosing a communication pattern](/advanced_guides/communication_patterns/). ## 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`: * Python ```json5 { peppy_schema: "node/v1", manifest: { name: "uvc_camera", tag: "v1", }, 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: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "uvc_camera"] }, } ``` * Rust ```json5 { peppy_schema: "node/v1", manifest: { name: "uvc_camera", tag: "v1", }, 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` (Python) / `peppygen::emitted_topics` (Rust). Each module exposes `declare_publisher(node_runner)`, which returns a `TopicPublisher`, and `build_message(...)`, which serializes the message fields into a payload. Declare the publisher once, then publish each message on it: * Python ```python import asyncio import time from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppygen.emitted_topics import video_stream async def emit_frames(node_runner: NodeRunner): # Declare the publisher once; every publish below then reuses it. publisher = await video_stream.declare_publisher(node_runner) frame_id = 0 while True: payload = video_stream.build_message( video_stream.MessageHeader(stamp=time.time(), frame_id=frame_id), "rgb8", 640, 480, bytes([1, 2, 3]), ) await publisher.publish(payload) frame_id = (frame_id + 1) % (2**32) await asyncio.sleep(0.1) async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(emit_frames(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust ```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 { // Declare the publisher once; every publish below is then lock-free. let publisher = video_stream::declare_publisher(&node_runner_clone) .await .expect("failed to declare video_stream publisher"); let mut frame_id = 0u32; loop { if let Ok(payload) = video_stream::build_message( video_stream::MessageHeader { stamp: std::time::SystemTime::now(), frame_id, }, "rgb8".to_owned(), 640, 480, vec![1, 2, 3], ) { let _ = publisher.publish(payload).await; } frame_id = frame_id.wrapping_add(1); tokio::time::sleep(Duration::from_secs_f64(0.1)).await; } }); Ok(()) }) } ``` `build_message` takes each field from the `message_format` in order (no `node_runner` argument) and returns the payload that `publisher.publish` sends; in Rust it returns a `Result` you unwrap, in Python it returns the payload directly and raises on invalid input. Nested objects become generated types whose fields match the object definition: a struct in Rust (`video_stream::MessageHeader`), a dataclass in Python (`video_stream.MessageHeader`). A producer publishes a **single stream** regardless of how many consumers are listening, and it never knows about the bindings consumers use to address it. Starting the producer before any consumer (or after them) is equally valid; consumers attach when they show up. For a topic with a simple message format: ```json5 { name: "message_stream", qos_profile: "sensor_data", message_format: { message: "string" } } ``` `build_message` takes a single string argument: * Python ```python from peppygen.emitted_topics import message_stream publisher = await message_stream.declare_publisher(node_runner) payload = message_stream.build_message("hello world") await publisher.publish(payload) ``` * Rust ```rust use peppygen::emitted_topics::message_stream; let publisher = message_stream::declare_publisher(&node_runner).await?; let payload = message_stream::build_message("hello world".to_owned())?; publisher.publish(payload).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 `link_id` in the interface: * Python ```json5 { peppy_schema: "node/v1", manifest: { name: "web_video_stream", tag: "v1", depends_on: { nodes: [ { name: "uvc_camera", tag: "v1", link_id: "uvc_camera" }, ] }, }, interfaces: { topics: { consumes: [ { link_id: "uvc_camera", // References depends_on.nodes[].link_id name: "video_stream", // Topic name on that node }, ], }, }, execution: { language: "python", build_cmd: ["uv", "sync"], run_cmd: ["uv", "run", "web_video_stream"] }, } ``` * Rust ```json5 { peppy_schema: "node/v1", manifest: { name: "web_video_stream", tag: "v1", depends_on: { nodes: [ { name: "uvc_camera", tag: "v1", link_id: "uvc_camera" }, ] }, }, interfaces: { topics: { consumes: [ { link_id: "uvc_camera", // References depends_on.nodes[].link_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 By default, 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:v1` depends on `uvc_camera:v1`, but it does not exist in the stack ``` Pass `peppy node sync --include-repositories` (`-r`) to let the daemon fall back to the [repository cache](/advanced_guides/repositories/#syncing-against-repositories) for dependencies that aren’t in the stack. Tip To depend on a contract instead of a specific producer node, so any implementing node can fill the slot at launch time, declare the dep under `depends_on.contracts` rather than `depends_on.nodes`. See the [Contract implementation](/advanced_guides/contract_implementation) guide. ### Receiving messages [Section titled “Receiving messages”](#receiving-messages) The code generator creates a module for each consumed topic under `peppygen.consumed_topics` (Python) / `peppygen::consumed_topics` (Rust). The module name is `_` based on the `link_id` field; in this case `uvc_camera_video_stream`. Call `subscribe` once to obtain a held `Subscription`, then await `next` for each message: * Python ```python import asyncio from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppygen.consumed_topics import uvc_camera_video_stream async def receive_frames(node_runner: NodeRunner): # Same subscribe() as every cardinality: it covers the slot's complete # bound set, which for `one` is exactly one producer. The yielded # ProducerRef is constant here, so the loop ignores it. subscription = await uvc_camera_video_stream.subscribe(node_runner) async for _producer, frame in subscription: print(f"frame: {frame.width}x{frame.height}") async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(receive_frames(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust ```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 { // Same subscribe() as every cardinality: it covers the slot's complete // bound set, which for `one` is exactly one producer. The yielded // ProducerRef is constant here, so the loop ignores it. let mut subscription = uvc_camera_video_stream::subscribe(&node_runner).await?; while let Some((_, frame)) = subscription.next().await? { println!("frame: {}x{}", frame.width, frame.height); } Ok(()) }) } ``` `subscribe` takes: * `node_runner`: the node runner reference. and returns a `Subscription` covering the slot’s **complete bound producer set**: one producer-pinned wire subscription per bound producer, merged client-side behind the single `subscribe()` API. The shape is identical for every [cardinality](#dependency-cardinality); only the size of the bound set changes. Awaiting `subscription.next()` yields: * a `(producer, message)` pair for each message (`Ok(Some((producer, message)))` in Rust), where `producer` is a `ProducerRef` carrying the publisher’s full `(core_node, instance_id)` wire identity, so the consumer can tell messages from different bound producers apart, and `message` is the deserialized message, with fields matching the topic’s `message_format`. * `None` (`Ok(None)` in Rust) once the node is shutting down and no queued message remains, or when every source has closed. * an error if a received payload fails to deserialize: `Err(..)` in Rust, a raised exception in Python. The error carries producer context and neither tears down the subscription nor shrinks the bound set. Each underlying subscription holds an in-order buffer, so a message published between `next` calls is kept, not dropped; loop on `next` and nothing is lost in the gap between iterations. Message order is preserved independently per producer (no total ordering across producers is promised), and ready producers are merged fairly so one busy producer cannot indefinitely starve another. `ProducerRef` is `Eq + Hash`, so it keys per-producer state directly; to follow a single producer, filter on the yielded producer. There are no call-site producer-targeting arguments at all. Which producers reach a slot is decided entirely by application bindings (see [Bindings and routing](#bindings-and-routing) below): a slot receives messages only from the producer instances bound to it, and each resolved binding carries the producer’s full `(core_node, instance_id)` wire address, so the slot can never match a producer that merely shares the instance\_id on another core node. The producer segments of a keyexpr are never wildcarded: a federated router forwards traffic only for the explicitly bound producers, and every subscriber stays fully pinned (and auditable) in the zenoh admin space. That same full identity is what `next` returns with every message, so a consumer can pass it straight back into any producer-targeting call (a `ProducerRef` yielded by the slot’s own subscription is a member of the slot’s bound set by construction). Every generated consumed topic, service, and action module also exposes a bound-producer accessor for its slot, returning the runtime-resolved, immutable producer binding in application declaration order. The accessor’s return type encodes exactly the guarantee [launch validation](#validator-rules) established for the slot’s [cardinality](#dependency-cardinality), instead of restating it in comments: ```rust // Generated for every consumed module of a `cardinality: "one"` slot, // replacing bound_producers() for that slot. pub fn bound_producer(node_runner: &crate::NodeRunner) -> &ProducerRef; // Generated for every consumed module of a `cardinality: "one_or_more"` slot: // a never-empty ordered set whose first() is infallible. pub fn bound_producers(node_runner: &crate::NodeRunner) -> NonEmptyProducers<'_>; // Generated for every consumed module of a `cardinality: "zero_or_more"` slot: // a plain, possibly empty slice, so the empty branch is forced by the type // and is never dead code. pub fn bound_producers(node_runner: &crate::NodeRunner) -> &[ProducerRef]; ``` Python mirrors the split by name and annotation: a `one` slot generates `bound_producer(node_runner) -> peppylib.ProducerRef`, and the multi cardinalities generate `bound_producers(node_runner) -> List[peppylib.ProducerRef]` (documented never-empty for `one_or_more`). Every module sharing the slot’s `link_id` returns the same set in the same order. The set is fixed when the node starts and is not live discovery: a producer disconnecting does not shrink it, trigger discovery, or cause automatic rebinding. A cardinality change is therefore visible at compile time in the consuming node: the accessor’s type changes and every call site that relied on the old guarantee must be revisited. This is intended; a cardinality flip is a semantic change, not a rebinding. ### Receiving messages continuously [Section titled “Receiving messages continuously”](#receiving-messages-continuously) To receive messages continuously, subscribe once and loop on `next`. The held subscription keeps every message in arrival order, so the loop never misses a message published between iterations. Each message is processed in a separate task so that the receive loop is not blocked: * Python ```python import asyncio import sys from peppygen import NodeBuilder, NodeRunner from peppygen.parameters import Parameters from peppygen.consumed_topics import uvc_camera_video_stream async def process_frame(producer, frame): print( f"got {frame.width}x{frame.height} frame " f"from {producer.core_node}/{producer.instance_id}" ) async def receive_frames(node_runner: NodeRunner): try: subscription = await uvc_camera_video_stream.subscribe(node_runner) except Exception as e: print(f"Failed to subscribe: {e}", file=sys.stderr) return # The subscription is an async iterator; the loop ends when it closes. # Hold a reference to each task until it finishes, otherwise the event # loop may garbage-collect it mid-flight. tasks: set[asyncio.Task] = set() async for producer, frame in subscription: task = asyncio.create_task(process_frame(producer, frame)) tasks.add(task) task.add_done_callback(tasks.discard) async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(receive_frames(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust ```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) { let mut subscription = match uvc_camera_video_stream::subscribe(&node_runner).await { Ok(subscription) => subscription, Err(e) => { eprintln!("Failed to subscribe: {e}"); return; } }; loop { match subscription.next().await { Ok(Some((producer, frame))) => { tokio::spawn(async move { println!( "got {}x{} frame from {}/{}", frame.width, frame.height, producer.core_node, producer.instance_id ); }); } Ok(None) => break, Err(e) => { eprintln!("Error receiving frame: {e}"); break; } } } } ``` In Python the `Subscription` is also an async iterator: `async for producer, message in subscription` is equivalent to looping on `next` and stops cleanly once the subscription closes. ### Consuming a multi-cardinality bound set [Section titled “Consuming a multi-cardinality bound set”](#consuming-a-multi-cardinality-bound-set) Nothing changes at the call site when the slot’s [cardinality](#dependency-cardinality) allows several producers: the same single `subscribe()` covers every bound producer, and the yielded `ProducerRef` tells the streams apart. `ProducerRef` is `Eq + Hash`, so it keys per-producer state directly: * Rust ```rust use peppygen::consumed_topics::camera_video_stream; use peppygen::{NodeBuilder, Parameters, ProducerRef, Result}; use std::collections::HashMap; fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { // One subscription covering every producer bound to the `camera` // slot. The shape is identical for every cardinality; only the // size of the bound set changes. // `one`: the subscription covers exactly one producer. // `one_or_more`: the merged subscription covers at least one producer. // `zero_or_more`: the slot may be empty; the subscription then // yields nothing until the node stops. let mut subscription = camera_video_stream::subscribe(&node_runner).await?; let mut frames_per_camera: HashMap = HashMap::new(); while let Some((producer, frame)) = subscription.next().await? { *frames_per_camera.entry(producer.clone()).or_insert(0) += 1; println!( "frame from {}@{}: {}x{} (total from this camera: {})", producer.instance_id, producer.core_node, frame.width, frame.height, frames_per_camera[&producer], ); } Ok(()) }) } ``` * Python ```python from peppygen.consumed_topics import camera_video_stream async def count_frames(node_runner): # One subscription covering every producer bound to the `camera` # slot; the yielded producer tells per-camera streams apart. subscription = await camera_video_stream.subscribe(node_runner) frames_per_camera: dict = {} async for producer, frame in subscription: frames_per_camera[producer] = frames_per_camera.get(producer, 0) + 1 print( f"frame from {producer.instance_id}@{producer.core_node}: " f"{frame.width}x{frame.height} " f"(total from this camera: {frames_per_camera[producer]})" ) ``` To follow a single camera instead, filter on the yielded producer (`bound_producers()` lists the members in binding order). ## Bindings and routing [Section titled “Bindings and routing”](#bindings-and-routing) Routing is **entirely consumer-side**. Producers publish unconditionally and are binding-agnostic; only the consumer’s `depends_on` slots and the application bindings that fill them decide which producer instance reaches which slot. ([Pairing](/advanced_guides/pairing) topics are the one exception: a pairing slot is routed by the pair itself, established with `--pair` at instance start, not by a binding.) A binding takes the form `KEY: VALUE`, where `KEY` is a `link_id` declared in the consumer’s `depends_on` and `VALUE` selects the slot’s target producer instance(s). Each bound target creates a private one-way channel from that producer instance to the consumer’s slot: a multi-cardinality slot expands into N independent application-selected bound edges sharing the same `link_id`. How many targets a slot takes is the slot’s declared [cardinality](#dependency-cardinality); there is no wildcard fallback and no discovery, only explicitly bound producers. In a launcher / stack config: ```json5 { source: { local: "./consumer" }, instances: [{ instance_id: "my_consumer", bindings: { uvc_camera: "my-camera-instance" }, }], } ``` or, when launching a single node during development: ```sh peppy node run --bind uvc_camera@my-camera-instance . ``` ### Dependency cardinality [Section titled “Dependency cardinality”](#dependency-cardinality) Every `depends_on.contracts` and `depends_on.nodes` entry may declare a `cardinality`, constraining how many producers the application may bind to the slot: ```json5 depends_on: { contracts: [ { name: "uvc_camera", tag: "v1", link_id: "camera", cardinality: "one_or_more" }, ], } ``` | Cardinality | Valid unique targets | Omitted binding | | --------------- | -------------------- | --------------- | | `one` / omitted | exactly 1 | error | | `one_or_more` | 1 or more | error | | `zero_or_more` | 0 or more | empty set | The binding value’s shape mirrors the slot’s cardinality. A `one` slot takes a scalar string only, exactly as above. A `one_or_more` or `zero_or_more` slot takes an **array** of instance ids; for `zero_or_more` the empty array `[]` is a valid definition, equivalent to omitting the binding entirely: ```json5 bindings: { camera: ["front_camera", "rear_camera"], } ``` Any other combination is an error: an array on a `one` slot (single-element arrays included), a scalar on a multi slot, and an empty array on a `one_or_more` slot (cardinality unmet). The CLI mirrors the array form by repetition, which accumulates on a multi slot and stays a hard error on a `one` slot; the empty set has no flag spelling, omission is it: ```sh peppy node run --bind camera@front_camera --bind camera@rear_camera . ``` Cardinality applies to a consumer’s dependency slot; it does not permit undeclared or unbound launched node instances, and it constrains the application-configured bound set **at startup**, not runtime availability: a producer disconnecting does not shrink the set, trigger discovery, or cause automatic rebinding. `depends_on.pairings` entries have no cardinality (a pairing is strictly 1:1 and expresses absence with its `optional` flag; a `cardinality` key on a pairing entry is a manifest error). The consuming API is uniform across cardinalities in everything except the [bound-producer accessor](#receiving-messages): topics subscribe to the slot’s complete bound set and yield the producing `ProducerRef` with every message, and [services](/advanced_guides/services) and [actions](/advanced_guides/actions) require the caller to select one explicit, membership-checked member of that set, whatever its size. The accessor itself is cardinality-typed: `one` generates the singular, infallible `bound_producer()`, `one_or_more` generates `bound_producers()` returning a never-empty set whose `first()` needs no unwrap, and `zero_or_more` generates `bound_producers()` returning a possibly empty slice, so the empty branch is forced by the type. For a `zero_or_more` slot bound to nothing, the subscription simply yields nothing until the node stops. ### Per-message routing [Section titled “Per-message routing”](#per-message-routing) For every message a producer publishes, each consumer instance applies one rule on its own slots: the message is delivered to every slot whose bound set names the producer, and to no other slot. A producer named by no binding is ignored by the consumer entirely; there is no wildcard fallback. Wildcard-subscribe-and-filter-in-process is deliberately not offered: a wildcard expresses interest in every same-namespace producer of the contract, so a federated router would forward traffic from unbound producers across the mesh, and the bound set would stop being auditable from the zenoh admin space. ### Worked example: `openarm01_backbone` [Section titled “Worked example: openarm01\_backbone”](#worked-example-openarm01_backbone) A consumer that wires two specific depth cameras to dedicated wrist slots: openarm01\_backbone/peppy.json5 ```json5 { manifest: { name: "openarm01_backbone", tag: "v1", depends_on: { contracts: [ { name: "depth_camera", tag: "v1", link_id: "wrist_left_camera" }, { name: "depth_camera", tag: "v1", link_id: "wrist_right_camera" }, ], }, }, // ... } ``` And the launcher that binds it: peppy\_launcher.json5 ```json5 { deployments: [ { source: { name: "depth_camera:v1" }, instances: [ { instance_id: "left_cam" }, { instance_id: "right_cam" }, ]}, { source: { name: "openarm01_backbone:v1" }, instances: [ { instance_id: "backbone_inst_1", bindings: { wrist_left_camera: "left_cam", wrist_right_camera: "right_cam", }}, ]}, ], } ``` Three contract statements follow from this manifest: 1. A frame from `left_cam` arrives only on the `wrist_left_camera_video_stream` slot. 2. A frame from `right_cam` arrives only on the `wrist_right_camera_video_stream` slot. 3. If the `wrist_right_camera` binding line were removed, the launch would be rejected: every declared slot except `zero_or_more` must be bound. A producer named by no binding is simply ignored, but a declared `one` or `one_or_more` slot with no binding is an error, not a silent gap. Dedicated slots (one `link_id` per camera, as here) and one multi-cardinality slot (`cameras: ["left_cam", "right_cam"]`) are both valid designs. Dedicated slots give each producer a distinct role in code; a multi slot treats the producers as an N-of-a-kind set behind one API. ### Validator rules [Section titled “Validator rules”](#validator-rules) The launcher validator runs these checks before the stack starts: 1. **Every `KEY` must name a declared slot, and every declared slot must resolve.** A `--bind KEY@VALUE` (or launcher `bindings:` entry) whose `KEY` matches no `depends_on.{nodes,contracts}` `link_id` is rejected; there are no free-form keys. In the other direction, a declared `one` or `one_or_more` slot the bindings leave out fails the launch with one error per unfulfilled slot; a `zero_or_more` slot with no binding resolves to the empty set. 2. **The value’s shape must match the slot’s cardinality.** A `one` slot takes a scalar only (an array is rejected, single-element and empty included), a multi slot takes an array only (a scalar is rejected), and an empty array meets only `zero_or_more` (on `one_or_more` it is a cardinality-unmet error). Repeated `--bind KEY@…` flags carry no shape and are checked by count: more than one occurrence on a `one` slot is rejected. Duplicate targets within one slot are rejected rather than deduplicated. 3. **Every target must satisfy the slot, checked per bound instance.** For node slots, each target producer’s `(node_name, node_tag)` must equal the slot’s declared pair (`BindingTargetMismatch` otherwise). For contract slots, each target’s `manifest.implements` must include the slot’s `(name, tag)` (`BindingContractNotImplemented` otherwise); see [Contract implementation](/advanced_guides/contract_implementation) for the implementation rules. 4. **Stack-wide `instance_id` uniqueness.** Every `instance_id` must be unique across the entire stack, not just within a `(node_name, node_tag)` group. The `--bind` syntax names producers by `instance_id`, so reusing one across two different node kinds would make the binding ambiguous. 5. **Bindings are stamped with the daemon’s `core_node`.** The `--bind KEY@instance_id` syntax names producers by `instance_id` alone, but the wire addresses producers by the full `(core_node, instance_id)` pair, since `instance_id` is only unique within one stack. The validator stamps the launching daemon’s `core_node` into every resolved binding, so the runtime never matches on `instance_id` alone: a slot subscribes with both wire fields set. A producer on another core node that happens to share an `instance_id` can never feed a bound slot. Bound-set member order is the application declaration order (launcher array order / CLI flag occurrence order), preserved verbatim into the runtime configuration. ## Message format types [Section titled “Message format types”](#message-format-types) The `message_format` supports the following field types: | Type | 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"` | `f32` | `float` | | `"f64"` | `f64` | `float` | | `"string"` | `String` | `str` | | `"bytes"` | `Vec` | `bytes` | | `"time"` | `std::time::SystemTime` | `float` (Unix epoch seconds) | Fields can also use complex types: * **Object**: a nested struct in Rust, a generated dataclass in Python: ```json5 header: { $type: "object", stamp: "time", frame_id: "u32" } ``` * **Array**: a variable-length list (`Vec` in Rust, `list[T]` in Python; an array of `u8` maps to `Vec` / `bytes`): ```json5 frame: { $type: "array", $items: "u8" } ``` * **Fixed-length array**: an array with a known size (`[T; N]` in Rust, a `list[T]` that must hold exactly `N` items in Python): ```json5 position: { $type: "array", $items: "f32", $length: 3 } ``` * **Optional**: a field that may be absent (`Option` in Rust, `T | None` in Python): ```json5 error_msg: { $type: "string", $optional: true } ``` # 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 { peppy_schema: "node/v1", manifest: { name: "controller", tag: "v1", }, 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 { peppy_schema: "node/v1", manifest: { name: "controller", tag: "v1", }, 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) Peppy 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. A node can drive multiple goals of the same action concurrently; its goal handler decides whether to accept a new goal or reject it, for example while the arm it targets is already moving. These three are not the whole story: [pairing](/advanced_guides/pairing/) covers exclusive bidirectional exchanges between two instances, and [contract implementation](/advanced_guides/contract_implementation/) lets any implementing producer fill a consumer’s slot. For a side-by-side decision guide, see [Choosing a communication pattern](/advanced_guides/communication_patterns/). ## 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 { peppy_schema: "node/v1", manifest: { name: "hello_receiver", tag: "v1", depends_on: { nodes: [ { name: "hello_world_param", tag: "v1", link_id: "hello_world_param", }, ] }, }, interfaces: { topics: { consumes: [ { link_id: "hello_world_param", name: "message_stream", } ], } }, execution: { language: "python", build_cmd: [ "uv", "sync" ], run_cmd: [ "uv", "run", "hello_receiver" ] }, } ``` * Rust peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "hello_receiver", tag: "v1", depends_on: { nodes: [ { name: "hello_world_param", tag: "v1", link_id: "hello_world_param", }, ] }, }, interfaces: { topics: { consumes: [ { link_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): # Subscribe once; the held subscription buffers every message in order, so # iterating never drops a message published between iterations. subscription = await hello_world_param_message_stream.subscribe(node_runner) async for producer, message in subscription: print(f"Received from {producer.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) { // Subscribe once; the held subscription buffers every message in order, so // looping on `next` never drops a message published between iterations. let mut subscription = match hello_world_param_message_stream::subscribe(&node_runner).await { Ok(subscription) => subscription, Err(e) => { eprintln!("Failed to subscribe: {e}"); return; } }; loop { match subscription.next().await { Ok(Some((producer, message))) => { println!("Received from {}: {}", producer.instance_id, message.message) } Ok(None) => break, 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:v1` depends on `hello_world_param:v1`, 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: ```plaintext $ peppy stack list Node stack ┌──────────────────────────────────────┬───────┬───────────┬───────────────────────────────────────────────────┐ │ NODE │ STAGE │ INSTANCES │ PATH │ ├──────────────────────────────────────┼───────┼───────────┼───────────────────────────────────────────────────┤ │ core-node-sweet-germain-4388:v0.10.0 │ Root │ 1 running │ ~/workspace/peppy │ │ hello_receiver:v1 │ Ready │ 0 │ ~/.peppy/built_nodes/hello_receiver_v1.tar.zst │ │ hello_world_param:v1 │ Ready │ 0 │ ~/.peppy/built_nodes/hello_world_param_v1.tar.zst │ └──────────────────────────────────────┴───────┴───────────┴───────────────────────────────────────────────────┘ Instance bindings ┌──────────────────────────────────────┬──────────────────────────────┬─────────┬─────────┬──────────┐ │ NODE │ INSTANCE │ STATUS │ HEALTH │ BINDINGS │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼──────────┤ │ core-node-sweet-germain-4388:v0.10.0 │ core-node-sweet-germain-4388 │ running │ healthy │ (none) │ └──────────────────────────────────────┴──────────────────────────────┴─────────┴─────────┴──────────┘ Dependencies hello_receiver:v1 ➔ hello_world_param:v1 ``` We need to make sure that at least one instance is started for `hello_receiver:v1` and another one for `hello_world_param:v1`. The order in which the nodes are started does not affect the node communication. Let’s start a `hello_world_param:v1` instance: ```sh ❯ peppy node run hello_world_param:v1 name=planet Running node hello_world_param:v1... Starting node hello_world_param:v1 with instance_id 'gifted-moser-9365' and 1 argument(s)... Calling node_start for hello_world_param:v1 (instance_id=gifted-moser-9365)... Log file: /Users/tuatini/.peppy/logs/run/gifted-moser-9365.log Started node instance 'gifted-moser-9365' (pid: 76981) ``` Since our node requires a `name`, we provide it with that argument on startup. Note the producer’s instance id in the output (`gifted-moser-9365` here): a consumer receives messages only from the producers explicitly bound to its slots, so we need that id to start the receiver. Now let’s start the `hello_receiver` node, binding its `hello_world_param` slot to the producer instance with `--bind LINK_ID@INSTANCE_ID`: ```sh ❯ peppy node run hello_receiver:v1 --bind hello_world_param@gifted-moser-9365 Running node hello_receiver:v1... Starting node hello_receiver:v1 with instance_id 'vigorous-buck-8117' and 0 argument(s)... Calling node_start for hello_receiver:v1 (instance_id=vigorous-buck-8117)... Log file: /Users/tuatini/.peppy/logs/run/vigorous-buck-8117.log Started node instance 'vigorous-buck-8117' (pid: 77190) ``` A slot left without a `--bind` is an error: every declared slot must be bound (unless its cardinality is `zero_or_more`), and the run is rejected before the node spawns. 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:v1` 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:v1 name=you Running node hello_world_param:v1... Starting node hello_world_param:v1 with instance_id 'admiring-black-0614' and 1 argument(s)... Calling node_start for hello_world_param:v1 (instance_id=admiring-black-0614)... Log file: /Users/tuatini/.peppy/logs/run/admiring-black-0614.log Started node instance 'admiring-black-0614' (pid: 78063) ``` The running receiver is still bound to `gifted-moser-9365` only, so it will not see the new producer: this slot has the default cardinality `one`, so it binds exactly one producer, and only the bound producer reaches it. To read the new instance instead, restart the receiver bound to it: ```sh ❯ peppy node stop vigorous-buck-8117 ❯ peppy node run hello_receiver:v1 \ --bind hello_world_param@admiring-black-0614 Running node hello_receiver:v1... Starting node hello_receiver:v1 with instance_id 'vigorous-buck-8117' and 0 argument(s)... ``` And we check the logs again: ```sh ❯ tail /Users/tuatini/.peppy/logs/run/vigorous-buck-8117.log [2026-01-24T10:04:47.085] [stdout] Received from admiring-black-0614: hello you count 1 [2026-01-24T10:04:50.085] [stdout] Received from admiring-black-0614: hello you count 2 [2026-01-24T10:04:53.086] [stdout] Received from admiring-black-0614: hello you count 3 [2026-01-24T10:04:56.085] [stdout] Received from admiring-black-0614: hello you count 4 ``` The receiver now follows `admiring-black-0614`, and only it. `gifted-moser-9365` keeps publishing, but no binding names it anymore, so its messages reach no slot. (To consume both producers at once, a consumer declares two slots, one `link_id` per producer; see [Bindings and routing](/advanced_guides/topics/#bindings-and-routing).) ```plaintext $ peppy stack list Node stack ┌──────────────────────────────────────┬───────┬───────────┬───────────────────────────────────────────────────┐ │ NODE │ STAGE │ INSTANCES │ PATH │ ├──────────────────────────────────────┼───────┼───────────┼───────────────────────────────────────────────────┤ │ core-node-sweet-germain-4388:v0.10.0 │ Root │ 1 running │ ~/workspace/peppy │ │ hello_receiver:v1 │ Ready │ 1 running │ ~/.peppy/built_nodes/hello_receiver_v1.tar.zst │ │ hello_world_param:v1 │ Ready │ 2 running │ ~/.peppy/built_nodes/hello_world_param_v1.tar.zst │ └──────────────────────────────────────┴───────┴───────────┴───────────────────────────────────────────────────┘ Instance bindings ┌──────────────────────────────────────┬──────────────────────────────┬─────────┬─────────┬───────────────────────────┐ │ NODE │ INSTANCE │ STATUS │ HEALTH │ BINDINGS │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────┤ │ core-node-sweet-germain-4388:v0.10.0 │ core-node-sweet-germain-4388 │ running │ healthy │ (none) │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────┤ │ hello_receiver:v1 │ vigorous-buck-8117 │ running │ healthy │ hello_world_param → │ │ │ │ │ │ admiring-black-0614@core- │ │ │ │ │ │ node-sweet-germain-4388 │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────┤ │ hello_world_param:v1 │ gifted-moser-9365 │ running │ healthy │ (none) │ │ │ admiring-black-0614 │ running │ healthy │ (none) │ └──────────────────────────────────────┴──────────────────────────────┴─────────┴─────────┴───────────────────────────┘ Dependencies hello_receiver:v1 ➔ hello_world_param:v1 ``` The `Instance bindings` table makes the routing explicit: `hello_receiver`’s instance resolved its `hello_world_param` slot to the one producer we bound, rendered as `instance_id@core_node` (its full wire address). A slot only ever receives from the producer bound to it: that is exactly why `vigorous-buck-8117` prints messages from `admiring-black-0614` and nothing from `gifted-moser-9365` above. *** 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 Peppy node Now that you have Peppy installed, let’s create your first node. A node is the fundamental unit of computation in Peppy - it represents a runnable application or service. In this guide, we’ll explore how nodes work and how they communicate with each other. You can think of nodes as compute units that are each responsible for a single job. For example, a node can: * Emit frames from a USB camera * Move a single actuator * Act as the brain of a robot by receiving input from sensors (audio, video, etc.) and emitting actions The key point is that **a node is responsible for performing a single function**. ## Project structure [Section titled “Project structure”](#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 Peppy, 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" }` and `peppylib = { path = ".peppy/libs/peppylib" }` as dependencies in your toolchain’s configuration, and update `build_cmd` and `run_cmd` in `peppy.json5` to match your toolchain’s commands. Peppy 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 Peppy, 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. Peppy 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 { peppy_schema: "node/v1", manifest: { name: "hello_world", tag: "v1", }, interfaces: {}, execution: { language: "python", build_cmd: [ "uv", "sync" ], run_cmd: [ "uv", "run", "hello_world" ] } } ``` * Rust peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "hello_world", tag: "v1", }, interfaces: {}, execution: { language: "rust", build_cmd: [ "cargo", "build", "--release" ], run_cmd: [ "./target/release/hello_world" ] } } ``` Let’s go through the different options: * `peppy_schema`: Identifies the structure of the configuration file. For node configs always set this to `"node/v1"` * `manifest`: Contains information about the node. Each node is identified by 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 Tag format A tag is **not** a semver version; bumping a tag always means “incompatible with the previous one”. Pick a short label that identifies the contract: `v1`, `v2`, `donut`, `experimental`, etc. Validation rules: * Must start with an ASCII letter (`a-z`, `A-Z`). * May contain letters, digits, `_` and `-` (same character set as node names). * Dots (`.`) are forbidden; `0.1.0` and `v1.2` are rejected. Let’s modify this configuration file to expose a topic that sends a “hello world” message. * Python peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "hello_world", tag: "v1", }, interfaces: { topics: { emits: [ { name: "message_stream", qos_profile: "sensor_data", message_format: { message: "string" }, } ], } }, execution: { language: "python", build_cmd: [ "uv", "sync" ], run_cmd: [ "uv", "run", "hello_world" ] }, } ``` * Rust peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "hello_world", tag: "v1", }, interfaces: { topics: { emits: [ { name: "message_stream", qos_profile: "sensor_data", message_format: { message: "string" }, } ], } }, execution: { language: "rust", build_cmd: [ "cargo", "build", "--release" ], run_cmd: [ "./target/release/hello_world" ] }, } ``` To apply these changes, the node needs to synchronize with the cache in the `.peppy` folder. Run: ```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 import sys 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): # Declare the publisher once, then publish each message on it. try: publisher = await message_stream.declare_publisher(node_runner) except Exception as e: print(f"Failed to declare message_stream publisher: {e}", file=sys.stderr) return counter = 0 while True: counter += 1 message = f"hello world count {counter}" print(message, flush=True) try: await publisher.publish(message_stream.build_message(message)) except Exception as e: print(f"Failed to publish hello world: {e}", file=sys.stderr) await asyncio.sleep(3) async def setup(params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(emit_hello_world_loop(node_runner))] def main(): NodeBuilder().run(setup) if __name__ == "__main__": main() ``` * Rust src/main.rs ```rust use std::sync::Arc; use std::time::Duration; use peppygen::emitted_topics::message_stream; use peppygen::{NodeBuilder, NodeRunner, Parameters, Result}; use peppylib::runtime::CancellationToken; /// Emits a "hello world count X" message every 3 seconds, starting immediately. /// The loop runs until the cancellation token is triggered. async fn emit_hello_world_loop(runner: Arc, token: CancellationToken) { // Declare the publisher once; every publish below is then lock-free. let publisher = match message_stream::declare_publisher(&runner).await { Ok(publisher) => publisher, Err(e) => { eprintln!("Failed to declare message_stream publisher: {e}"); return; } }; let mut counter: u64 = 0; let mut interval = tokio::time::interval(Duration::from_secs(3)); loop { tokio::select! { _ = token.cancelled() => break, _ = interval.tick() => { counter += 1; let message = format!("hello world count {counter}"); println!("{message}"); match message_stream::build_message(message) { Ok(payload) => { if let Err(e) = publisher.publish(payload).await { eprintln!("Failed to publish hello world: {e}"); } } Err(e) => eprintln!("Failed to build hello world message: {e}"), } } } } } fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { let runner = node_runner.clone(); let token = node_runner.cancellation_token().clone(); // We use tokio::spawn to avoid blocking the closure tokio::spawn(emit_hello_world_loop(runner, token)); Ok(()) }) } ``` As you can see, the topic interface has already been generated. We didn’t need to implement serialization, encoding, or communication layers; Peppy handles all of that automatically. Additionally, the tag and name assigned to your node provide contract isolation. When the node’s interfaces change in a way that breaks consumers, you publish under a new tag (e.g. `v2`); dependent nodes that pin the old tag keep working until they are explicitly migrated. ## Graceful shutdown [Section titled “Graceful shutdown”](#graceful-shutdown) A node instance rarely gets to decide when it stops: [`peppy node stop`](/guides/node_stack/#stopping-your-node) stops it on demand, `peppy node add` stops running instances when it replaces a node, the daemon tears every node down when it shuts down cleanly, and a plain `SIGINT`/`SIGTERM` works too. Every one of these paths gives your node the same two things: * The **cancellation token** (`node_runner.cancellation_token()`) fires. This is the signal to *stop working*: have long-running tasks watch it and break out of their loops. * **Shutdown hooks** registered with `node_runner.on_shutdown(...)` then run to completion, with messaging still connected. This is where *cleanup* belongs (parking actuators, releasing hardware or locks, flushing state): unlike a task watching the token, a hook is guaranteed to be awaited before the node exits. - Python The hook callback may be a plain function or an `async def`: ```python async def setup(params, node_runner: NodeRunner): async def release_lock(): # runs on every stop path, before the node's tasks are torn down await datastore.remove(node_runner, "my_lock", response_timeout_secs=2.0) node_runner.on_shutdown(release_lock) ``` The background tasks returned from `setup` are cancelled automatically after the hooks: their pending `await` raises `asyncio.CancelledError`, and the node exits once they finish. Keep `try`/`finally` for last-moment tidy-up; cleanup that must be guaranteed belongs in `on_shutdown`. - Rust The hello\_world node above already follows the recommended pattern for stopping work: every spawned task `select!`s on `token.cancelled()` next to its work. Cleanup goes in a hook: ```rust let runner = node_runner.clone(); node_runner.on_shutdown(async move { // runs on every stop path, after the token fires, before run() returns if let Err(e) = datastore::remove(&runner, "my_lock", TIMEOUT).await { tracing::warn!("failed to release lock: {e}"); } }); ``` Do **not** put cleanup in a spawned task that watches the token instead: once the token fires the runtime begins tearing down, and a detached task is not guaranteed to run again. The whole shutdown is bounded by a grace period (`shutdown_grace_secs`, 5 seconds by default) for your cleanup to run: a node that shuts down cooperatively is reported as a clean stop, one that ignores the ask is force-killed, and `peppy node stop` warns about the latter. The [shutdown lifecycle guide](/advanced_guides/shutdown/) covers the full contract: every stop path, hook ordering, the grace windows, and Python’s task-teardown mechanics. # Installation > How to install Peppy 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.10.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/launchers-hub): 1. Clone the repository: ```sh git clone https://github.com/Peppy-bot/launchers-hub ``` 2. Navigate into the directory: ```sh cd launchers-hub ``` In the `examples/` directory, you’ll find a launcher file for each language. Opening it reveals the following structure: * Python [`examples/python_robot.json5`](https://github.com/Peppy-bot/launchers-hub/blob/main/examples/python_robot.json5) * Rust [`examples/rust_robot.json5`](https://github.com/Peppy-bot/launchers-hub/blob/main/examples/rust_robot.json5) 1. The file is identified by `peppy_schema: "launcher/v1"` at the root: peppy uses this to distinguish launcher files from node `peppy.json5` files (which use `"node/v1"`) 2. All deployments are defined in the `deployments` array 3. Each deployment requires a `source` and `instances` attribute. Besides `instance_id`, an instance can carry `arguments`, `env_vars`, `bindings` (filling `depends_on` slots with producer instances: a scalar for a `one` slot, an array for a multi-cardinality slot), and, for nodes declaring [pairing](/advanced_guides/pairing) slots, `pairings` (pairing a slot with a peer instance at launch) and `defer_pairings` (explicitly starting a required slot unpaired) The `source` attribute supports four formats: * **URL**: A link to a `.tar.zst` archive, requiring both `url` and `sha256` attributes. The download is retried automatically on transient network failures (connection errors or `5xx` responses); a mismatch against `sha256`, or a client (`4xx`) error, fails immediately * **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 * **Repository**: Reference a node that is already registered in your user repositories. Specify `name` and `tag`, or use the combined `name: ":"` shorthand. The node is resolved against your local nodes cache at `~/.peppy/cache/nodes.json5`. Note The repository source resolves nodes from a locally cached index. Run `peppy repo refresh` before launching to ensure the cache is up to date. If a node or tag is missing from the cache, the launch will fail with a “not found” error. ```json5 // Repository source (long form) { source: { name: "openarm01_controller", tag: "v1", }, instances: [ { instance_id: "the_nervous_system" } ] } ``` ```json5 // Repository source (combined shorthand, equivalent to the example above) { source: { name: "openarm01_controller:v1", }, instances: [ { instance_id: "the_nervous_system" } ] } ``` Caution The git `ref` and the node `tag` declared in `peppy.json5` are independent values, and they do not share the same format: git refs can contain dots (e.g. `v0.1.0`), but node tags must start with an ASCII letter and forbid dots (e.g. `v1`, `donut`). This means you could have a git `ref` of `v0.1.0` while `peppy.json5` specifies `tag: "v1"`. Keep them coordinated so a checkout of a given git ref lines up with the tag your launcher expects. From within the cloned repository, execute the launcher: * Python ```sh peppy stack launch ./examples/python_robot.json5 ``` * Rust ```sh peppy stack launch ./examples/rust_robot.json5 ``` Peppy automatically inspects the `name` and `tag` of each node, verifies that all dependencies are met, and constructs the node stack in the correct order. ### Launching from a repository [Section titled “Launching from a repository”](#launching-from-a-repository) Launchers discovered by `peppy repo refresh` (see [Repositories](/advanced_guides/repositories/)) can be invoked by bare name, no path required: ```sh peppy stack launch openarm01_sim_teleop ``` An argument that contains a path separator or ends in `.json5` is always resolved as a filesystem path. A bare name (no separator, no extension) is first tried on disk as `.json5` next to your current directory, and only falls back to the repository cache at `~/.peppy/cache/launchers.json5` when no such file exists. Run `peppy repo refresh` first to ensure the launcher cache is populated. If the name resolves neither to a file nor to a cached launcher, the launch fails with an explicit “not found” error. 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: ```plaintext $ peppy stack list Node stack ┌──────────────────────────────────────┬───────┬───────────┬───────────────────────────────────────────────────────────┐ │ NODE │ STAGE │ INSTANCES │ PATH │ ├──────────────────────────────────────┼───────┼───────────┼───────────────────────────────────────────────────────────┤ │ core-node-adoring-wiles-7286:v0.10.0 │ Root │ 1 running │ ~/workspace/peppy │ │ fake_openarm01_controller:v1 │ Ready │ 1 running │ ~/.peppy/built_nodes/fake_openarm01_controller_v1.tar.zst │ │ fake_robot_brain:v1 │ Ready │ 1 running │ ~/.peppy/built_nodes/fake_robot_brain_v1.tar.zst │ │ fake_uvc_camera:v1 │ Ready │ 2 running │ ~/.peppy/built_nodes/fake_uvc_camera_v1.tar.zst │ │ fake_video_reconstruction:v1 │ Ready │ 1 running │ ~/.peppy/built_nodes/fake_video_reconstruction_v1.tar.zst │ └──────────────────────────────────────┴───────┴───────────┴───────────────────────────────────────────────────────────┘ Instance bindings ┌──────────────────────────────────────┬──────────────────────────────┬─────────┬─────────┬───────────────────────────────────┐ │ NODE │ INSTANCE │ STATUS │ HEALTH │ BINDINGS │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────────────┤ │ core-node-adoring-wiles-7286:v0.10.0 │ core-node-adoring-wiles-7286 │ running │ healthy │ (none) │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────────────┤ │ fake_openarm01_controller:v1 │ vibrant-keller-2310 │ running │ healthy │ (none) │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────────────┤ │ fake_robot_brain:v1 │ zealous-bohr-5512 │ running │ healthy │ fake_openarm01_controller → (any) │ │ │ │ │ │ fake_uvc_camera → (any) │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────────────┤ │ fake_uvc_camera:v1 │ mystic-galois-7701 │ running │ healthy │ (none) │ │ │ brave-noether-3098 │ running │ healthy │ (none) │ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────────────┤ │ fake_video_reconstruction:v1 │ gallant-curie-4417 │ running │ healthy │ fake_uvc_camera → (any) │ └──────────────────────────────────────┴──────────────────────────────┴─────────┴─────────┴───────────────────────────────────┘ Dependencies fake_robot_brain:v1 ➔ fake_openarm01_controller:v1 fake_robot_brain:v1 ➔ fake_uvc_camera:v1 fake_video_reconstruction:v1 ➔ fake_uvc_camera:v1 ``` The output confirms that all nodes have been added to the stack along with their dependency relationships. The `Instance bindings` table breaks that down per instance: the consumers (`fake_robot_brain`, `fake_video_reconstruction`) list the `depends_on` slots they resolved, while the producers (`fake_openarm01_controller`, `fake_uvc_camera`) and the core node declare no slots and show `(none)`. A resolved binding shows the chosen producer(s) as `instance_id@core_node`, in binding declaration order; a multi-cardinality slot lists every bound member, and a `zero_or_more` slot bound to nothing shows `(empty set)`. Every declared slot except `zero_or_more` must be bound (a launcher that leaves one out is rejected at validation); see [dependency cardinality](/advanced_guides/topics#dependency-cardinality). # The Node stack > Learn how to add, start, stop, and manage nodes in the Peppy stack The **node stack** is the central registry that manages all your nodes and their relationships in Peppy. 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, Peppy 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 Peppy stack by running: ```sh peppy node add . ``` 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 (since `build_cmd` has not been run). You’ll produce that artifact in the next section with [`peppy node build`](#building-hello_world). The output of this command provides some useful information: ```plaintext Adding node from /private/tmp/hello_world... Log file: ~/.peppy/logs/add/hello_world_v1_20260121_224824_699.log Added node hello_world:v1 to the node stack ``` This gives you access to the log file for debugging in case the `add` command fails. When a node is added to the node stack, a copy is made in the peppy cache under `~/.peppy/built_nodes/` 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. ## Building `hello_world` [Section titled “Building hello\_world”](#building-hello_world) An added node has no runnable artifact until it’s built. Building runs the node’s `build_cmd` (for example `uv sync` for Python or `cargo build --release` for Rust) against the snapshot that `peppy node add` staged: ```sh peppy node build hello_world:v1 ``` The format is `:` for a node that is already part of the node stack. While the build is running the node sits in the `Building` stage; once it completes, it moves to `Ready` and can be run. * Python Caution [uv](https://docs.astral.sh/uv/getting-started/installation/) must be installed on your system. Without it, the build 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, the build 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. The output looks like: ```plaintext Building node hello_world:v1... Log file: ~/.peppy/logs/build/hello_world_v1_20260121_224901_312.log Built node hello_world:v1. Artifact: ~/.peppy/built_nodes/hello_world_v1.tar.zst ``` The artifact is a `.tar.zst` archive. When you start an instance with `peppy node run`, the daemon extracts it into a per-instance directory and executes `run_cmd` there. ## Starting your node [Section titled “Starting your node”](#starting-your-node) Start your node: ```sh peppy node run hello_world:v1 ``` 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/ubuntu/.peppy/logs/run/elegant-solomon-5423.log [2026-02-17T10:15:23.599] Executing run_cmd: uv run hello_world (working_dir: /home/ubuntu/.peppy/instances/elegant-solomon-5423) [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 ``` ### Chaining add, build, and run [Section titled “Chaining add, build, and run”](#chaining-add-build-and-run) Now that you’ve seen all three steps, `peppy node add` exposes flags that chain them for you: * `--build` (`-b`) runs `peppy node build` immediately after the add succeeds. * `--run` (`-r`) also spawns an instance once the build finishes, and implies `--build`. * `--sync` (`-s`) runs `peppy node sync` **before** the add, so the snapshot picks up any edits you made to `peppy.json5`. Only valid for local filesystem sources; remote git/HTTP sources are synced server-side on fetch. ```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 and build with a separate `peppy node run`. Bundling them collapses the whole “I edited something, now run it” cycle to a single command. Run-only options (trailing `key=value` arguments, `--instance-id`, and `--bind KEY@VALUE`) are only accepted alongside `-r`/`--run`; combining them with a plain `peppy node add` is rejected at parse time so an arg you cared about isn’t silently dropped. Keep `key=value` arguments **after** the source path (e.g. `peppy node add -sr . frequency=30`) so the source positional isn’t confused with the trailing args. `--bind` mirrors the `peppy node run --bind` flag and runs the same validator; see [Bindings and routing](/advanced_guides/topics/#bindings-and-routing). ## 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 Node stack ┌─────────────────────────────────────────┬───────┬───────────┬─────────────────────────────────────────────┐ │ NODE │ STAGE │ INSTANCES │ PATH │ ├─────────────────────────────────────────┼───────┼───────────┼─────────────────────────────────────────────┤ │ core-node-funny-chatterjee-6386:v0.10.0 │ Root │ 1 running │ ~/.peppy/bin │ │ hello_world:v1 │ Ready │ 1 running │ ~/.peppy/built_nodes/hello_world_v1.tar.zst │ └─────────────────────────────────────────┴───────┴───────────┴─────────────────────────────────────────────┘ Instance bindings ┌─────────────────────────────────────────┬─────────────────────────────────┬─────────┬─────────┬──────────┐ │ NODE │ INSTANCE │ STATUS │ HEALTH │ BINDINGS │ ├─────────────────────────────────────────┼─────────────────────────────────┼─────────┼─────────┼──────────┤ │ core-node-funny-chatterjee-6386:v0.10.0 │ core-node-funny-chatterjee-6386 │ running │ healthy │ (none) │ ├─────────────────────────────────────────┼─────────────────────────────────┼─────────┼─────────┼──────────┤ │ hello_world:v1 │ suspicious-swanson-5880 │ running │ healthy │ (none) │ └─────────────────────────────────────────┴─────────────────────────────────┴─────────┴─────────┴──────────┘ Dependencies (none) ``` The `STAGE` column reports each node’s **stage**, its artifact-level lifecycle: * **`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). The `INSTANCES` column aggregates per-instance state (`starting`, `running`, and the terminal `finished` / `failed`) for the node, independently of its stage. To see individual instance IDs, use [`peppy node info`](#inspecting-a-node-with-peppy-node-info). Below the node table, the **`Instance bindings`** table expands the stack one level further: one row per instance, grouped by node, alongside the slot **bindings** that instance resolved when it started. A binding ties one of the consumer’s `depends_on` slots (identified by its `link_id`) to the producer instance(s) that feed it, rendered as `link_id → producer` (a multi-cardinality slot lists every bound member in binding order; a `zero_or_more` slot bound to nothing shows `(empty set)`). Every declared slot except `zero_or_more` must be bound (launch rejects unbound slots), and an instance whose node declares no `depends_on` shows `(none)`. Only nodes that currently have at least one instance appear in this table. See [Bindings and routing](/advanced_guides/topics/#bindings-and-routing) for how slots are declared and how `--bind` pins them. In the listing above neither node declares a dependency, so every binding cell is `(none)`. ### Instance health and lifecycle [Section titled “Instance health and lifecycle”](#instance-health-and-lifecycle) Beyond the bindings, the `Instance bindings` table reports two per-instance columns that follow an instance through its runtime lifecycle: * **`STATUS`** is the instance’s lifecycle state. A freshly launched instance is `starting` while its process comes up and clears its startup health gate, then becomes `running` once it has started successfully. If its process later exits on its own, it moves to a **terminal** state: `finished` when it exited cleanly (a one-shot node that completed its work and shut itself down, see the [shutdown lifecycle guide](/advanced_guides/shutdown/)), or `failed` when it exited with an error code or was killed. A terminal instance has stopped for good and will not run again. These are the same states [`peppy node info`](#inspecting-a-node-with-peppy-node-info) prints as `[starting]` / `[running]` / `[finished]` / `[failed]`. * **`HEALTH`** is the outcome of the core node’s most recent liveness probe against a `running` instance: `healthy` or `unhealthy`. Every instance starts out `healthy`. A terminal (`finished` / `failed`) instance has exited, so it has no live health to report and shows `-`. Once an instance is `running`, the daemon keeps probing its health in the background (every 5 seconds, allowing 3 seconds per probe) and updates the `HEALTH` flag from the result: * A failed probe marks the instance `unhealthy`. This is for a process that is **alive but not answering**: a wedged or deadlocked node, or one that has lost its messaging session. The process is still up; it just is not responding. * A later successful probe marks it `healthy` again. A process that has actually **exited** is the separate, terminal case above, not an `unhealthy` one. The daemon watches each running node’s process directly, so when a node exits on its own (a one-shot node finishing, or a crash) the instance moves to `finished` or `failed` rather than lingering as a `running` instance that fails its probe. Neither an `unhealthy` nor a terminal instance is automatically removed from the stack. An `unhealthy` `running` instance stays listed until it recovers on its own, so a transient failure (a momentary loss of the messaging session) surfaces as a short `unhealthy` window rather than silently tearing instances out of your stack. A `finished` or `failed` instance likewise stays listed, so you can see that it completed or died instead of wondering where it went. Either is cleared when you stop it with [`peppy node stop`](#stopping-your-node) (one instance) or [`peppy node remove`](#removing-your-node) (the whole node), or when the stack is relaunched. Each lifecycle transition (a `running` instance going `unhealthy` or recovering, or a process exiting to `finished` / `failed`) is appended to the daemon’s stack log at `~/.peppy/stack_log.log`, giving you a timestamped record of when an instance failed, finished, or came back. ### Daemon shutdown and orphan prevention [Section titled “Daemon shutdown and orphan prevention”](#daemon-shutdown-and-orphan-prevention) Spawned nodes are child processes of the daemon, so peppy takes care to ensure that no node (or any process a node itself spawned) is ever left running after the daemon goes away. **Clean shutdown (`Ctrl+C`, `systemctl stop`, `SIGTERM`).** Before it exits, the daemon tears down every spawned node: it asks each node to shut down cooperatively (in the node, this fires the cancellation token and runs its shutdown hooks; see the [shutdown lifecycle guide](/advanced_guides/shutdown/)), waits for each to exit cooperatively (the shared **shutdown grace period** for cleanup, `shutdown_grace_secs`, **5 seconds** by default, plus the brief runtime teardown that follows), then force-kills the process group of anything still alive. This is immediate: it does **not** wait the daemon-liveness grace period described below, so stopping the daemon feels instant while still guaranteeing nothing is orphaned. It’s the same graceful-then-forced sequence [`peppy node stop`](#stopping-your-node) runs for a single instance, which uses the same `shutdown_grace_secs` window. **Unclean daemon death (crash, OOM, `SIGKILL`).** When the daemon dies without the chance to run that cleanup, it can’t tear its nodes down. Instead, each spawned node runs a watchdog that listens for a periodic heartbeat from the daemon. If it sees no heartbeat for the **daemon grace period**, `daemon_grace_secs` (**180 seconds / 3 minutes** by default), the node shuts itself down rather than lingering as an orphan. The period is deliberately generous so a brief daemon blip or a quick restart doesn’t tear your nodes down: a node started in peer mode survives a daemon restart that completes within the window. Both grace periods are configured in `~/.peppy/conf/peppy_config.json5` (see [Daemon configuration](/advanced_guides/daemon_config/) for the full file reference) and take effect after a daemon restart: peppy\_config.json5 ```json5 { lifecycle: { // Seconds a node waits without a daemon heartbeat before it self-terminates // to avoid orphaning. Default: 180 (3 minutes). Minimum: 30. daemon_grace_secs: 180, // Seconds a clean shutdown and `peppy node stop` wait for a node to exit // cooperatively before force-killing it. Raise it for nodes that need longer // to park actuators or release hardware. Default: 5. Minimum: 1. shutdown_grace_secs: 5, }, } ``` Caution `daemon_grace_secs` must be at least 30 seconds (a smaller value is rejected when the config is loaded): it has to comfortably exceed the heartbeat interval and a router restart, or a momentary daemon hiccup would trip every node’s watchdog. `shutdown_grace_secs` must be at least 1 second. ## 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 last-known health, 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:v1 ``` ```plaintext [INFO] Getting node info for hello_world:v1... Node Information ================================================== Name: hello_world Tag: v1 Language: Python Build cmd: uv sync Run cmd: uv run hello_world Node Stack Status -------------------------------------------------- Stage: Ready Instances: 2 tracked - suspicious-swanson-5880 [running] healthy - flamboyant-penrose-9622 [running] healthy Logs -------------------------------------------------- hello_world:v1 Add log: /home/ubuntu/.peppy/logs/add/hello_world_v1_20260416_101124_613.log Run logs: - suspicious-swanson-5880: /home/ubuntu/.peppy/logs/run/suspicious-swanson-5880.log - flamboyant-penrose-9622: /home/ubuntu/.peppy/logs/run/flamboyant-penrose-9622.log Exposed Interfaces -------------------------------------------------- Emitted Topics: - message_stream (qos: SensorData) Integrity -------------------------------------------------- Config SHA256: 67505086f8ac099d0861cb5dfa539b1c8c3c9354ad90ed7713a5147c7cb43342 ``` 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. `Exposed Interfaces` summarizes the topics, services, and actions the node publishes or consumes per its `peppy.json5`. `Config SHA256` is the fingerprint of the config that was fed through peppygen; it’s what `peppy node add` checks against to refuse a stale snapshot and tell you to run `peppy node sync`. ## 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:v1` node, but the node itself still remains in the node stack. Stopping is a **graceful-then-forced** operation, and the command blocks until the instance’s process has actually exited: 1. The daemon asks the instance to shut down cooperatively and gives it a grace period (`shutdown_grace_secs`, 5 seconds by default; see [Daemon shutdown and orphan prevention](#daemon-shutdown-and-orphan-prevention)) for its cleanup to run. For a robot node this is its chance to park actuators, release hardware, and flush state. Inside the node, this ask fires the runtime’s cancellation token and then runs the node’s registered shutdown hooks; see [Graceful shutdown](/guides/first_node/#graceful-shutdown) for the quick-start and the [shutdown lifecycle guide](/advanced_guides/shutdown/) for the full node-side contract. 2. If the instance doesn’t exit cooperatively in time (the grace window plus the brief teardown its runtime needs afterward), the daemon **force-kills its whole process group** (the node and any child processes it spawned), so nothing is left running. If the instance had to be force-killed, `peppy node stop` warns you instead of reporting a clean stop: ```plaintext WARN Node instance 'hello_world-...' did not shut down gracefully within the grace period and was force-killed ``` Caution A force-kill is abrupt: the node gets no chance to run its own shutdown logic. A node that must stop hardware cleanly should handle the cooperative shutdown signal and exit promptly within the grace period. The same graceful-then-forced sequence runs for every node when the daemon itself shuts down; see [Daemon shutdown and orphan prevention](#daemon-shutdown-and-orphan-prevention). ## 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:v1 ``` If you run `peppy stack list` again, you’ll see that only the core node remains: ```plaintext $ peppy stack list Node stack ┌─────────────────────────────────────────┬───────┬───────────┬──────────────┐ │ NODE │ STAGE │ INSTANCES │ PATH │ ├─────────────────────────────────────────┼───────┼───────────┼──────────────┤ │ core-node-funny-chatterjee-6386:v0.10.0 │ Root │ 1 running │ ~/.peppy/bin │ └─────────────────────────────────────────┴───────┴───────────┴──────────────┘ Instance bindings ┌─────────────────────────────────────────┬─────────────────────────────────┬─────────┬─────────┬──────────┐ │ NODE │ INSTANCE │ STATUS │ HEALTH │ BINDINGS │ ├─────────────────────────────────────────┼─────────────────────────────────┼─────────┼─────────┼──────────┤ │ core-node-funny-chatterjee-6386:v0.10.0 │ core-node-funny-chatterjee-6386 │ running │ healthy │ (none) │ └─────────────────────────────────────────┴─────────────────────────────────┴─────────┴─────────┴──────────┘ 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 { peppy_schema: "node/v1", manifest: { name: "hello_world_param", tag: "v1", }, 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 { peppy_schema: "node/v1", manifest: { name: "hello_world_param", tag: "v1", }, 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): # Declare the publisher once, then publish each message on it. publisher = await message_stream.declare_publisher(node_runner) counter = 0 while True: counter += 1 message = f"hello {name} count {counter}" print(message, flush=True) await publisher.publish(message_stream.build_message(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::emitted_topics::message_stream; use peppygen::{NodeBuilder, NodeRunner, Parameters, Result}; use peppylib::runtime::CancellationToken; /// Emits a "hello world count X" message every 3 seconds, starting immediately. /// The loop runs until the cancellation token is triggered. async fn emit_hello_world_loop(runner: Arc, token: CancellationToken, name: String) { // Declare the publisher once; every publish below is then lock-free. let publisher = match message_stream::declare_publisher(&runner).await { Ok(publisher) => publisher, Err(e) => { eprintln!("Failed to declare message_stream publisher: {e}"); return; } }; let mut counter: u64 = 0; let mut interval = tokio::time::interval(Duration::from_secs(3)); loop { tokio::select! { _ = token.cancelled() => break, _ = interval.tick() => { counter += 1; let message = format!("hello {name} count {counter}"); println!("{message}"); match message_stream::build_message(message) { Ok(payload) => { if let Err(e) = publisher.publish(payload).await { eprintln!("Failed to publish hello world: {e}"); } } Err(e) => eprintln!("Failed to build hello world message: {e}"), } } } } } fn main() -> Result<()> { NodeBuilder::new().run(|args: Parameters, node_runner| async move { let runner = node_runner.clone(); let token = node_runner.cancellation_token().clone(); // We use tokio::spawn to avoid blocking the closure tokio::spawn(emit_hello_world_loop(runner, token, 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 Peppy transforms the `peppy.json` configuration into bindings for the targeted programming language. For the full parameter schema syntax (long-form declarations, `$default` values, group fill-in semantics, and arrays), see the [Parameters reference](/reference/parameters/). # Sharing nodes > Learn how to share your node or use other people's nodes One of Peppy’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. Because `nodes-hub` ships as one of the default [repositories](/advanced_guides/repositories/), `uvc_camera` is already in the index, so you can add it by `name:tag` without referring to the git URL at all: ```sh peppy node add uvc_camera:v1 ``` ### Adding a node from an unindexed repository [Section titled “Adding a node from an unindexed repository”](#adding-a-node-from-an-unindexed-repository) If the node lives in a repository that isn’t registered in your `repositories.json5`, you can still add it directly by URL: ```sh peppy node add https://github.com/some-user/custom_nodes.git/uvc_camera ``` or ```sh peppy node add --ref main https://github.com/some-user/custom_nodes.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:v1` corresponds to the tag found in `peppy.json5`. `--ref` is only accepted with a full git URL; for `name:tag` sources the ref is pinned once in `repositories.json5` when the repo is registered. Now that this node is added, we can start it: ```sh peppy node run uvc_camera:v1 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/instances/angry-ride-4256/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:v1 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. Note Archive downloads over HTTP tolerate flaky networks: a transient failure (a connection error or a `5xx` response) is retried automatically, up to three attempts with a short backoff. A checksum mismatch or a client (`4xx`) error fails immediately, without retrying. Tip When you’re iterating on a node that depends on a hub node, and you don’t want to `peppy node add` the hub node first just to regenerate peppygen, run `peppy node sync -r` instead. The `--include-repositories` flag lets the daemon resolve missing dependencies through the [repository cache](/advanced_guides/repositories/#syncing-against-repositories) directly. # 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 { peppy_schema: "node/v1", manifest: { name: "standalone", tag: "v1", }, interfaces: {}, execution: { language: "python", // A bunch of fake parameters required to start our node parameters: { device_path: "string", video: { frame_rate: "u16", resolution: { width: "u16", height: "u16", }, encoding: "string", }, }, build_cmd: [ "uv", "sync" ], run_cmd: [ "uv", "run", "standalone" ] } } ``` * Rust peppy.json5 ```json5 { peppy_schema: "node/v1", manifest: { name: "standalone", tag: "v1", }, interfaces: {}, execution: { language: "rust", // A bunch of fake parameters required to start our node parameters: { device_path: "string", video: { frame_rate: "u16", resolution: { width: "u16", height: "u16", }, encoding: "string", }, }, build_cmd: [ "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: * Python params.json ```json { "device_path": "/dev/video0", "video": { "frame_rate": 30, "resolution": { "width": 1920, "height": 1080 }, "encoding": "h264" } } ``` * Rust params.json ```json { "device_path": "/dev/video0", "video": { "frame_rate": 30, "resolution": { "width": 1920, "height": 1080 }, "encoding": "h264" } } ``` Then modify the source file to read from this file: * 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 Video, VideoResolution # # params = Parameters( # device_path="/dev/video0", # video=Video( # frame_rate=30, # resolution=VideoResolution( # width=1920, # height=1080, # ), # encoding="h264", # ), # ) with open("params.json") as f: params = json.load(f) standalone_config = StandaloneConfig().with_parameters(params) NodeBuilder().standalone(standalone_config).run(setup) if __name__ == "__main__": main() ``` Now if we run the following command again: ```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::video::{Video, VideoResolution}; // // let params = Parameters { // device_path: "/dev/video0".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. # Changelog > Release notes and version history for Peppy All notable changes to Peppy will be documented on this page. Subscribe to the [Atom feed](/changelog.xml) for updates. **[v0.17.0 (Alpha)](/releases/v0-17-0/)**: *Dependency slots gain explicit cardinality, letting a slot bind to multiple producers through a uniform consumer API.* ### Dependency-slot cardinality * Dependency slots now support explicit cardinality, letting a slot bind to one or more producers instead of exactly one. * Bindings can target multiple producers, and consumers read them through a uniform `bound_producers()` API regardless of cardinality. * Generated producer accessors reflect each slot's cardinality: single-producer slots expose `bound_producer()` while multi-producer slots expose `bound_producers()`. * Bindings can now specify explicit service and action targets. * Slots declared `one_or_more` must be non-empty, and this check now applies to programmatically supplied `Flags` values as well as arrays. **[v0.16.0 (Alpha)](/releases/v0-16-0/)**: *Renames interfaces to contracts, tightens \`depends\_on\` slot validation, and fixes macOS container builds.* ### Breaking changes * The `conforms_to` manifest field has been renamed to `implements` as part of a broader rename of "interfaces" to "contracts"; rename `conforms_to` to `implements` in your `peppy.json5` and update any "interface" wording to "contract". * Dependency slots (`depends_on`) now require a single producer each, replacing the previous `from_any` binding model, and peppy now fails validation whenever a `depends_on` slot is left unfulfilled; update your bindings so every slot names exactly one producer. ### Container builds and runs (macOS) * macOS container builds no longer silently default to `$HOME` and now use the current working directory. * The peppy data root is now mounted into the Lima VM so container builds and runs can access it. * Fixed a hang where restarting the Lima VM blocked the async runtime while registering a host mount for the first time. * Container Python bindings no longer ship stale native extensions after shared-crate changes. ### Validation and output * Contract (`implements`) validation failures now report per-slot coverage mismatches instead of a single combined string. * The CLI info output formatting has been simplified. ### Documentation * The advanced guides now include Python examples alongside the existing Rust examples. * The Python async examples now wrap `select!` in a loop and hold references to asyncio tasks so they are not cleaned up or garbage-collected prematurely. **[v0.15.1 (Alpha)](/releases/v0-15-1/)**: *Adds remote core node targeting and collision detection, renames the serve command and the shared-directory variable.* ### Breaking changes * The `PEPPYOS_SHARED_DIR` environment variable has been renamed to `PEPPY_SHARED_DIR`; update your environment so the shared directory stays configured. ### Core node targeting * The `--core-node` flag can now target a remote core node and is accepted by `node add`, `node sync`, and `repo add`. * The `--core-node` flag is now validated as the command is parsed, so invalid values are reported immediately. * peppy now detects core node name collisions. ### Serve reliability * `peppy service serve` now shuts down cleanly when an error occurs during startup. **[v0.15.0 (Alpha)](/releases/v0-15-0/)**: *Nodes can now declare, establish, and dissolve pairings with one another, and serve restarts and shutdowns are more reliable.* ### Node pairing * Nodes can now declare, establish, and dissolve pairings (peer links) with one another at runtime. * Each paired node exposes a `LINK_ID` and a `pairings` view in its generated module so node code can identify and work with its active links. * `peppy stack list` now shows an "Instance pairings" section listing the active pairings between running instances. * Malformed pairing targets are now rejected, and pairing delivery is protected against a link being dissolved concurrently. ### Serve reliability * `peppy serve` no longer drops shutdown signals or races when restarting, so restarts and shutdowns now behave reliably. **[v0.14.0 (Alpha)](/releases/v0-14-0/)**: *Logging in now connects your daemon to a per-user TLS-verified cloud router, and topics move to a new Subscription API.* ### Breaking changes * Topic subscriptions no longer use the per-call `on_next_message_received` method; subscribing now returns a `Subscription` that you hold and read messages from, so update your node code to use it. ### Authentication and cloud routers * Logging in now fetches and caches your per-user cloud (zenoh) router configuration and federates your daemon to it live. * peppy verifies the router's TLS certificate when you log in. * Logging in or out now takes effect immediately, regenerating and restarting the daemon and propagating your organization namespace through it. * Fixed a cross-identity organization cache leak, added a daemon restart when the namespace drifts at startup, and hardened login and logout edge cases. * The restart warning is now skipped when your daemon stack has no user nodes. ### Releasing * Added a `--skip-prod-cert-check` flag to bypass the production-router certificate gate when running a release. ### Topics * Tightened how subscriptions behave once they are closed, across generated node code and the docs. ### Reliability * peppy now retries transient `ETXTBSY` and `ENOENT` exec errors when extracting the ruff binary during parallel binary extraction. ### Documentation * Clarified that `reconnect_after_secs` and `repull_after` are cache-freshness hints rather than keepalive deadlines. **[v0.14.1 (Alpha)](/releases/v0-14-1/)**: *Bundle downloads retry transient failures, AppArmor profiles are per-install, and node run drops the legacy --link-id flag.* ### Breaking changes * The legacy `--link-id` flag has been removed from `node run`; remove it from any commands or scripts that still pass it. ### Node downloads and runtime * Transient HTTP failures when downloading node bundles are now retried automatically with bounded backoff, making node setup more resilient to flaky networks. * Empty environment variable path overrides are now treated as unset, so an empty value falls back to the default instead of being used as a literal path. ### AppArmor * AppArmor profiles are now namespaced per Apptainer installation path, so multiple Apptainer installations no longer collide. * The AppArmor fix script now shell-escapes the starter path correctly, so installation paths containing special characters work. **[v0.13.0 (Alpha)](/releases/v0-13-0/)**: *Adds OAuth device-flow sign-in through the new peppy auth commands and moves schema identifiers to a slash-separated form.* ### Breaking changes * Schema identifiers now use a slash separator (`node/v1`, `interface/v1`, `launcher/v1`); update the schema references in your `peppy.json5` to the new slash-separated form. ### Authentication * Added `peppy auth login`, `peppy auth logout`, and `peppy auth whoami` to sign in, sign out, and check your current identity using the OAuth device flow. **[v0.12.1 (Alpha)](/releases/v0-12-1/)**: *Adds signal handling and bind-mount safety.* ### Container lifecycle * Lima liveness checks now share a bounded wait helper and enforce per-probe timeouts. * Native containers now fall back to a host SIGTERM when the in-guest signal is unavailable. * In-VM container processes on macOS now receive a cooperative SIGTERM phase before being force-killed. ### Stack * All container bind mounts are now pre-flighted before any instance is started. **[v0.12.0 (Alpha)](/releases/v0-12-0/)**: *Remove emit topics API that could cause deadlocks on large payloads* ## What's Changed * Fix topics public API by [@godardt](https://github.com/godardt) in [#265](https://github.com/Peppy-bot/peppyos/pull/265) * Release v0.12.0 by [@godardt](https://github.com/godardt) in [#266](https://github.com/Peppy-bot/peppyos/pull/266) **Full Changelog**: [`v0.11.1...v0.12.0`](https://github.com/Peppy-bot/peppyos/compare/v0.11.1...v0.12.0) **[v0.11.1 (Alpha)](/releases/v0-11-1/)**: *Fix a bunch of deadlocks on node stop* ## What's Changed * Optimize config-internal by [@godardt](https://github.com/godardt) in [#252](https://github.com/Peppy-bot/peppyos/pull/252) * Optimize core-node-api by [@godardt](https://github.com/godardt) in [#253](https://github.com/Peppy-bot/peppyos/pull/253) * Optimize core-node-internal by [@godardt](https://github.com/godardt) in [#254](https://github.com/Peppy-bot/peppyos/pull/254) * Optimize generator-internal by [@godardt](https://github.com/godardt) in [#255](https://github.com/Peppy-bot/peppyos/pull/255) * Optimize containers-internal by [@godardt](https://github.com/godardt) in [#256](https://github.com/Peppy-bot/peppyos/pull/256) * Optimize latency-report by [@godardt](https://github.com/godardt) in [#257](https://github.com/Peppy-bot/peppyos/pull/257) * Optimize the peppy crate by [@godardt](https://github.com/godardt) in [#259](https://github.com/Peppy-bot/peppyos/pull/259) * Optimize node-stack-internal by [@godardt](https://github.com/godardt) in [#258](https://github.com/Peppy-bot/peppyos/pull/258) * refactor(pmi): harden public API, purify session-config builder, forbid unsafe by [@godardt](https://github.com/godardt) in [#260](https://github.com/Peppy-bot/peppyos/pull/260) * Refactor peppylib and peppylib-py by [@godardt](https://github.com/godardt) in [#261](https://github.com/Peppy-bot/peppyos/pull/261) * Fix stop clock and heartbeat publishers before closing the messaging session by [@godardt](https://github.com/godardt) in [#262](https://github.com/Peppy-bot/peppyos/pull/262) * Fix nodes termination unhealthy when nodes are tasks that exit by [@godardt](https://github.com/godardt) in [#263](https://github.com/Peppy-bot/peppyos/pull/263) * Release v0.11.1 by [@godardt](https://github.com/godardt) in [#264](https://github.com/Peppy-bot/peppyos/pull/264) **Full Changelog**: [`v0.11.0...v0.11.1`](https://github.com/Peppy-bot/peppyos/compare/v0.11.0...v0.11.1) **[v0.11.0 (Alpha)](/releases/v0-11-0/)**: *Add many fixes to the public APIs, especially with actions* ## What's Changed * Deploy peppylib as a standalone installable project in Python nodes by [@godardt](https://github.com/godardt) in [#240](https://github.com/Peppy-bot/peppyos/pull/240) * Fix producer death block by [@godardt](https://github.com/godardt) in [#242](https://github.com/Peppy-bot/peppyos/pull/242) * Add on\_shutdown hook phase with grace-bounded LIFO execution across all node stop paths by [@godardt](https://github.com/godardt) in [#243](https://github.com/Peppy-bot/peppyos/pull/243) * Add `stack benchmark` improvements by [@godardt](https://github.com/godardt) in [#244](https://github.com/Peppy-bot/peppyos/pull/244) * fix: Replace half-address producer refs with fully-qualified ProducerRef by [@godardt](https://github.com/godardt) in [#246](https://github.com/Peppy-bot/peppyos/pull/246) * Fix force node stop on `node build` by [@godardt](https://github.com/godardt) in [#245](https://github.com/Peppy-bot/peppyos/pull/245) * Fix `node stop` targeting wildcard core nodes by [@godardt](https://github.com/godardt) in [#247](https://github.com/Peppy-bot/peppyos/pull/247) * fix: isolate peppy data root per CI run to prevent cross-run collisions by [@godardt](https://github.com/godardt) in [#249](https://github.com/Peppy-bot/peppyos/pull/249) * Return full producer identity (core\_node + instance\_id) from consumed topic callbacks by [@godardt](https://github.com/godardt) in [#248](https://github.com/Peppy-bot/peppyos/pull/248) * docs: sync with PR [#250](https://github.com/Peppy-bot/peppyos/pull/250) by [@godardt](https://github.com/godardt) in [#251](https://github.com/Peppy-bot/peppyos/pull/251) * Release v0.11.0 by [@godardt](https://github.com/godardt) in [#250](https://github.com/Peppy-bot/peppyos/pull/250) **Full Changelog**: [`v0.10.5...v0.11.0`](https://github.com/Peppy-bot/peppyos/compare/v0.10.5...v0.11.0) **[v0.10.5 (Alpha)](/releases/v0-10-5/)**: *Fix \`node stop\` and daemon kill graceful shutdowns* ## What's Changed * Switch to Zenoh peer sessions for direct peer-to-peer data paths by [@godardt](https://github.com/godardt) in [#236](https://github.com/Peppy-bot/peppyos/pull/236) * docs: sync with PR [#234](https://github.com/Peppy-bot/peppyos/pull/234) by [@godardt](https://github.com/godardt) in [#235](https://github.com/Peppy-bot/peppyos/pull/235) * Fix zombie processes of nodes in the node stack by [@godardt](https://github.com/godardt) in [#237](https://github.com/Peppy-bot/peppyos/pull/237) * Refactor build helpers by [@godardt](https://github.com/godardt) in [#238](https://github.com/Peppy-bot/peppyos/pull/238) * Release v0.10.5 by [@godardt](https://github.com/godardt) in [#239](https://github.com/Peppy-bot/peppyos/pull/239) **Full Changelog**: [`v0.10.4...v0.10.5`](https://github.com/Peppy-bot/peppyos/compare/v0.10.4...v0.10.5) **[v0.10.4 (Alpha)](/releases/v0-10-4/)**: *Add \`peppy stack benchmark\`* ## What's Changed * Add latency measurements by [@godardt](https://github.com/godardt) in [#233](https://github.com/Peppy-bot/peppyos/pull/233) * Release v0.10.4 by [@godardt](https://github.com/godardt) in [#234](https://github.com/Peppy-bot/peppyos/pull/234) **Full Changelog**: [`v0.10.3...v0.10.4`](https://github.com/Peppy-bot/peppyos/compare/v0.10.3...v0.10.4) **[v0.10.3 (Alpha)](/releases/v0-10-3/)**: *Add support for shared datastore* ## What's Changed * Remove deferred binding support by [@godardt](https://github.com/godardt) in [#229](https://github.com/Peppy-bot/peppyos/pull/229) * Add from\_any bidirectional guide with robot arm snippets by [@godardt](https://github.com/godardt) in [#230](https://github.com/Peppy-bot/peppyos/pull/230) * Add memory store by [@godardt](https://github.com/godardt) in [#228](https://github.com/Peppy-bot/peppyos/pull/228) * Extract router feature flag and split zenohd submodules by [@godardt](https://github.com/godardt) in [#232](https://github.com/Peppy-bot/peppyos/pull/232) * Release v0.10.3 by [@godardt](https://github.com/godardt) in [#231](https://github.com/Peppy-bot/peppyos/pull/231) **Full Changelog**: [`v0.10.2...v0.10.3`](https://github.com/Peppy-bot/peppyos/compare/v0.10.2...v0.10.3) **[v0.10.2 (Alpha)](/releases/v0-10-2/)**: *Fix bidirectional communication design* ## What's Changed * docs: sync with PR [#221](https://github.com/Peppy-bot/peppyos/pull/221) by [@godardt](https://github.com/godardt) in [#222](https://github.com/Peppy-bot/peppyos/pull/222) * Remove external consumed topics, all consumed topics now require a link\_id by [@godardt](https://github.com/godardt) in [#223](https://github.com/Peppy-bot/peppyos/pull/223) * Rework bidirectional comm by [@godardt](https://github.com/godardt) in [#224](https://github.com/Peppy-bot/peppyos/pull/224) * Add bidirectional communication via interfaces by [@godardt](https://github.com/godardt) in [#225](https://github.com/Peppy-bot/peppyos/pull/225) * Release v0.10.2 by [@godardt](https://github.com/godardt) in [#226](https://github.com/Peppy-bot/peppyos/pull/226) **Full Changelog**: [`v0.10.1...v0.10.2`](https://github.com/Peppy-bot/peppyos/compare/v0.10.1...v0.10.2) **[v0.10.1 (Alpha)](/releases/v0-10-1/)**: *Fix stack list display as well as --force flag on node build* ## What's Changed * fix: release gate slot before completing goal to eliminate "action already in progress" race by [@godardt](https://github.com/godardt) in [#217](https://github.com/Peppy-bot/peppyos/pull/217) * Fix zenohd stdout/stderr to file to prevent pipe buffer deadlock by [@godardt](https://github.com/godardt) in [#218](https://github.com/Peppy-bot/peppyos/pull/218) * Improve the output of the `stack list` command by [@godardt](https://github.com/godardt) in [#219](https://github.com/Peppy-bot/peppyos/pull/219) * Fix zombie processes by [@godardt](https://github.com/godardt) in [#220](https://github.com/Peppy-bot/peppyos/pull/220) * Release v0.10.1 by [@godardt](https://github.com/godardt) in [#221](https://github.com/Peppy-bot/peppyos/pull/221) **Full Changelog**: [`v0.10.0...v0.10.1`](https://github.com/Peppy-bot/peppyos/compare/v0.10.0...v0.10.1) **[v0.10.0 (Alpha)](/releases/v0-10-0/)**: *Add interface conformance along with fixes to action* ## What's Changed * Improve install script by [@godardt](https://github.com/godardt) in [#192](https://github.com/Peppy-bot/peppyos/pull/192) * Variants removal by [@godardt](https://github.com/godardt) in [#191](https://github.com/Peppy-bot/peppyos/pull/191) * Release v0.9.3 by [@godardt](https://github.com/godardt) in [#196](https://github.com/Peppy-bot/peppyos/pull/196) * Interfaces conformances 1 by [@godardt](https://github.com/godardt) in [#197](https://github.com/Peppy-bot/peppyos/pull/197) * Interfaces conformances: standard structures by [@godardt](https://github.com/godardt) in [#198](https://github.com/Peppy-bot/peppyos/pull/198) * Replace semver tags with non-dotted tags by [@godardt](https://github.com/godardt) in [#199](https://github.com/Peppy-bot/peppyos/pull/199) * Refactor pmi-internal by [@godardt](https://github.com/godardt) in [#201](https://github.com/Peppy-bot/peppyos/pull/201) * Implement the `conforms_to` business logic for interface conformance by [@godardt](https://github.com/godardt) in [#200](https://github.com/Peppy-bot/peppyos/pull/200) * Fix interfaces & nodes on the wire by [@godardt](https://github.com/godardt) in [#202](https://github.com/Peppy-bot/peppyos/pull/202) * Add depends.on.interfaces business logic by [@godardt](https://github.com/godardt) in [#203](https://github.com/Peppy-bot/peppyos/pull/203) * Add launcher bindings business logic by [@godardt](https://github.com/godardt) in [#204](https://github.com/Peppy-bot/peppyos/pull/204) * Throw warnings on missing `link_id` during `peppy run` by [@godardt](https://github.com/godardt) in [#205](https://github.com/Peppy-bot/peppyos/pull/205) * Fix launcher and `node run` bindings by [@godardt](https://github.com/godardt) in [#206](https://github.com/Peppy-bot/peppyos/pull/206) * Add instance\_ids fetch by node name + node tag by [@godardt](https://github.com/godardt) in [#207](https://github.com/Peppy-bot/peppyos/pull/207) * docs: sync with PR [#209](https://github.com/Peppy-bot/peppyos/pull/209) by [@godardt](https://github.com/godardt) in [#210](https://github.com/Peppy-bot/peppyos/pull/210) * Fix action communication by goal ID by [@godardt](https://github.com/godardt) in [#214](https://github.com/Peppy-bot/peppyos/pull/214) * Replace raw cancel/result payloads with typed ResultStatus and CancelState by [@godardt](https://github.com/godardt) in [#215](https://github.com/Peppy-bot/peppyos/pull/215) * Revision release v0.10.0 by [@godardt](https://github.com/godardt) in [#211](https://github.com/Peppy-bot/peppyos/pull/211) * Release v0.10.0 by [@godardt](https://github.com/godardt) in [#216](https://github.com/Peppy-bot/peppyos/pull/216) **Full Changelog**: [`v0.9.3...v0.10.0`](https://github.com/Peppy-bot/peppyos/compare/v0.9.3...v0.10.0) **[v0.9.3 (Alpha)](/releases/v0-9-3/)**: *Fix peppy actions misalignment* ## What's Changed * feat: bundle gocryptfs alongside apptainer for encrypted overlay support by [@godardt](https://github.com/godardt) in [#193](https://github.com/Peppy-bot/peppy/pull/193) * Fix action communication misalignment by [@godardt](https://github.com/godardt) in [#195](https://github.com/Peppy-bot/peppy/pull/195) **Full Changelog**: [`v0.9.2...v0.9.3`](https://github.com/Peppy-bot/peppy/compare/v0.9.2...v0.9.3) **[v0.9.2 (Alpha)](/releases/v0-9-2/)**: *Add documentation for actions with optional fields* ## What's Changed * docs: sync with PR [#188](https://github.com/Peppy-bot/peppy/pull/188) by [@godardt](https://github.com/godardt) in [#189](https://github.com/Peppy-bot/peppy/pull/189) * Release v0.9.2 by [@godardt](https://github.com/godardt) in [#190](https://github.com/Peppy-bot/peppy/pull/190) **Full Changelog**: [`v0.9.1...v0.9.2`](https://github.com/Peppy-bot/peppy/compare/v0.9.1...v0.9.2) **[v0.9.1 (Alpha)](/releases/v0-9-1/)**: *Fix cancel token during action feedback when the action ends* ## What's Changed * docs: sync with PR [#184](https://github.com/Peppy-bot/peppy/pull/184) by [@godardt](https://github.com/godardt) in [#185](https://github.com/Peppy-bot/peppy/pull/185) * Fix Action feedback deadlock by [@godardt](https://github.com/godardt) in [#187](https://github.com/Peppy-bot/peppy/pull/187) * Release v0.9.1 by [@godardt](https://github.com/godardt) in [#188](https://github.com/Peppy-bot/peppy/pull/188) **Full Changelog**: [`v0.9.0...v0.9.1`](https://github.com/Peppy-bot/peppy/compare/v0.9.0...v0.9.1) **[v0.9.0 (Alpha)](/releases/v0-9-0/)**: *Add support for launchers repositories* ## Main changes * `schema_version` is now `peppy_schema: "node_v1"` or `peppy_schema: "launcher_v1"` depending if it's a node or a launcher * `peppy stack launch ` now works with remote launchers. The repository is added by default. Running `peppy stack launch ./` defaults to a local path, while `peppy stack launch ` defaults to a launcher in the repositories ## What's Changed * Add `stack launch` from repositories feature by [@godardt](https://github.com/godardt) in [#183](https://github.com/Peppy-bot/peppy/pull/183) * Release v0.9.0 by [@godardt](https://github.com/godardt) in [#184](https://github.com/Peppy-bot/peppy/pull/184) **Full Changelog**: [`v0.8.5...v0.9.0`](https://github.com/Peppy-bot/peppy/compare/v0.8.5...v0.9.0) **[v0.8.5 (Alpha)](/releases/v0-8-5/)**: *Add ability to use \`peppy node sync -r\` to use repositories* ## What's Changed * Add `node sync -r` to synchronize with repositories by [@godardt](https://github.com/godardt) in [#181](https://github.com/Peppy-bot/peppy/pull/181) * Release v0.8.5 by [@godardt](https://github.com/godardt) in [#182](https://github.com/Peppy-bot/peppy/pull/182) **Full Changelog**: [`v0.8.4...v0.8.5`](https://github.com/Peppy-bot/peppy/compare/v0.8.4...v0.8.5) **[v0.8.4 (Alpha)](/releases/v0-8-4/)**: *Add support for default parameters* ## What's Changed * Default parameters by [@godardt](https://github.com/godardt) in [#179](https://github.com/Peppy-bot/peppy/pull/179) * Release v0.8.4 by [@godardt](https://github.com/godardt) in [#180](https://github.com/Peppy-bot/peppy/pull/180) **Full Changelog**: [`v0.8.3...v0.8.4`](https://github.com/Peppy-bot/peppy/compare/v0.8.3...v0.8.4) **[v0.8.3 (Alpha)](/releases/v0-8-3/)**: *Add wall & sim clock support* ## What's Changed * Feature clock sync by [@godardt](https://github.com/godardt) in [#176](https://github.com/Peppy-bot/peppy/pull/176) * Add sim-clock mode with per-instance framework overrides by [@godardt](https://github.com/godardt) in [#177](https://github.com/Peppy-bot/peppy/pull/177) * Release v0.8.3 by [@godardt](https://github.com/godardt) in [#178](https://github.com/Peppy-bot/peppy/pull/178) **Full Changelog**: [`v0.8.2...v0.8.3`](https://github.com/Peppy-bot/peppy/compare/v0.8.2...v0.8.3) **[v0.8.2 (Alpha)](/releases/v0-8-2/)**: *Add core node information available to peppylib* ## What's Changed * Extract capnp encoding types into new core-node-api crate by [@godardt](https://github.com/godardt) in [#172](https://github.com/Peppy-bot/peppy/pull/172) * Peppylib stack list command implementation by [@godardt](https://github.com/godardt) in [#173](https://github.com/Peppy-bot/peppy/pull/173) * docs: sync with PR [#174](https://github.com/Peppy-bot/peppy/pull/174) by [@godardt](https://github.com/godardt) in [#175](https://github.com/Peppy-bot/peppy/pull/175) * Release v0.8.2 by [@godardt](https://github.com/godardt) in [#174](https://github.com/Peppy-bot/peppy/pull/174) **Full Changelog**: [`v0.8.1...v0.8.2`](https://github.com/Peppy-bot/peppy/compare/v0.8.1...v0.8.2) **[v0.8.1 (Alpha)](/releases/v0-8-1/)**: *Fix timeouts for \`stack launch\`* ## What's Changed * Fix stack launch timeouts by [@godardt](https://github.com/godardt) in [#169](https://github.com/Peppy-bot/peppy/pull/169) * Release v0.8.1 by [@godardt](https://github.com/godardt) in [#170](https://github.com/Peppy-bot/peppy/pull/170) **Full Changelog**: [`v0.8.0...v0.8.1`](https://github.com/Peppy-bot/peppy/compare/v0.8.0...v0.8.1) **[v0.8.0 (Alpha)](/releases/v0-8-0/)**: *Add support for repositories* ## What's Changed **✨ Highlight: Repositories**. This release introduces first-class [repositories](/advanced_guides/repositories/), a new way to tell peppy where to discover nodes. Register local directories, git repositories (with optional branch/tag pinning via `--ref`), or HTTP endpoints with `peppy repo add`, then run `peppy repo refresh` to build a cached index of every available node. Once indexed, nodes can be added by their short `name:tag` form (e.g. `peppy node add uvc_camera:0.1.0`) and launched in the node stack without pointing at a path or URL. * Add repositories feature by [@godardt](https://github.com/godardt) in [#161](https://github.com/Peppy-bot/peppy/pull/161) * Automatically test for docs drift by [@godardt](https://github.com/godardt) in [#163](https://github.com/Peppy-bot/peppy/pull/163) * `node add` command now works with node name + tag from repositories by [@godardt](https://github.com/godardt) in [#162](https://github.com/Peppy-bot/peppy/pull/162) * Add ability to use `stack launch` with nodes from repositories by [@godardt](https://github.com/godardt) in [#164](https://github.com/Peppy-bot/peppy/pull/164) * docs: sync with PR [#165](https://github.com/Peppy-bot/peppy/pull/165) by [@godardt](https://github.com/godardt) in [#166](https://github.com/Peppy-bot/peppy/pull/166) * docs: sync with PR [#165](https://github.com/Peppy-bot/peppy/pull/165) by [@godardt](https://github.com/godardt) in [#167](https://github.com/Peppy-bot/peppy/pull/167) * docs: sync with PR [#165](https://github.com/Peppy-bot/peppy/pull/165) by [@godardt](https://github.com/godardt) in [#168](https://github.com/Peppy-bot/peppy/pull/168) * Release v0.8.0 by [@godardt](https://github.com/godardt) in [#165](https://github.com/Peppy-bot/peppy/pull/165) **Full Changelog**: [`v0.7.0...v0.8.0`](https://github.com/Peppy-bot/peppy/compare/v0.7.0...v0.8.0) **[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 Peppy ## Node [Section titled “Node”](#node) A **Node** is the fundamental unit of computation in Peppy. 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 short contract identifier (e.g., `v1`, `donut`). Must start with an ASCII letter; allowed chars are letters, digits, `_`, `-`. Dots are forbidden. | | `labels` | Optional metadata labels | | `depends_on` | Optional dependency declarations on other nodes (`depends_on.nodes`) or contracts (`depends_on.contracts`); see [Contract implementation](/advanced_guides/contract_implementation) | A node is uniquely identified by its `name:tag` combination. Tags are **not** semver. Bumping a tag always means “incompatible with the previous one”: there is no notion of a backward-compatible patch or minor release. Pick a label that identifies the contract (`v1`, `v2`, `donut`), not a version number. ### 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. Two higher-level constructs build on these: [contract implementation](/advanced_guides/contract_implementation/), where the contract lives in a standalone document and any implementing producer can fill the consumer’s slot, and [pairing](/advanced_guides/pairing/), an exclusive 1:1 bidirectional topic exchange between two instances. See [Choosing a communication pattern](/advanced_guides/communication_patterns/) for how to pick between all of them. ## 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) * **State**: Its runtime lifecycle state: `starting` while it comes up, `running` once started, then the terminal `finished` (clean exit) or `failed` (crash) if its process exits on its own * **Health**: The result of the core node’s latest liveness probe against a running instance, `healthy` or `unhealthy` (not applicable once the instance is terminal) 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, health tracking, and removal of node instances. Running instances are continuously health-probed; a failing probe flags an instance `unhealthy` but never removes it, so an instance leaves the stack only when it is explicitly stopped or removed. See [Instance health and lifecycle](/guides/node_stack/#instance-health-and-lifecycle) for details. 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 the daemon (`peppy service serve`) that the `peppy` CLI talks to. 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: spawning them, probing their health, and tearing them down so none is left orphaned when the daemon stops or dies * **System Coordination**: Acting as the central coordinator for the local Peppy runtime When the core node shuts down cleanly it stops every spawned node (cooperatively, then force-killing any straggler’s process group); if it dies unexpectedly, each node’s watchdog notices the missing heartbeat and shuts the node down after a grace period. See [Daemon shutdown and orphan prevention](/guides/node_stack/#daemon-shutdown-and-orphan-prevention) for details. Each Peppy 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. Because core nodes are addressed by name over the messaging layer, every core node reachable over the same router or federation must have a **unique name**. Each daemon derives a stable, machine-specific name by default, or you can pin one with [`core_node_name`](/advanced_guides/daemon_config/#core_node_name-the-daemons-core-node-name); a daemon whose name is already taken refuses to boot. ## 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 Peppy Will Peppy be open source? Yes! Peppy 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 Peppy support? Since Peppy is built in Rust, Rust will be the first supported language, followed by Python and C. Will Peppy 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 Peppy. What tech are you using under the hoods? Peppy is written in [Rust](https://rust-lang.org/) and uses [Zenoh](https://zenoh.io/) for node communication. Can Peppy instances on different machines communicate with each other? Yes! Peppy 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 Peppy maintain backward compatibility? Not until version 1.0. Since Peppy 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. Peppy serves as the abstraction layer, allowing seamless switching between real hardware and simulation. Any plans to make Peppy compatible with Windows? Windows support is not currently planned. Our focus remains on improving Peppy core functionality, with official support limited to Linux (x86/ARM) and macOS (Apple Silicon). How will you make money? Peppy 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 Peppy 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, Peppy 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. Caution `$optional` belongs to message formats only. Node parameters use [`$default`](/reference/parameters/#default-values) instead, which is a separate concept: a default value to fall back on when the runtime doesn’t supply one. *** ## 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 { peppy_schema: "node/v1", manifest: { name: "arm_controller", tag: "v1" }, 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" } } } ] } } } ``` # Parameters > Reference for the node parameter schema syntax declared in peppy.json5 The `parameters` block inside `execution` declares the schema of arguments a node accepts at runtime. Each entry is a parameter name mapped to a typed declaration. ```json5 execution: { language: "rust", parameters: { name: "string", fps: "u16" } } ``` For an introductory walkthrough see the [Node parameters guide](/guides/parameters/). This page is the reference for the schema syntax itself. *** ## Primitive types [Section titled “Primitive types”](#primitive-types) Each leaf parameter declares a primitive type. The shorthand form is a bare type token: ```json5 parameters: { fps: "u16" } ``` The available type tokens (and their language mappings) are documented in [Message Format → Primitive types](/reference/message_format/#primitive-types). Note that `bytes` and `time` cannot have a `$default` (see [Default values](#default-values) below). *** ## Long-form primitive [Section titled “Long-form primitive”](#long-form-primitive) The long form is an object with `$type` and optionally `$default`: ```json5 parameters: { fps: { $type: "u16", $default: 30 } } ``` | Field | Required | Description | | ---------- | -------- | ---------------------------------------------------- | | `$type` | Yes | A primitive type token (same set as message formats) | | `$default` | No | A value used when the runtime omits this parameter | The shorthand `"u16"` is equivalent to `{ $type: "u16" }` with no default. *** ## Default values [Section titled “Default values”](#default-values) A primitive parameter can declare a `$default` that the runtime uses when the launcher omits the value. ```json5 parameters: { device: { path: { $type: "string", $default: "/dev/video0" }, serial: "string" // still required (varies per unit) }, frame_rate: { $type: "u16", $default: 30 } } ``` ### Parse-time validation [Section titled “Parse-time validation”](#parse-time-validation) `$default` is type- and range-checked when `peppy.json5` is loaded, so typos and out-of-range values surface immediately rather than at runtime: | Declaration | Result | | -------------------------------------- | ------------------------------------------------------------ | | `{ $type: "u8", $default: 300 }` | Rejected: `300` is outside `u8` range `[0, 255]` | | `{ $type: "u16", $default: "thirty" }` | Rejected: string not assignable to `u16` | | `{ $type: "u32", $default: -1 }` | Rejected: negative value for unsigned type | | `{ width: "u16", $default: 5 }` | Rejected: `$default` not allowed on a parameter group | | `{ $type: "string", $optional: true }` | Rejected: `$optional` is for message formats, not parameters | | `{ $type: "bytes", $default: ... }` | Rejected: `$default` not supported for `bytes`/`time` | ### Group fill-in [Section titled “Group fill-in”](#group-fill-in) `$default` is allowed only on primitives. If every leaf reachable from a group has a default, the group itself can be omitted at runtime and the entire subtree will be filled in. The same applies to partially supplied groups: any missing child whose spec declares a `$default` is filled in, while children declared without a default still produce a `MissingParameters` error naming the specific leaf path (e.g. `device.serial`). For example, given this schema: ```json5 parameters: { device: { path: { $type: "string", $default: "/dev/video0" }, serial: "string" }, video: { frame_rate: { $type: "u16", $default: 30 }, encoding: "string" } } ``` A launcher that supplies only the required fields: ```json5 arguments: { device: { serial: "0001A2B3" }, video: { encoding: "yuyv" } } ``` Reaches the spawned node as a complete arg set with defaults filled in: ```json5 { device: { path: "/dev/video0", serial: "0001A2B3" }, video: { frame_rate: 30, encoding: "yuyv" } } ``` Note `$default` is for **node parameters** only. The similar-looking `$optional` modifier is for [interface message formats](/reference/message_format/#optional-modifier) and is **not** valid on parameters; they are different concepts. *** ## Groups [Section titled “Groups”](#groups) Nest parameters by writing a naked object: ```json5 parameters: { video: { width: "u16", height: "u16" } } ``` Groups can also be written with an explicit `$type: "object"`, which is equivalent: ```json5 parameters: { video: { $type: "object", width: "u16", height: "u16" } } ``` Both forms generate the same nested struct in Rust and dataclass in Python. Groups can nest arbitrarily deep, and every leaf inside is reachable by a dot-path (e.g. `video.width`). *** ## Arrays [Section titled “Arrays”](#arrays) A parameter can declare an array of items: ```json5 parameters: { flags: { $type: "array", $items: "string" } } ``` | Field | Required | Description | | --------- | -------- | --------------------------------------------------------- | | `$type` | Yes | Must be `"array"` | | `$items` | Yes | A primitive type or a nested schema for each element | | `$length` | No | Fixed number of elements; omit for variable-length arrays | Arrays cannot have `$default`. Generated code for array parameters is not yet emitted by the Rust and Python generators, so declare arrays only if your node consumes them via a custom path.