Skip to content

Creating your first node

Now that you have Peppy installed, let’s create your first node. A node is the fundamental unit of computation in Peppy - it represents a runnable application or service. In this guide, we’ll explore how nodes work and how they communicate with each other.

You can think of nodes as compute units that are each responsible for a single job. For example, a node can:

  • Emit frames from a USB camera
  • Move a single actuator
  • Act as the brain of a robot by receiving input from sensors (audio, video, etc.) and emitting actions

The key point is that a node is responsible for performing a single function.

Run:

  1. Initialize the node:

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

    Terminal window
    cd hello_world

In the node folder, you’ll find the following:

  • A peppy.json5 file
  • A .peppy folder local to your node
  • A .gitignore that already excludes .peppy and the toolchain’s build artifacts
  • Your project scaffolding based on the selected toolchain

This is the entry point and the single source of truth for every node. All node interfaces and communication are defined in the peppy.json5 configuration file, regardless of the programming language used. A developer who needs to understand or share a node can simply look at this configuration file and immediately know how it works without digging into the code. Everything in a peppy.json5 is meant to be shared and distributed, so nothing local to your system should be added to this file.

This is a cache folder that contains everything automatically generated by the peppy daemon. Nothing here should be modified manually, and the scaffolded .gitignore already keeps it out of version control.

Let’s open the peppy.json5 file of the node we just created and explore it together.

peppy.json5
{
peppy_schema: "node/v1",
manifest: {
name: "hello_world",
tag: "v1",
},
interfaces: {},
execution: {
language: "python",
build_cmd: [
"uv",
"sync",
"--no-editable"
],
run_cmd: [
"./.venv/bin/python",
"-m",
"hello_world"
]
}
}

Let’s go through the different options:

  • peppy_schema: Identifies the structure of the configuration file. For node configs always set this to "node/v1"
  • manifest: Contains information about the node. Each node is identified by its name and tag
  • execution: Contains the language and execution commands for the node
    • language: The programming language used by the node (e.g. "python", "rust")
    • build_cmd: Command run during the build phase of the node (e.g. via peppy node build, peppy node add --build, or the combined shorthands peppy node add -sb / -sr). This is typically the place where you want to run heavy operations like code compilation.
    • run_cmd: Command run when a node instance is started
    • endpoints: The sockets the node binds during setup, when it serves something an operator opens (see Serving a page or an MCP endpoint)
    • setup_timeout_secs: Optional. The setup budget of the node: how long the daemon waits for the node’s setup function to return, from 1 to 540 seconds. Without it, the default setup budget of 15 seconds applies (see How an instance starts)
  • interfaces: This is where the interfaces used to communicate with the other nodes are defined

Let’s modify this configuration file to expose a topic that sends a “hello world” message.

peppy.json5
{
peppy_schema: "node/v1",
manifest: {
name: "hello_world",
tag: "v1",
},
interfaces: {
topics: {
emits: [
{
name: "message_stream",
qos_profile: "sensor_data",
message_format: {
message: "string"
},
}
],
}
},
execution: {
language: "python",
build_cmd: [
"uv",
"sync"
],
run_cmd: [
"uv",
"run",
"hello_world"
]
},
}

To apply these changes, the node needs to synchronize with the cache in the .peppy folder. Run:

Terminal window
peppy node sync

This command will generate the interfaces to use in our source code. If you’d rather sync, add, and build in a single step (useful once you’re iterating quickly), use peppy node add -sb, or -sr to also start an instance after the build. We can now modify the code and access those interfaces:

src/hello_world/__main__.py
import asyncio
import logging
from peppygen import NodeBuilder, NodeRunner
from peppygen.emitted_topics import message_stream
from peppygen.parameters import Parameters
logger = logging.getLogger(__name__)
async def emit_hello_world_loop(node_runner: NodeRunner):
# Declare the publisher once, then publish each message on it.
try:
publisher = await message_stream.declare_publisher(node_runner)
except Exception:
logger.exception("Failed to declare message_stream publisher")
return
counter = 0
while True:
counter += 1
message = f"hello world count {counter}"
print(message, flush=True)
try:
await publisher.publish(message_stream.build_message(message))
except Exception:
logger.exception("Failed to publish hello world")
await asyncio.sleep(3)
async def setup(params: Parameters, node_runner: NodeRunner) -> list[asyncio.Task]:
return [asyncio.create_task(emit_hello_world_loop(node_runner))]
def main():
NodeBuilder().run(setup)
if __name__ == "__main__":
main()

As you can see, the topic interface has already been generated. We didn’t need to implement serialization, encoding, or communication layers; Peppy handles all of that automatically.

Additionally, the tag and name assigned to your node provide contract isolation. When the node’s interfaces change in a way that breaks consumers, you publish under a new tag (e.g. v2); dependent nodes that pin the old tag keep working until they are explicitly migrated.

A node instance rarely gets to decide when it stops: peppy node stop stops it on demand, peppy node add stops running instances when it replaces a node, the daemon tears every node down when it shuts down cleanly, the daemon stops a start that it gives up after the node answered ready (a setup that outlasts its setup budget, a peppy stack reset), and a plain SIGINT/SIGTERM works too. Every one of these paths gives your node the same two things:

  • The cancellation token (node_runner.cancellation_token()) fires. This is the signal to stop working: have long-running tasks watch it and break out of their loops.
  • Shutdown hooks registered with node_runner.on_shutdown(...) then run to completion, with messaging still connected. This is where cleanup belongs (parking actuators, releasing hardware, flushing state): unlike a task watching the token, a hook is awaited before the node exits on every path above. A node that ends any other way runs no hook: a SIGKILL, an abort, the OOM killer and a power cut end the process where it stands. So a hook carries the work only the node can do. Exclusivity belongs on something that ends with the process: a file lock for a device, or a key owned through store_exclusive for something only one peppy instance may drive at a time. The shutdown lifecycle guide says what a killed node leaves behind.

The hook callback may be a plain function or an async def:

async def setup(params, node_runner: NodeRunner):
async def clear_latest():
# runs on the stop paths above, before the node's tasks are torn down
await datastore.remove(node_runner, "hello_world_latest", response_timeout_secs=2.0)
node_runner.on_shutdown(clear_latest)

The background tasks returned from setup are cancelled automatically after the hooks: their pending await raises asyncio.CancelledError, and the node exits once they finish. Keep try/finally for last-moment tidy-up; cleanup that must be guaranteed belongs in on_shutdown.

The whole shutdown is bounded by a grace period (lifecycle.shutdown_grace_secs, 5 seconds by default) for your cleanup to run: a node that shuts down cooperatively is reported as a clean stop, one that ignores the ask is force-killed, and peppy node stop warns about the latter. The shutdown lifecycle guide covers the full contract: every stop path, hook ordering, the grace windows, and Python’s task-teardown mechanics.

A node that serves something over the network, an operator panel, a viewer page, an MCP server, declares the sockets it binds under execution.endpoints. Each entry has a label, a kind (page for a URL an operator opens in a browser, mcp for one an MCP client attaches to) and a description. The declaration says that a socket is served under the label, never where: the port stays an ordinary parameter, the node decides the address at bind time (a port that is already taken can fall back to one the operating system picks) and announces what it got.

peppy.json5
{
// ...
execution: {
language: "rust",
parameters: {
http_host: { $type: "string", $default: "0.0.0.0" },
http_port: { $type: "u16", $default: 8765 },
},
endpoints: {
panel: { kind: "page", description: "The operator panel, HTTP and WebSocket." },
},
},
}

Inside setup, right after the bind, the node announces the socket under its label. Every declared label must be announced before setup returns, and nothing can be announced after: a label left unannounced fails the start with a message naming it.

async def setup(params, node_runner: NodeRunner):
server = ThreadingHTTPServer((params.http_host, params.http_port), Handler)
host, port = server.server_address[:2]
node_runner.announce_endpoint("panel", "http", host, port)
threading.Thread(target=server.serve_forever, daemon=True).start()
return []

A Rust node that treats its port as a preference binds with peppylib::runtime::bind_preferred(address) in place of TcpListener::bind: it takes the address asked for, or a port the operating system picks on the same host address when another process holds that one, and answers the listener with whether it fell back. Any other bind failure is returned as it is. Announcing listener.local_addr() reports the port taken either way, which is how several copies of one node run side by side on one machine.

The daemon that hosts the instance reads the announced sockets once the node is healthy and turns each one into the URLs an operator opens, one per address of that machine (a socket bound on 0.0.0.0 answers on every interface, so loopback comes first, then the others). Every command that starts or inspects instances prints them: peppy node run and peppy stack launch end with a Web pages: block and an MCP endpoints: block, peppy stack list has an Instance endpoints table, and peppy node info lists them under each instance.