Skip to content

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?. For where pairing sits among the other mechanisms, see Choosing a communication pattern.

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/v1

One pairing document models both directions. Each topic declares which role emits it; the other role consumes it.

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.

pairings/arm_link/peppy.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).

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.

robot_arm/peppy.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"],
},
}
arm_controller/peppy.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"],
},
}
NodeRole in arm_linkSlot (link_id)Emits to the peerConsumes from the peer
robot_armarmcontrollerjoint_statesjoint_commands
arm_controllercontrollerarmjoint_commandsjoint_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.

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.

Terminal window
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 --paired nor --defer-paired is a hard error naming the missing slot and both flags:

Terminal window
$ peppy node run arm_controller:v1
Error: 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 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
{
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 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)

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.

src/robot_arm/__main__.py
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()

On the other side, arm_controller emits joint_commands and consumes joint_states through its arm slot:

src/arm_controller/__main__.py
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()

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.

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:

Terminal window
# 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).

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" },
],
},
Terminal window
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.

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.

TopicsServicesActions
PatternPublish-subscribeRequest-responseGoal-feedback-result
Runtime data flowOne-way: producer → consumerTwo-way: consumer calls provider, provider repliesMulti-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?YesNoNo

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 and contract implementation both decouple nodes from each other through a shared contract. They answer different questions:

PairingContract
CardinalityExactly 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
DirectionalityBoth directions in one contract (two roles)One direction per contract
EstablishmentExplicit (--pair / pairings:), at instance startExplicit (--bind / bindings:), at instance start
Peer identityThe slot is the identity; the runtime guarantees whose messages you getPer-message ProducerRef; the consumer tells producers apart itself
LifecyclePair dissolves on death; survivor’s slot goes silent until re-pairedProducers come and go freely
Natural fitControl loops, teleoperation, any “these two specific instances belong together” relationshipTelemetry, 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 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?

PairingActions
LifecycleContinuous; runs for as long as the pair is liveBounded; each goal has an explicit start (goal accepted) and end (result delivered, cancelled, or errored)
InitiatorEither side publishes whenever it has dataClient issues a goal; server responds
ConcurrencyBoth streams flow in parallel, indefinitelyConcurrent goals allowed; the server’s goal handler sets the acceptance policy
Per-exchange feedbackImplicit (each side publishes its own stream)Explicit feedback channel during the goal
CancellationUnpair (stop an endpoint); or just stop publishingFirst-class cancel request mid-flight
Natural fitInner control loops, continuous state mirroringDiscrete 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.