Skip to content

Daemon configuration

By default, the Peppy daemon reads one global configuration file, ~/.peppy/conf/peppy_config.json5. It sets the daemon’s core-node name, selects either a Peppy-managed or operator-run Zenoh router, controls the managed router’s messaging behavior, defines the grace periods that govern node lifecycle, can name a PyPI mirror for Python builds, and can name an OpenTelemetry endpoint the daemon exports its log files to. The daemon applies it to its own core-node session and to every node it spawns. The same file also records the backend resource-server URL the peppy platform commands talk to; that block is read by the CLI, not the daemon. PEPPY_CONFIG can supply a read-only alternative source.

In the normal on-disk flow, you never need to create or migrate this file by hand:

  • First load. If the file does not exist, Peppy creates it with every setting at its default value, annotated with explanatory comments.
  • Missing settings. If the file exists but omits settings this release defines, Peppy detects every omission and appends each setting it can safely place, together with its default value and comments. Your own values, comments, formatting, and any unrecognized top-level keys are otherwise preserved exactly as you wrote them; when appending defaults, completion may add a structural separator comma after the prior final entry. Peppy logs each setting it added. An unusual JSON5 spelling or layout, such as an escaped section key that the text scanner cannot safely pair with its block, produces a warning naming every setting that could not be inserted; the daemon continues with those defaults in memory. Fields required inside a non-default variant are not invented: if you select zenoh.external, you must supply zenoh.external.endpoint.
  • Malformed file. If the file cannot be parsed, or a value is out of range, Peppy refuses to continue and reports the error instead of silently falling back to defaults. A file that fails to load is never modified.

In a normally spelled section, a defaulted setting you delete from the file therefore comes back with its default the next time Peppy loads the configuration. To change one, edit its value instead of removing it. If Peppy warns that an unusual spelling or layout prevented insertion, add the named setting manually if you want it written; the daemon already uses its default in memory. Removing zenoh.external.endpoint from an external block instead makes the file invalid and leaves it untouched.

Set PEPPY_CONFIG to inject the complete daemon configuration at launch. A non-empty value has two forms: a path to a JSON5 config file, or an inline JSON5 document. Peppy expands ~ and ~/ in path form, and resolves relative paths from the command’s current working directory, which is the daemon’s working directory for peppy service serve.

Classification always tries the value as a file first. If the file can be read, file form is committed, so a later parse, validation, or completeness error does not fall back to interpreting the path text as inline JSON5. Only a file read or path-expansion failure triggers the inline parse. If both attempts fail, the error reports both reasons. An empty value is treated as unset and uses the normal file flow; whitespace alone is not empty and fails if it is neither a readable path nor valid inline JSON5.

An override is read-only. Peppy ignores $PEPPY_HOME/conf/peppy_config.json5 and never creates, completes, or rewrites either that file or the override source. The override must parse, pass validation, and explicitly spell out every setting the running release defines. Missing settings are a startup error that names their paths and explains that override sources are never completed with defaults. This differs from the normal on-disk flow, which appends safe defaults and warns when an unusual source spelling prevents an insertion.

The override applies to peppy service serve and to every peppy platform command, all of which read the daemon configuration. PEPPY_HOME still controls credentials, repositories, and all other Peppy state. Edits to a file-form override take effect on the daemon’s next restart, just like edits to the default file.

Both examples below use the same complete config file. The second passes its full contents inline, including any leading JSON5 comments:

Terminal window
PEPPY_CONFIG=./deploy/peppy_config.json5 peppy service serve
PEPPY_CONFIG="$(cat ./deploy/peppy_config.json5)" peppy service serve
// Daemon settings are read once at startup, so edits to them take effect only
// after restart. resource_servers is refreshed by each CLI authentication command.
{
// Fixed name for this daemon's core node, or null to derive a
// machine-specific default (cn-...). Names must be unique across all
// daemons reachable over the same router/federation: a daemon whose name is
// already in use refuses to boot. At most 63 characters from
// "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-".
// `peppy service serve --core-node-name` overrides this for one run.
core_node_name: null,
// The zenoh messaging transport. Configure exactly one of two blocks:
// managed: peppy starts, monitors, restarts, and stops its bundled
// zenohd router, and every knob inside `managed` applies.
// external: you run the router and peppy only dials it; peppy never
// starts, reconfigures, restarts, stops, or federates it,
// local nodes always relay through it, and the managed knobs
// do not exist. Spelled, in place of `managed`, as:
// external: { endpoint: "tcp/<host>:<port>" },
zenoh: {
// Settings peppy can only honor because it owns the router process. Replace
// this whole block with `external` to use your own router.
managed: {
// How the nodes on this machine exchange data with each other. Traffic to
// and from other machines always relays through the local zenohd router and
// its federation, regardless of this setting (each node seeds gossip from
// the local router, so direct peer links form only between co-located nodes).
// "peer" - Zenoh peer sessions with gossip: local nodes form direct
// peer-to-peer links and data stops relaying through the router.
// Each session binds a wildcard listener on an ephemeral port,
// reachable from the network.
// "router" - gossip off: all traffic relays through the central zenohd
// router, and a session binds no listener of its own.
// Container nodes in a separate network namespace (Lima on macOS) always use
// the router path regardless of this setting.
local_nodes_topology: "peer",
// Subscriber channel buffer sizes (number of in-flight messages) per QoS
// tier, used by local node sessions in both managed topologies. They matter
// most in peer mode, where no router relay buffers between a publisher and
// a subscriber. Defaults match peppy's built-in behavior; only edit to tune
// backpressure.
subscriber_buffers: {
standard_buffer_size: 128,
high_throughput_buffer_size: 1024,
},
},
},
lifecycle: {
// Node lifecycle knobs. `daemon_grace_secs` is the grace period a spawned node
// waits, after the daemon's heartbeat goes silent, before shutting itself down
// to avoid orphaning.
daemon_grace_secs: 180,
// How long a clean shutdown (ctrl+C / `systemctl stop`) and `peppy node
// stop` wait for a node to exit cooperatively before force-killing its
// process group. Seconds; minimum 1. A robot node uses this window to park
// actuators and release hardware before it is killed.
shutdown_grace_secs: 5,
},
// Packages base URL of a PyPI mirror, or null. When set, Python node builds
// download the PyPI files that their uv.lock pins from this mirror, and a file
// the mirror does not have still comes from files.pythonhosted.org. uv checks
// every file against the sha256 in the lock. Example:
// "https://pypi.tuna.tsinghua.edu.cn/packages/".
pypi_mirror: null,
// OTLP/HTTP URL of an OpenTelemetry collector or an intake, or null. When set,
// the daemon exports the launch, add, build, run and stack logs to
// <otlp_endpoint>/v1/logs, or to the URL as written when its path already
// ends in /v1/logs. Request headers, such as an API key, go in
// ~/.peppy/conf/otlp_headers.json5. Example: "http://localhost:4318".
otlp_endpoint: null,
// Lowest severity the daemon exports, or null to export every line. One of
// "trace", "debug", "info", "warn". A line below it stays in its log file
// only. A line that prints no level is always exported.
otlp_min_severity: null,
// Backend resource-server URL the `peppy platform` commands talk to. Baked in
// at compile time (the dev backend in debug builds, prod in release);
// --api-url / PEPPY_API_URL override it at runtime.
resource_servers: {
api: "https://api.peppy.bot",
},
}

core_node_name: the daemon’s core-node name

Section titled “core_node_name: the daemon’s core-node name”

core_node_name fixes the name of this daemon’s core node. Leave it null (the default) and the daemon derives a stable, machine-specific name of the form cn-{adjective}-{animal}; set a string to pin an explicit one.

Core-node names must be unique across every daemon reachable over the same router or federation. Each daemon advertises an ephemeral presence token; on boot it refuses to start if that name is already live, and while running it logs a rate-limited collision alarm if another daemon later claims the same name. If two daemons end up sharing a name, give one of them a unique core_node_name (or pass peppy service serve --core-node-name <name> for a single run) and restart it. This most commonly matters when enrolling federates several machines into one project, or when cloning a disk image reuses another machine’s derived name.

The value must be non-empty, at most 63 characters, and use only the node-name character set (letters, digits, _, -); the literal name self is reserved and rejected. An invalid value stops the daemon at startup with an error instead of failing later when the core node boots. peppy service serve --core-node-name <name> overrides the config for one run (the flag wins over the file); if both are absent, the derived default is used.

The zenoh section holds exactly one of managed or external. Block presence selects the mode. managed is the default: omitting zenoh or writing zenoh: {} loads all managed defaults, and normal file completion materializes the explicit managed block.

Use managed when Peppy should start, monitor, restart, and stop its bundled zenohd:

zenoh: {
managed: {
local_nodes_topology: "peer",
subscriber_buffers: {
standard_buffer_size: 128,
high_throughput_buffer_size: 1024,
},
},
},

Use external when Peppy should dial a router that you run:

zenoh: {
external: {
endpoint: "tcp/127.0.0.1:7448",
},
},

The managed knobs exist only because Peppy controls that router process. Under external, local_nodes_topology and subscriber_buffers are not part of the document, and writing them is an error. Local nodes always relay through the external router, local subscriber queues use Peppy’s built-in capacity defaults, and router federation belongs entirely to the operator.

zenoh.managed.local_nodes_topology: local messaging topology

Section titled “zenoh.managed.local_nodes_topology: local messaging topology”

zenoh.managed.local_nodes_topology selects how the nodes on this machine exchange data with each other:

  • "peer" (default): nodes run Zenoh peer sessions with gossip discovery enabled. After discovery, nodes form direct peer-to-peer links and data stops relaying through the central router, which removes a network hop from every message.
  • "router": gossip is off and all traffic relays through the central zenohd router. Use this when direct node-to-node connectivity is unreliable or undesirable, or to simplify debugging by funneling all traffic through one process.

The setting is local by construction, which is why it carries the local_nodes_ prefix: each node seeds gossip discovery from the local router, so a peer session only ever discovers, and forms direct links with, sessions co-located on this machine. Traffic to and from nodes on other machines always relays through the local router and its link to the project’s cloud router (see Platform), whichever topology you pick.

In "peer" mode a session binds a wildcard listener (0.0.0.0 on an ephemeral port) so Zenoh can advertise the host’s routable interface addresses for those direct links; that listener is therefore reachable from the network, not confined to loopback. This widens an existing exposure class rather than opening a new one: the Zenoh transport is unauthenticated, and the managed router’s own listener already binds 0.0.0.0. Only "router" mode leaves a session with no listener at all, because with gossip off nothing needs to advertise a dialable address, so pick it for a deployment that must not accept inbound peer links.

Container nodes that live in a separate network namespace (such as the Lima VM peppy uses on macOS) always take the router path, regardless of this setting: gossip cannot establish direct links across the namespace boundary.

zenoh.managed.subscriber_buffers: subscriber buffer sizes

Section titled “zenoh.managed.subscriber_buffers: subscriber buffer sizes”

Each Peppy subscriber buffers in-flight messages in a bounded process-local queue. The zenoh.managed.subscriber_buffers block sets that queue’s capacity (number of messages, not bytes) per QoS tier:

  • standard_buffer_size (default 128): the local queue for topics on the standard QoS tier, which most topics use. The same capacity also sizes the queues behind service requests and replies.
  • high_throughput_buffer_size (default 1024): the local queue for topics on the high-throughput QoS tier, such as sensor-data streams, where short bursts well above the average rate are normal.

These sizes do not apply to action feedback: the client of a goal keeps every feedback message until it reads it, up to the feedback bound it passes to fire_goal.

These are local Peppy queue capacities, not Zenoh router buffers, and they apply under both managed local topologies. They matter most in the peer topology: with nodes peering directly there is no router relay between a publisher and a subscriber, so the local queue is all that absorbs a burst.

When a topic subscriber falls behind and its local queue fills up, its session stops taking messages from the connection the topic arrives on until the subscriber takes one. This holds up every message on that connection, not only the topic’s: in the router topology that is all of the node’s traffic; in the peer topology it is the traffic from the publishing peer, or from the router for a publisher on another machine. Once the network buffers of that connection are full, the stall reaches the sending side: messages published with the standard or sensor_data profile are dropped there, and messages published with the reliable or critical profile wait for room. When one of them waits five seconds, Zenoh closes the connection to the slow session. An action goal’s feedback buffer never makes its session wait: when the caller falls behind by more unread messages than its bound, the buffer drops the unread messages, a caller that reads the feedback cancels the goal, and its next read gives the loss as an error. The subscriptions of the test harness to a node’s emitted topics, and those of its pairing mocks, do the same. Raise a capacity to absorb longer bursts at the cost of memory and worst-case latency; lower it to surface backpressure, or message loss, sooner. The defaults match Peppy’s built-in behavior, so you only need to touch this block to tune backpressure.

A subscription to a topic that retains holds at least as many messages as the topic retains, whatever these capacities say.

Both values must be greater than 0; the daemon rejects a zero buffer size at startup.

zenoh.external: use an operator-run router

Section titled “zenoh.external: use an operator-run router”

To use a router you start yourself, configure zenoh.external with the Zenoh endpoint Peppy should dial:

zenoh: {
external: {
endpoint: "tcp/127.0.0.1:7448",
},
},

endpoint is a dial address, not a listener bind address. It must use tcp/<host>:<port> with a hostname, an IPv4 address, or a bracketed IPv6 address, for example tcp/router.internal:7448 or tcp/[::1]:7448. The port is required and must be from 1 through 65535. Listener wildcards such as 0.0.0.0, [::], and * are rejected because Peppy cannot connect to them. Metadata/config suffixes and reserved delimiters (?, #, ;, =) are rejected, and tcp/ is the only supported transport.

Peppy propagates this host and port to its CLI sessions and spawned nodes, so the address must be reachable from every environment that runs a node. For Lima containers on macOS, Peppy rewrites a host-local address such as 127.0.0.1 to the VM’s host gateway; a genuinely remote hostname or IP address is preserved.

Start the external router before Peppy. Its listener may bind a wildcard even though Peppy’s dial endpoint must be connectable. For a router on the same machine, for example:

Terminal window
zenohd -l tcp/0.0.0.0:7448

At startup, Peppy requires a responsive Zenoh router at the configured endpoint. Nothing listening is a startup error; a non-Zenoh service at the address is a distinct startup error. Peppy observes a responsive router’s health but never kills it on daemon exit, restarts it after a failure, or changes its configuration to federate it to the cloud router. The operator controls the router’s lifecycle and federation. If it becomes unresponsive while the daemon is running, the watchdog logs a prominent warning but leaves it alone; sessions reconnect automatically after the operator restores it.

peppy platform enroll and unenroll leave external federation untouched and print a note instead of poking the daemon. They do not restart the daemon; an enrollment change reaches its sessions on the next manual daemon restart. PEPPY_MESSAGING_PORT configures the listener for Peppy’s managed router, while external mode uses the port written in zenoh.external.endpoint.

The lifecycle block tunes the two windows Peppy uses to guarantee that no node outlives the daemon. The full mechanics are described in Daemon shutdown and orphan prevention.

  • daemon_grace_secs (default 180, minimum 30): every spawned node runs a watchdog that listens for the daemon’s periodic heartbeat (published every 5 seconds). If the heartbeat goes silent for this many seconds, the node shuts itself down rather than lingering as an orphan. This only governs unclean daemon death (crash, OOM, SIGKILL); a clean shutdown does not wait for it. The minimum exists so a brief daemon stall or a restart of the daemon’s messaging router never trips every node’s watchdog. Only the heartbeats of the node’s own daemon restart this period: each daemon process draws an instance id when it starts and writes it into the runtime config of every node it starts. A new daemon does not wait for this period: a node shuts itself down at the first heartbeat of another daemon of its core node once its own daemon no longer holds its presence token, because the new daemon starts with an empty stack.
  • shutdown_grace_secs (default 5, minimum 1): the node’s cooperative-cleanup budget. A clean daemon shutdown (Ctrl+C, systemctl stop), peppy node stop and the stop of a starting instance that answered ready wait this window, plus a fixed allowance for the node’s runtime to finish tearing down (in Rust, the async runtime dropping; in Python, the event-loop join and interpreter finalize), before force-killing its process group. Raise it for nodes that need longer to park actuators or release hardware before dying; the force-kill deadline rises with it.

pypi_mirror: PyPI mirror for Python builds

Section titled “pypi_mirror: PyPI mirror for Python builds”

pypi_mirror names a PyPI mirror that Python node builds download the PyPI files that their uv.lock pins from. Leave it null (the default) to download them from PyPI; set the packages base URL of a mirror to use it:

pypi_mirror: "https://pypi.tuna.tsinghua.edu.cn/packages/",

With a mirror set, each Python build that does not reuse a cached artifact asks the mirror for the files that its uv.lock pins from PyPI, and downloads each file the mirror has from the mirror. A file the mirror does not have still comes from PyPI, and uv checks every file against the sha256 in the lock, so a mirror cannot change what a build installs. See PyPI mirror for Python builds for what the daemon changes, the line each build shows, and a list of mirrors.

The value must be the packages base URL of the mirror: an https URL under which the mirror keeps the files of PyPI with the path layout of https://files.pythonhosted.org/packages/, so its path ends with packages/. The index URL of a mirror (ending with /simple/) is not a packages base URL. The URL must not hold credentials, a query, or a fragment. Any other value stops the daemon at startup with an error that names pypi_mirror.

otlp_endpoint names the OTLP/HTTP endpoint the daemon exports its launch, add, build, run and stack logs to, as OpenTelemetry log records. Leave it null (the default) and the daemon exports nothing; set the URL of an OpenTelemetry collector or of an intake to turn the export on:

otlp_endpoint: "http://localhost:4318",

The value is the base URL of the collector or intake, and the daemon posts to <otlp_endpoint>/v1/logs; a URL whose path already ends with /v1/logs is the URL the daemon posts to. It must be an http or https URL with no credentials, query, or fragment. Any other value stops the daemon at startup with an error that names otlp_endpoint. Request headers, such as an API key, go in ~/.peppy/conf/otlp_headers.json5, never in the URL. See OpenTelemetry log export for what is exported, the fields of a record, the headers file, and what happens while the endpoint is unreachable.

otlp_min_severity: lowest exported severity

Section titled “otlp_min_severity: lowest exported severity”

otlp_min_severity holds the lowest severity the daemon exports: one of "trace", "debug", "info", "warn". Leave it null (the default) to export every line. A line below it stays in its log file only, and a line that prints no level is always exported. The setting goes up to "warn": the stack log line that reports records the export discarded is a warning, and every setting exports it. The setting has no effect while otlp_endpoint is null. Any other value stops the daemon at startup with an error that names otlp_min_severity and lists the four names. See Severity for how the daemon reads the severity of a line.

This block holds the platform-backend base URL the CLI platform commands talk to. The daemon ignores it; the peppy platform commands read it. See Platform for the sign-in and enrollment flows.

  • api (default https://api.peppy.bot in release builds, https://api.dev.peppy.bot in debug): the backend the auth commands talk to. There is no dev/prod selection at runtime; the file stores exactly the build’s backend.

The URL is resolved in precedence order: --api-url, then PEPPY_API_URL, then api here. An empty block falls back to the build’s default backend. In release builds, plain http is accepted only for local backends (loopback / *.localhost) and any other host must be https, validated when the command runs; debug builds accept remote plain http with a warning.

SettingDefaultConstraintEffect
core_node_namenull (derived cn-...)Non-empty, ≤ 63 chars, node-name charset, self reservedFixed name for this daemon’s core node; must be unique across all daemons on the same router/federation or the daemon refuses to boot. Overridable per run with --core-node-name.
zenoh.managed.local_nodes_topology"peer""peer" or "router"Topology of this machine’s nodes: direct peer links with gossip, or relay everything through the central router. Cross-machine traffic always goes through the routers.
zenoh.managed.subscriber_buffers.standard_buffer_size128> 0Local Peppy subscriber queue capacity (messages) for standard-QoS topics; also sizes service request/reply queues.
zenoh.managed.subscriber_buffers.high_throughput_buffer_size1024> 0Local Peppy subscriber queue capacity (messages) for high-throughput-QoS topics such as sensor-data streams.
zenoh.external.endpointNo defaultRequired inside external; tcp/<dial-host>:<port>; no wildcard hostNetwork endpoint of the operator-run router. Peppy dials it without changing its federation or managing its lifecycle.
lifecycle.daemon_grace_secs180>= 30Seconds without a daemon heartbeat before a spawned node self-terminates (unclean daemon death only).
lifecycle.shutdown_grace_secs5>= 1Seconds a clean shutdown and peppy node stop wait for cooperative exit (plus a fixed runtime-teardown allowance) before force-killing.
pypi_mirrornull (PyPI)null, or an https URL whose path ends with packages/, with no credentials, query, or fragmentPackages base URL of the PyPI mirror Python builds download the PyPI files of their uv.lock from; a file the mirror does not have comes from PyPI.
otlp_endpointnull (no export)null, or an http or https URL with no credentials, query, or fragmentOTLP/HTTP endpoint the daemon exports its log files to, as OpenTelemetry log records.
otlp_min_severitynull (every line)null, or one of "trace", "debug", "info", "warn"Lowest severity the daemon exports; a line that prints no level is always exported.
resource_servers.apihttps://api.peppy.bot (release), https://api.dev.peppy.bot (debug)https, or http for local hostsBackend the CLI auth commands talk to. Read by the CLI, not the daemon.

Both Zenoh variants at once, a value outside its constraint, an unsupported zenoh.external.endpoint, an unknown zenoh.managed.local_nodes_topology value, an unknown key inside zenoh or either variant, or a syntax error all stop the operation that loaded the configuration with an error pointing at the problem. An out-of-range value names the offending field, and a parse error reports the bad value or its position, so a typo can never silently revert your stack to defaults. Unknown top-level keys remain accepted and preserved.