Skip to content

Choosing a communication pattern

Peppy gives nodes five ways to exchange data: topics, services, actions, pairing, and the datastore. On top of the first three, 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.
ExchangeInitiated byLifecycleReach for it when
TopicsOne-way stream from a producer to its consumersThe producer, whenever it has dataContinuous while the producer runsSensor readings, camera frames, state updates
ServicesOne request, one responseThe consumerOne bounded exchange per callQueries, toggles, quick one-shot computations
ActionsGoal in, feedback stream out, result on completionThe consumer fires a goal; the producer drives it to an endA discrete job with an explicit start, end, and cancelNavigation, arm movements, calibration runs
PairingTwo independent one-way streams under one two-role contractEither side, whenever it has dataContinuous while both instances live; dissolves when either diesInner control loops between two specific instances
DatastoreWrite a small value, read it back laterWriter and reader, at independent timesThe value persists on the core node until overwritten or removedCalibration 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.

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 underWho can fill the slotInstances per slot
Direct node dependencydepends_on.nodesInstances of that exact producer nodeThe producer instance(s) bound to the slot, sized by its cardinality (one by default)
Contract dependencydepends_on.contractsAny node whose manifest.implements names the contractThe implementing instance(s) bound to the slot, sized by its cardinality (one by default)
Pairing slotdepends_on.pairingsOne instance playing the complementary role of the pairingExactly one peer at a time, exclusive, both directions
  • 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 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.

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. 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?.
  • 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.
  • 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.

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.