Variants & Flavors
Peppy provides two ways to organize alternative implementations of a node:
- Variant — same interface, different implementation. Swap between AI models, mock nodes, or platform-specific builds without the rest of the stack noticing.
- Flavor — different interfaces, same core function. A repository of implementations that serve the same role (e.g., a robot brain) but target different robots with different joints, sensors, and capabilities.
Both use the same underlying mechanism — the variants array in the root node’s manifest — but they serve different design goals.
How variants work
Section titled “How variants work”A variant is a regular node that inherits the manifest and interfaces from a root node but defines its own execution. When you add a variant, peppy swaps the root node’s execution with the variant’s execution while keeping the same manifest and interfaces.
For example, if you have a brain node running a Rust-based controller, you can add a mock variant that runs a lightweight Python script instead — same interfaces, different execution. The rest of your stack doesn’t notice the difference.
Only one variant (or the root node itself) can be active in the stack at a time — adding a variant replaces any previous entry for that name:tag.
Variants — same interface, different implementations
Section titled “Variants — same interface, different implementations”When multiple implementations serve the same role in the system, they share an identical interface. The rest of the stack does not need to know which implementation is active — it talks to the node through the same topics, services, and actions regardless.
This is the core use case for peppy variants: one root node defines the manifest and interfaces, and each variant supplies only an execution.
Example — depth camera drivers:
A depth_camera node ships a default driver and declares variants for other hardware. Every variant publishes the same depth_image and point_cloud topics, so downstream nodes consume depth data without caring which physical sensor is behind it.
depth_camera/ ← root node├── peppy.json5 ← manifest + interfaces + default execution├── realsense/ ← variant (execution only)│ └── peppy.json5├── zed/ ← variant (execution only)│ └── peppy.json5└── oak_d/ ← variant (execution only) └── peppy.json5// depth_camera/peppy.json5 (root node){ schema_version: 1, manifest: { name: "depth_camera", tag: "0.1.0", variants: [ { name: "realsense", source: { local: "./realsense" } }, { name: "zed", source: { local: "./zed" } }, { name: "oak_d", source: { local: "./oak_d" } }, ], }, interfaces: { topics: { emits: [ { name: "depth_image", qos_profile: "sensor_data", message_format: { width: "u32", height: "u32", encoding: "string", data: { $type: "array", $items: "u8" }, }, }, { name: "point_cloud", qos_profile: "sensor_data", message_format: { num_points: "u32", x: { $type: "array", $items: "f32" }, y: { $type: "array", $items: "f32" }, z: { $type: "array", $items: "f32" }, }, }, ], }, }, execution: { language: "rust", add_cmd: ["cargo", "build", "--release"], start_cmd: ["./target/release/depth_camera"], },}Because every variant exposes depth_image and point_cloud with the same message formats, they are interchangeable — swap the active variant without touching any other node in the stack.
Example — mock node for testing:
A mock variant lets you test the rest of the stack without real hardware. It publishes the same topics with synthetic data:
// depth_camera/mock/peppy.json5 — lightweight test double{ schema_version: 1, execution: { language: "python", add_cmd: ["uv", "sync"], start_cmd: ["uv", "run", "mock_depth_camera"], },}The mock variant defines only an execution — it inherits the root’s depth_image and point_cloud interfaces, so every downstream node works exactly as it would with real hardware.
Example — platform-specific builds:
A node can also declare variants for different execution environments. For instance, a lidar node might run natively on Linux but need a macOS variant for local development and a containerized variant for CI:
// lidar/peppy.json5 (root node — Linux native){ schema_version: 1, manifest: { name: "lidar", tag: "0.2.0", variants: [ { name: "macos", source: { local: "./macos" } }, { name: "containerized", source: { local: "./containerized" } }, ], }, interfaces: { topics: { emits: [ { name: "scan", qos_profile: "sensor_data", message_format: { ranges: { $type: "array", $items: "f32" }, angle_min: "f32", angle_max: "f32", }, }, ], }, }, execution: { language: "rust", add_cmd: ["cargo", "build", "--release"], start_cmd: ["./target/release/lidar"], },}// lidar/containerized/peppy.json5 — same interfaces, runs in a container{ schema_version: 1, execution: { language: "rust", container: { def_file: "apptainer.def", }, },}All three — native Linux, macOS, and containerized — publish the same scan topic. The rest of the stack is unaffected by which variant is active.
Flavors — different interfaces, same core function
Section titled “Flavors — different interfaces, same core function”A flavor is a variant whose source node implements the same core function as the root node but targets a different system — typically a different robot. Because each system has different joints, sensors, and capabilities, the interfaces necessarily differ between flavors.
Within the source repository, flavors are independent nodes with their own manifests and interfaces. Each node is designed to be consumed as a variant in its target system’s project. A standard peppy node can serve as a variant in another project as long as its interfaces match the root node’s interfaces in that project.
Example — GR00T brain for multiple robots:
A GR00T brain repository contains implementations for different robot platforms. Each has its own joint configuration and sensor layout, so they define different interfaces. These are flavors of the same core function — a GR00T-powered robot brain.
groot_brain/├── shared/ ← common code used by all flavors├── openarm01/ ← flavor: full node (manifest + interfaces + execution)│ └── peppy.json5├── unitree_g1/ ← flavor: full node (manifest + interfaces + execution)│ └── peppy.json5└── 1x/ ← flavor: full node (manifest + interfaces + execution) └── peppy.json5Each flavor has its own manifest and interfaces tailored to its target robot:
// groot_brain/openarm01/peppy.json5 — a standalone node{ schema_version: 1, manifest: { name: "openarm01_brain", tag: "0.1.0", }, interfaces: { topics: { emits: [ { name: "joint_commands", qos_profile: "sensor_data", message_format: { // OpenArm01 has 6 joints positions: { $type: "array", $items: "f64", $length: 6 }, }, }, ], }, }, execution: { language: "python", add_cmd: ["uv", "sync"], start_cmd: ["uv", "run", "openarm01_brain"], },}// unitree_g1 and 1x each have their own peppy.json5 with// different joint counts, sensors, and message formats.A downstream OpenArm01 robot project can then reference this flavor as a variant of its own brain node:
// openarm01_robot/brain/peppy.json5 (root node in the robot project){ schema_version: 1, manifest: { name: "brain", tag: "0.1.0", variants: [ { name: "groot", source: { repo: "https://github.com/example/groot-brain.git", path: "openarm01", ref: "main", }, }, ], }, // Same interfaces as openarm01_brain — variant matching succeeds interfaces: { topics: { emits: [ { name: "joint_commands", qos_profile: "sensor_data", message_format: { positions: { $type: "array", $items: "f64", $length: 6 }, }, }, ], }, }, execution: { language: "rust", add_cmd: ["cargo", "build", "--release"], start_cmd: ["./target/release/brain"], },}Defining variants
Section titled “Defining variants”Variants are declared in the root node’s manifest.variants array. Each entry has a name and a source:
// peppy.json5 (root node){ schema_version: 1, manifest: { name: "robot_brain", tag: "0.1.0", variants: [ { name: "mock", source: { local: "../mock_brain" } }, { name: "skildai", source: { local: "../skildai_brain" } }, { name: "pi0", source: { repo: "https://github.com/example/pi0-brain.git", path: "brain", ref: "main", }, }, ], }, interfaces: { topics: { emits: [ { name: "joint_commands", qos_profile: "sensor_data", message_format: { positions: { $type: "array", $items: "f64", $length: 6 }, velocities: { $type: "array", $items: "f64", $length: 6 }, }, }, ], }, }, execution: { language: "rust", add_cmd: ["cargo", "build", "--release"], start_cmd: ["./target/release/robot_brain"], },}Default variant
Section titled “Default variant”A variant named "default" has special meaning: it is the node’s primary implementation, relocated to a separate directory. When a default variant is declared, the root peppy.json5 omits the execution section entirely — the execution comes from the default variant instead.
This is useful when you want every implementation (including the primary one) to live under a variants/ subdirectory for a cleaner project layout:
uvc_camera/ ← root node (no execution)├── peppy.json5 ← manifest + interfaces only└── variants/ ├── default/ ← default variant (execution) │ └── peppy.json5 └── mujoco/ ← another variant (execution) └── peppy.json5// uvc_camera/peppy.json5 (root node — no execution){ schema_version: 1, manifest: { name: "uvc_camera", tag: "0.1.0", variants: [ { name: "default", source: { local: "./variants/default" } }, { name: "mujoco", source: { local: "./variants/mujoco" } }, ], }, interfaces: { topics: { emits: [ { name: "image", qos_profile: "sensor_data", message_format: { width: "u32", height: "u32", encoding: "string", data: { $type: "array", $items: "u8" }, }, }, ], }, }, // No `execution` — it comes from the default variant}{ schema_version: 1, execution: { language: "rust", add_cmd: ["cargo", "build", "--release"], start_cmd: ["./target/release/uvc_camera"], },}When you run peppy node add ./uvc_camera without specifying a --variant, the default variant is resolved automatically. You can still select a different variant explicitly with peppy node add --variant mujoco ./uvc_camera.
Variant config
Section titled “Variant config”A variant’s peppy.json5 only needs a schema_version and an execution. The manifest and interfaces fields are optional:
// peppy.json5 (mock variant){ schema_version: 1, execution: { language: "rust", start_cmd: ["./target/release/mock_brain"], },}Each variant has its own execution, which means:
- Its own
language(Rust or Python). - Its own
add_cmdfor build steps. - Its own
start_cmdorcontainerconfiguration. - Its own
parametersschema.
Each variant also gets its own .peppy directory with independently generated code bindings and fingerprints.
Interface matching
Section titled “Interface matching”If a variant’s peppy.json5 does define an interfaces section, the interfaces must match the root node’s interfaces. The comparison is order-independent — topics, services, and actions can appear in any order, and message format fields can be in any order, as long as the same items and attributes are present.
If the interfaces do not match, the add operation fails with a VariantInterfaceMismatch error.
// This variant config is valid — same interfaces, different order{ schema_version: 1, interfaces: { topics: { emits: [ // Same topic as root but message format fields in different order { name: "joint_commands", qos_profile: "sensor_data", message_format: { velocities: { $type: "array", $items: "f64", $length: 6 }, positions: { $type: "array", $items: "f64", $length: 6 }, }, }, ], }, }, execution: { language: "python", add_cmd: ["uv", "sync"], start_cmd: ["uv", "run", "mock_brain"], },}If a variant omits interfaces entirely, no validation is performed and the root’s interfaces are used as-is.
Adding a variant
Section titled “Adding a variant”Use the --variant flag with peppy node add. The source type is detected automatically:
# By name (looked up in root manifest)peppy node add --variant mock ./robot_brain
# From a git repository (with ref)peppy node add \ --variant https://github.com/example/mock-brain.git/brain@main \ ./robot_brain
# From an HTTP archivepeppy node add \ --variant https://releases.example.com/mock-brain-0.1.0.tar.zst \ ./robot_brainFor git sources, append @ref to specify a branch, tag, or commit (e.g., @main, @v1.0).
In all cases, the positional argument (./robot_brain) is the root node — its manifest and interfaces are used. The variant provides only the execution. The variant appears in the node stack under the root’s name and tag (robot_brain:0.1.0).
Variant source types in the manifest
Section titled “Variant source types in the manifest”When declaring variants in the manifest, sources support the same three types as regular deployment sources:
| Source type | Example |
|---|---|
| Local path | { local: "../mock_brain" } |
| Git repo | { repo: "https://github.com/...", path: "brain", ref: "main" } |
| URL archive | { url: "https://example.com/brain.tar.zst", sha256: "..." } |
Local paths are resolved relative to the root node’s directory.