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 (
--paironpeppy node run, orpairings: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?. For where pairing sits among the other mechanisms, see Choosing a communication pattern.
Example: robot arm control loop
Section titled “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.
arm_controller (role: controller) robot_arm (role: arm) │ │ slot "arm" │─ emits joint_commands ───────────────▶│ slot "controller" │ │ │◀─────────────────── emits joint_states│ │ │ └─────────────── ⇌ ───────────────────┘ one pair over arm_link/v1One 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”A pairing is a standalone pairing/v1 document in a repository peppy scans (see 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.
{ 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:
rolesdeclares exactly two distinct roles.topicsis one flat list; every topic’semitted_bymust 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”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.
{ 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"], },}{ 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"], },}{ 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"], },}{ 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”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.
peppy repo refreshpeppy node sync ./robot_armpeppy 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_1The --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 --paired nor --defer-paired is a hard error naming the missing slot and both flags:
$ peppy node run arm_controller:v1Error: required pairing slot(s) not covered: [arm]. Pass `--pair <link_id>@<peer_instance>`to pair each at start, or `--defer-pair <link_id>` to explicitly start unpairedIn a launcher, the pair is declared once, on either instance, with pairings:; a slot left intentionally unpaired goes in defer_pairings::
{ 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:
Instance pairings
NODE INSTANCE PAIRINGSrobot_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”peppy node sync generates a module per slot topic under peppygen.pairings.<link_id>.<topic> (Python) / peppygen::pairings::<link_id>::<topic> (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.
import asyncioimport sysimport time
from peppygen import NodeBuilder, NodeRunnerfrom peppygen.parameters import Parametersfrom 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()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:
import asyncioimport sys
from peppygen import NodeBuilder, NodeRunnerfrom peppygen.parameters import Parametersfrom 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()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_idfields 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”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:
# 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_1ctrl_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).
Multiple slots: the two-arm commander
Section titled “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:
// 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" }, ],},peppy node run --instance-id arm_l robot_arm:v1 --defer-pair controllerpeppy node run --instance-id arm_r robot_arm:v1 --defer-pair controllerpeppy node run --instance-id cmd_1 two_arm_commander:v1 \ --pair left_arm@arm_l --pair right_arm@arm_rThe 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?”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: 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 in one direction (the client depends on the server, no cycle).
Pairing vs. contracts
Section titled “Pairing vs. contracts”Pairing and 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 and 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.