Node communication
A node by itself isn’t very useful without the ability to communicate with other nodes in the node stack.
Each node is only aware of the interfaces it exposes and the ones it consumes, as defined in its peppy.json5 configuration. For example:
{ peppy_schema: "node/v1", manifest: { name: "controller", tag: "v1", }, interfaces: { topics: { emits: [], consumes: [], }, services: { exposes: [], consumes: [], }, actions: { exposes: [], consumes: [], }, }, execution: { language: "python", build_cmd: ["uv", "sync", "--no-editable"], run_cmd: ["uv", "run", "controller"] },}{ peppy_schema: "node/v1", manifest: { name: "controller", tag: "v1", }, interfaces: { topics: { emits: [], consumes: [], }, services: { exposes: [], consumes: [], }, actions: { exposes: [], consumes: [], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/controller"] },}Here we have interfaces organized by kind (topics, services, actions). Topics use emits and consumes lists, while services and actions use exposes and consumes lists. These interfaces define the dependencies between nodes.
Topics, services, and actions
Section titled “Topics, services, and actions”Peppy provides three primary communication patterns for nodes to exchange data:
-
Topics are used for continuous, unidirectional data streams. A node publishes messages to a topic, and any number of nodes can subscribe to receive them. This is ideal for sensor data, state updates, or any information that flows continuously (e.g., camera images, odometry).
-
Services implement a request-response pattern. A node connects to another node and waits for a response. Use services for quick operations that need a result, like querying a node’s state or triggering a one-time computation.
-
Actions are for long-running tasks that need feedback and cancellation support. A client sends a goal to an action node, which provides periodic feedback during execution and a final result upon completion. Actions are built on top of topics and services internally. Use them for tasks like navigation or arm movement. A node can drive multiple goals of the same action concurrently; its goal handler decides whether to accept a new goal or reject it, for example while the arm it targets is already moving.
These three are not the whole story: pairing covers exclusive bidirectional exchanges between two instances, and contract implementation lets any implementing producer fill a consumer’s slot. For a side-by-side decision guide, see Choosing a communication pattern.
Consumed interfaces
Section titled “Consumed interfaces”In our hello_world_param node, we’ve already emitted a topic. Let’s try to make this topic communicate with another node.
We first need to initialize the hello_receiver node in a new folder:
-
Initialize the node:
Terminal window peppy node init --toolchain uv hello_receiverTerminal window peppy node init --toolchain cargo hello_receiver -
Navigate into the directory:
Terminal window cd hello_receiver
with the following peppy.json5:
{ peppy_schema: "node/v1", manifest: { name: "hello_receiver", tag: "v1", depends_on: { nodes: [ { name: "hello_world_param", tag: "v1", link_id: "hello_world_param", }, ] }, }, interfaces: { topics: { consumes: [ { link_id: "hello_world_param", name: "message_stream", } ], } }, execution: { language: "python", build_cmd: [ "uv", "sync" ], run_cmd: [ "uv", "run", "hello_receiver" ] },}{ peppy_schema: "node/v1", manifest: { name: "hello_receiver", tag: "v1", depends_on: { nodes: [ { name: "hello_world_param", tag: "v1", link_id: "hello_world_param", }, ] }, }, interfaces: { topics: { consumes: [ { link_id: "hello_world_param", name: "message_stream", } ], } }, execution: { language: "rust", build_cmd: [ "cargo", "build", "--release" ], run_cmd: [ "./target/release/hello_receiver" ] },}and the following source file:
import asyncio
from peppygen import NodeBuilder, NodeRunnerfrom peppygen.parameters import Parametersfrom peppygen.consumed_topics.hello_world_param import message_stream
async def setup(_params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]: return [asyncio.create_task(receive_messages(node_runner))]
async def receive_messages(node_runner: NodeRunner): # Subscribe once; the held subscription buffers every message in order, so # iterating never drops a message published between iterations. subscription = await message_stream.subscribe(node_runner) async for producer, message in subscription: print(f"Received from {producer.instance_id}: {message.message}")
def main(): NodeBuilder().run(setup)
if __name__ == "__main__": main()use std::sync::Arc;
use peppygen::consumed_topics::hello_world_param::message_stream;use peppygen::{NodeBuilder, NodeRunner, Parameters, Result};
fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { tokio::spawn(receive_messages(node_runner)); Ok(()) })}
async fn receive_messages(node_runner: Arc<NodeRunner>) { // Subscribe once; the held subscription buffers every message in order, so // looping on `next` never drops a message published between iterations. let mut subscription = match message_stream::subscribe(&node_runner).await { Ok(subscription) => subscription, Err(e) => { eprintln!("Failed to subscribe: {e}"); return; } };
loop { match subscription.next().await { Ok(Some((producer, message))) => { println!("Received from {}: {}", producer.instance_id, message.message) } Ok(None) => break, Err(e) => { eprintln!("Error receiving message: {e}"); break; } } }}Finally, add this new node to the stack:
-
Sync the node interfaces:
Terminal window peppy node sync -
Add the node to the stack:
Terminal window peppy node add .
Starting the nodes
Section titled “Starting the nodes”Now we need to make sure our nodes are started. If we take a look at our node stack:
$ peppy stack list┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│ Core node: cn-sweet-germain (host: robot-host) │├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤│ Node stack ││ ││ ┌──────────────────────────────────────┬───────┬───────────┬───────────────────────────────────────────────────┐ ││ │ NODE │ STAGE │ INSTANCES │ PATH │ ││ ├──────────────────────────────────────┼───────┼───────────┼───────────────────────────────────────────────────┤ ││ │ cn-sweet-germain:v0.10.0 │ Root │ 1 running │ ~/workspace/peppy │ ││ │ hello_receiver:v1 │ Ready │ 0 │ ~/.peppy/built_nodes/hello_receiver_v1.tar.zst │ ││ │ hello_world_param:v1 │ Ready │ 0 │ ~/.peppy/built_nodes/hello_world_param_v1.tar.zst │ ││ └──────────────────────────────────────┴───────┴───────────┴───────────────────────────────────────────────────┘ ││ ││ Instance bindings ││ ││ ┌──────────────────────────────────────┬──────────────────────────────┬─────────┬─────────┬──────────┐ ││ │ NODE │ INSTANCE │ STATUS │ HEALTH │ BINDINGS │ ││ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼──────────┤ ││ │ cn-sweet-germain:v0.10.0 │ cn-sweet-germain │ running │ healthy │ (none) │ ││ └──────────────────────────────────────┴──────────────────────────────┴─────────┴─────────┴──────────┘ ││ ││ Dependencies ││ hello_receiver:v1 ➔ hello_world_param:v1 │└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘We need to make sure that at least one instance is started for hello_receiver:v1 and another one for hello_world_param:v1.
The order in which the nodes are started does not affect the node communication.
Let’s start a hello_world_param:v1 instance:
$ peppy node run hello_world_param:v1 name=planetRunning node hello_world_param:v1...Starting node hello_world_param:v1 with instance_id 'gifted-moser-9365' and 1 argument(s)...Calling node_start for hello_world_param:v1 (instance_id=gifted-moser-9365)...Log file: /home/ubuntu/.peppy/logs/run/gifted-moser-9365.logStarted node instance 'gifted-moser-9365' (pid: 76981)Since our node requires a name, we provide it with that argument on startup. Note the producer’s instance id in the output (gifted-moser-9365 here): a consumer receives messages only from the producers explicitly bound to its slots, so we need that id to start the receiver.
Now let’s start the hello_receiver node, binding its hello_world_param slot to the producer instance with --link LINK_ID@INSTANCE_ID:
$ peppy node run hello_receiver:v1 --link hello_world_param@gifted-moser-9365Running node hello_receiver:v1...Starting node hello_receiver:v1 with instance_id 'vigorous-buck-8117' and 0 argument(s)...Calling node_start for hello_receiver:v1 (instance_id=vigorous-buck-8117)...Log file: /home/ubuntu/.peppy/logs/run/vigorous-buck-8117.logStarted node instance 'vigorous-buck-8117' (pid: 77190)A slot left without a --link is an error: every declared slot must be bound (unless its cardinality is zero_or_more), and the run is rejected before the node spawns.
When you start the node, you can see a path to the logs. In my case: /home/ubuntu/.peppy/logs/run/vigorous-buck-8117.log.
If we open it up:
[2026-01-24T10:02:47.630] [stderr] Finished `release` profile [optimized] target(s) in 0.21s[2026-01-24T10:02:47.635] [stderr] Running `target/release/hello_receiver`[2026-01-24T10:02:51.243] [stdout] Received from gifted-moser-9365: hello planet count 7[2026-01-24T10:02:54.243] [stdout] Received from gifted-moser-9365: hello planet count 8[2026-01-24T10:02:57.244] [stdout] Received from gifted-moser-9365: hello planet count 9We can see the messages received from the hello_world_param:v1 instance!
Starting a second instance with different parameters
Section titled “Starting a second instance with different parameters”Now let’s push things a little further. Imagine we need a second instance with different parameters; we can start one like this:
$ peppy node run hello_world_param:v1 name=youRunning node hello_world_param:v1...Starting node hello_world_param:v1 with instance_id 'admiring-black-0614' and 1 argument(s)...Calling node_start for hello_world_param:v1 (instance_id=admiring-black-0614)...Log file: /home/ubuntu/.peppy/logs/run/admiring-black-0614.logStarted node instance 'admiring-black-0614' (pid: 78063)The running receiver is still bound to gifted-moser-9365 only, so it will not see the new producer: this slot has the default cardinality one, so it binds exactly one producer, and only the bound producer reaches it. To read the new instance instead, restart the receiver bound to it:
$ peppy node stop vigorous-buck-8117$ peppy node run hello_receiver:v1 \ --link hello_world_param@admiring-black-0614Running node hello_receiver:v1...Starting node hello_receiver:v1 with instance_id 'vigorous-buck-8117' and 0 argument(s)...And we check the logs again:
$ tail /home/ubuntu/.peppy/logs/run/vigorous-buck-8117.log[2026-01-24T10:04:47.085] [stdout] Received from admiring-black-0614: hello you count 1[2026-01-24T10:04:50.085] [stdout] Received from admiring-black-0614: hello you count 2[2026-01-24T10:04:53.086] [stdout] Received from admiring-black-0614: hello you count 3[2026-01-24T10:04:56.085] [stdout] Received from admiring-black-0614: hello you count 4The receiver now follows admiring-black-0614, and only it. gifted-moser-9365 keeps publishing, but no binding names it anymore, so its messages reach no slot. (To consume both producers at once, a consumer declares two slots, one link_id per producer; see Bindings and routing.)
$ peppy stack list┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│ Core node: cn-sweet-germain (host: robot-host) │├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤│ Node stack ││ ││ ┌──────────────────────────────────────┬───────┬───────────┬───────────────────────────────────────────────────┐ ││ │ NODE │ STAGE │ INSTANCES │ PATH │ ││ ├──────────────────────────────────────┼───────┼───────────┼───────────────────────────────────────────────────┤ ││ │ cn-sweet-germain:v0.10.0 │ Root │ 1 running │ ~/workspace/peppy │ ││ │ hello_receiver:v1 │ Ready │ 1 running │ ~/.peppy/built_nodes/hello_receiver_v1.tar.zst │ ││ │ hello_world_param:v1 │ Ready │ 2 running │ ~/.peppy/built_nodes/hello_world_param_v1.tar.zst │ ││ └──────────────────────────────────────┴───────┴───────────┴───────────────────────────────────────────────────┘ ││ ││ Instance bindings ││ ││ ┌──────────────────────────────────────┬──────────────────────────────┬─────────┬─────────┬───────────────────────────┐ ││ │ NODE │ INSTANCE │ STATUS │ HEALTH │ BINDINGS │ ││ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────┤ ││ │ cn-sweet-germain:v0.10.0 │ cn-sweet-germain │ running │ healthy │ (none) │ ││ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────┤ ││ │ hello_receiver:v1 │ vigorous-buck-8117 │ running │ healthy │ hello_world_param → │ ││ │ │ │ │ │ admiring-black-0614@core- │ ││ │ │ │ │ │ node-sweet-germain-4388 │ ││ ├──────────────────────────────────────┼──────────────────────────────┼─────────┼─────────┼───────────────────────────┤ ││ │ hello_world_param:v1 │ gifted-moser-9365 │ running │ healthy │ (none) │ ││ │ │ admiring-black-0614 │ running │ healthy │ (none) │ ││ └──────────────────────────────────────┴──────────────────────────────┴─────────┴─────────┴───────────────────────────┘ ││ ││ Dependencies ││ hello_receiver:v1 ➔ hello_world_param:v1 │└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘The Instance bindings table makes the routing explicit: hello_receiver’s instance resolved its hello_world_param slot to the one producer we bound, rendered as instance_id@core_node (its full wire address). A slot only ever receives from the producer bound to it: that is exactly why vigorous-buck-8117 prints messages from admiring-black-0614 and nothing from gifted-moser-9365 above.
We’ll explore services and actions in the advanced guides, although they fundamentally work the same way. Refer to nodes in this repository for more examples of topic/service/action usage.