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