Skip to content

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

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.

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.

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:

  1. Initialize the node:

    Terminal window
    peppy node init --toolchain uv hello_receiver
  2. Navigate into the directory:

    Terminal window
    cd hello_receiver

with the following peppy.json5:

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

and the following source file:

src/hello_receiver/__main__.py
import asyncio
from peppygen import NodeBuilder, NodeRunner
from peppygen.parameters import Parameters
from 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()

Finally, add this new node to the stack:

  1. Sync the node interfaces:

    Terminal window
    peppy node sync
  2. Add the node to the stack:

    Terminal window
    peppy node add .

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=planet
Running 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.log
Started 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-9365
Running 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.log
Started 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 9

We 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=you
Running 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.log
Started 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-0614
Running 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 4

The 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.