Repositories
Repositories tell Peppy where to look for nodes, launchers, contracts, pairings, and MCP exposures. Each repository states what it publishes through a committed peppy_repository.json5 at its root; peppy repo refresh reads that index from every configured repository and caches the result. The index itself is written by peppy repo index, which walks the tree and records:
- Nodes, identified by content: every
.json5file whose body declarespeppy_schema: "node/v1"is treated as a node config, regardless of its filename, keyed by thename:tagdeclared in its manifest. - Launchers, identified by content: every
.json5file whose body declarespeppy_schema: "launcher/v1"is treated as a launcher, regardless of its filename. The launcher is keyed by the file stem (e.g.openarm01_sim_teleop.json5becomes the launcher namedopenarm01_sim_teleop). - Contracts, identified by content: every
.json5file whose body declarespeppy_schema: "contract/v1"is treated as a contract, regardless of its filename. A contract is a reusable declaration of topics, services, and actions, keyed by thename:tagdeclared in its manifest; nodes claim contracts byname:taginmanifest.implements. - Pairings, identified by content: every
.json5file whose body declarespeppy_schema: "pairing/v1"is treated as a pairing, regardless of its filename. A pairing is a two-role, topics-only contract that two node instances pair 1:1 over, keyed by thename:tagdeclared in its manifest; see Pairing. - MCP exposures, identified by content: every
.json5file whose body declarespeppy_schema: "mcp_exposure/v1"is treated as an MCP exposure, regardless of its filename, keyed by thename:tagdeclared in its manifest. An exposure selects members of contracts and gives them stable public names, MCP-facing prose, and operational policies; a launcher lists exposures byname:tagand the server built intopeppyserves them, andpeppy repo index --check --validate-mcp-exposuresvalidates each one against the contracts it references. See MCP exposure.
Out of the box, five repositories are configured:
nodes-hub(https://github.com/Peppy-bot/nodes-hub.git, tracked onmain, id1000): a curated collection of ready-to-use nodes.launchers-hub(https://github.com/Peppy-bot/launchers-hub.git, tracked onmain, id1001): community launch files that compose nodes from the hubs.contracts-hub(https://github.com/Peppy-bot/contracts-hub.git, tracked onmain, id1002): shared contract definitions that nodes claim byname:taginmanifest.implements.mcp-hub(https://github.com/Peppy-bot/mcp-hub.git, tracked onmain, id1003): MCP exposure documents that a launcher lists byname:tagand the server built intopeppyserves.pairings-hub(https://github.com/Peppy-bot/pairings-hub.git, tracked onmain, id1004): shared pairing definitions that nodes play roles in byname:tagindepends_on.pairings; see Pairing.
The id is the priority key when several repositories provide the same name:tag, so the defaults resolve in this order.
Add your own local directory with peppy repo add /path/to/my/nodes, then peppy repo index /path/to/my/nodes and peppy repo refresh, so Peppy can discover nodes you create locally. A repository publishes through its committed index, so all three steps are needed before a local node resolves.
Configuration files
Section titled “Configuration files”Repository configuration lives in ~/.peppy/conf/, and the indexes built by peppy repo refresh are written to ~/.peppy/cache/:
| File | Purpose |
|---|---|
conf/repositories.json5 | Repositories to scan |
conf/excluded_repositories.json5 | Repositories (or subdirectories) to skip |
cache/nodes.json5 | Index of nodes discovered across repositories, with the contract and pairing links each node’s manifest declares |
cache/launchers.json5 | Index of launch files discovered across repositories |
cache/contracts.json5 | Index of contract definitions discovered across repositories |
cache/pairings.json5 | Index of pairing definitions discovered across repositories |
cache/mcp_exposures.json5 | Index of MCP exposure documents discovered across repositories |
cache/repo_status.json5 | Per-repository read status: when each one was last read successfully, and how its most recent read failed |
The two conf/ files are JSON5 arrays. Each entry has an id (auto-assigned if missing), a type, and source-specific fields:
[ { id: 1, type: "fs", path: "/home/user" }, { id: 2, type: "git", url: "https://github.com/Peppy-bot/nodes-hub.git", ref: "main" },]Source types
Section titled “Source types”| Type | Fields | Description |
|---|---|---|
fs | path | A local directory, read through its committed peppy_repository.json5. |
git | url, ref (optional) | A git repository. Peppy shallow-clones it and reads the same committed index. Use ref to pin a branch, tag, or commit. The url is HTTPS or an SSH URL (see Private git repositories over SSH). |
These are the only two source types. An entry with any other type fails its read as unreachable with an “unrecognized repository entry” detail.
An fs repository is a path only this machine can read, so a deployment resolved from one must keep
all of its instances on this daemon in a federated launch; serve the nodes from a git repository
to place them on other machines (see Federation limits).
Private git repositories over SSH
Section titled “Private git repositories over SSH”A git repository’s url may be HTTPS or SSH, so a private repository is reachable without embedding a
credential in repositories.json5. Give it either the scp-style git@host:owner/repo.git or an
ssh:// URL, and Peppy authenticates the way ssh itself does for that host. It reads what
~/.ssh/config selects for the URL’s host, user and port through ssh -G, so every Host and
Match block, Include file and system default applies exactly as it does to ssh:
- The keys of the agent the host’s
IdentityAgentnames are offered first, as the URL’s user (gitwhen the URL names none). Without that directive the agent is the oneSSH_AUTH_SOCKnames;IdentityAgent noneoffers no agent. - The host’s
IdentityFilelist follows, inssh’s order, each path offered when it exists as a file. That list isssh’s own defaults under~/.sshunless the configuration names others. - The host key is checked against
~/.ssh/known_hostsbefore any key is offered, so a host missing from that file, or one whose key has changed, is refused.
A refusal names everything that was offered: the agent socket, the identity files that exist, and
the configured paths that do not, so a typo in ~/.ssh/config is visible in the message. Without
ssh on the PATH, Peppy offers the agent SSH_AUTH_SOCK names and the default identity files
id_ed25519, id_ecdsa and id_rsa under ~/.ssh.
The daemon binds one agent for its lifetime. When it starts, it points SSH_AUTH_SOCK at the agent
~/.ssh/config selects for a host with no host-specific configuration (a Host * block, the way
1Password, Secretive and gpg-agent are wired in), and logs which one. That is what makes those agents
reachable from a daemon installed with peppy service install, which starts with a clean environment,
and from a shell whose SSH_AUTH_SOCK names another agent. A host whose own block selects a different
agent is refused by name; give every host the same IdentityAgent and restart the daemon.
Load a key the remote accepts into the agent ssh uses, or keep an unencrypted identity file at one
of the configured paths, and both peppy repo add and peppy node add reach the
repository over SSH.
Commands
Section titled “Commands”Initialize the defaults
Section titled “Initialize the defaults”peppy repo initSyncs repositories.json5 with the bundled default template. If the file does not yet exist it is created verbatim; otherwise any missing default entries are appended without touching your existing entries. Use this after upgrading Peppy to pick up new defaults without having to restart the daemon. The command operates directly on the local config file; no daemon connection is required.
List repositories
Section titled “List repositories”peppy repo listShows the indexed nodes grouped by the repository that provides them. Each group is headed by the repository’s display label (path for fs, url (ref: r) for git) followed by the node count and source kind, then lists each node’s name, tag, and path.
The list reflects the index, not the filesystem, so it shows what will actually resolve at launch. A node added to a local repository does not appear until you run peppy repo index <path> and then peppy repo refresh, which is also when it becomes launchable.
One situation is flagged: (shadowed by <repository>) means another repository with a lower id provides the same name:tag. This resolves deterministically to the named repository; the shadowed entry is recorded and inspectable but not used. A supported arrangement, not a problem.
A repository whose last refresh failed is marked [retained, last read <date>], with the reason on the following line. Its entries are the ones it last published successfully, so anything referencing them keeps working; the date tells you how old they are.
Search the repositories
Section titled “Search the repositories”peppy repo search <name-regex>[:<tag-regex>][@<sha256>]Finds any indexed item: nodes, launchers, contracts, pairings, and MCP exposures. Each query part is an unanchored regular expression, matched the way apt search matches its patterns: camera finds rgb_camera, ^rgb_camera$ only the exact name, cam(era)?:v[12] any combination of the alternatives. Matching is case-sensitive; prepend (?i) to a part for case-insensitive matching. A missing tag part matches every tag. Launchers carry no tag, so a bare name finds them, while a tag pattern only matches them when it matches the empty string (demo:.* does, demo:v1 does not). The : splits the query, so a POSIX class like [[:digit:]] cannot be written; use [0-9]. @<sha256> keeps only the copies carrying exactly those bytes, wherever they are published, so .*@<sha256> finds a document knowing only its fingerprint.
Every matching identity is listed with where it is stored, names the pattern matches outright first, then by name:
camera
3 items match `camera`┌──────────────┬───────────────────┬────────────────────────────────────────────────────────────┬────────────────────────────┬──────────┐│ KIND │ ITEM │ REPOSITORY │ PATH │ SHA256 │├──────────────┼───────────────────┼────────────────────────────────────────────────────────────┼────────────────────────────┼──────────┤│ mcp exposure │ front_camera:v1 │ https://github.com/Peppy-bot/mcp-hub.git (ref: main) │ cameras/front_camera.json5 │ 583f9ac9 ││ contract │ rgb_camera:v1 │ https://github.com/Peppy-bot/contracts-hub.git (ref: main) │ cameras/rgb_camera.json5 │ 4594da02 ││ node │ sim_rgb_camera:v1 │ https://github.com/Peppy-bot/nodes-hub.git (ref: main) │ sim_rgb_camera/peppy.json5 │ 9f2c1a77 │└──────────────┴───────────────────┴────────────────────────────────────────────────────────────┴────────────────────────────┴──────────┘The SHA256 column shows the first eight characters of each document’s fingerprint, as the PIN cells of peppy repo show do. A @<sha256> query takes the complete 64-character fingerprint; --json carries fingerprints whole.
The search reads this machine’s caches, so it reflects the last peppy repo refresh and needs no daemon (--core-node has no effect). A query nothing matches is an answer, not an error: the command says so and, when repositories are excluded, that one of them may have provided the item. --json prints the same answer as one JSON document with query (the parsed raw, name, tag, and sha256 parts), matches (each with its kind, name, tag, exact, and published document), and excluded.
Show what a query matches
Section titled “Show what a query matches”peppy repo show <name-regex>[:<tag-regex>][@<sha256>]Takes the same query as peppy repo search and prints the full report of every identity it matches, in the search’s order, the way apt show prints one record per named package: peppy repo show '^rgb_camera$:^v1$' reports the one contract (the anchors keep names like sim_rgb_camera out, since each unanchored part matches anywhere in the name or tag), and peppy repo show 'rgb_camera:v[12]' answers who still uses each version in one pass.
A report’s first lines name each document of the identity with its kind, repository, path, and sha256; contracts and pairings are separate namespaces, so an identity published as both is reported as both, and one report covers them. For a contract or pairing, one section per way a node uses it follows: implementing it (manifest.implements), consuming it (depends_on.contracts), playing a role in it (depends_on.pairings), or observing one of its roles (depends_on.pairing_observers). Sections are grouped by repository like peppy repo list, each group a table with one row per slot: NODE and TAG, ROLE (pairing sections only), SLOT (the link_id, with its cardinality or optional), PIN, and PATH (the manifest path). A launcher or an MCP exposure has no usage sections; its published line is the whole answer. A table wider than the terminal gives up width from its widest columns first, never below a header’s width, and wraps over-long cells onto continuation lines inside the outline; piped output is never wrapped.
^rgb_camera$:^v1$ contract rgb_camera:v1 published by https://github.com/Peppy-bot/contracts-hub.git (ref: main) at cameras/rgb_camera.json5 (sha256 a4e66999c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8)
Implemented by 2 indexed nodes https://github.com/Peppy-bot/nodes-hub.git (ref: main): ┌──────────────────┬─────┬────────┬────────────────────────┬──────────────────────────────┐ │ NODE │ TAG │ SLOT │ PIN │ PATH │ ├──────────────────┼─────┼────────┼────────────────────────┼──────────────────────────────┤ │ uvc_camera_linux │ v1 │ camera │ pin a4e66999 (current) │ uvc_camera/linux/peppy.json5 │ │ realsense_d4xx │ v1 │ camera │ unpinned │ realsense_d4xx/peppy.json5 │ └──────────────────┴─────┴────────┴────────────────────────┴──────────────────────────────┘
Consumed by 1 indexed node https://github.com/Peppy-bot/nodes-hub.git (ref: main): ┌──────────────────┬─────┬──────────────────────┬──────────┬────────────────────────────────────────────┐ │ NODE │ TAG │ SLOT │ PIN │ PATH │ ├──────────────────┼─────┼──────────────────────┼──────────┼────────────────────────────────────────────┤ │ episode_recorder │ v1 │ camera (one_or_more) │ unpinned │ example_robot/episode_recorder/peppy.json5 │ └──────────────────┴─────┴──────────────────────┴──────────┴────────────────────────────────────────────┘For a node, the report reads the same cache the other way: one section per pairing slot the node declares (depends_on.pairings), in manifest order, listing the nodes whose own slot plays the pairing’s other role. The title names the slot, the pairing it resolves through, the role this node plays, and optional where the slot may run with no peer; the rows are the peers’ own slots, in the columns a pairing’s own report uses, and the count is of nodes, so a node offering two complementary slots takes two rows and counts once. Being listed here is a match of pairing and role, which is what pairing requires and what the caches record; whether a stack of the two is one worth running is a launcher’s business (see Launch files).
^sim_rgb_camera$:^v1$ node sim_rgb_camera:v1 published by https://github.com/Peppy-bot/nodes-hub.git (ref: main) at sim_rgb_camera/peppy.json5 (sha256 34d40c0140066c9055879c2d4bd022a7f665752ab834247a9ff69e4fd68a1899)
Slot engine (sim_rgb_camera_link:v1 as viewer) can pair with 2 indexed nodes https://github.com/Peppy-bot/nodes-hub.git (ref: main): ┌────────────────────┬─────┬────────┬────────────────────────┬──────────┬────────────────────────────────┐ │ NODE │ TAG │ ROLE │ SLOT │ PIN │ PATH │ ├────────────────────┼─────┼────────┼────────────────────────┼──────────┼────────────────────────────────┤ │ openarm_sim_isaac │ v1 │ camera │ wrist_left (optional) │ unpinned │ openarm/sim_isaac/peppy.json5 │ │ openarm_sim_isaac │ v1 │ camera │ wrist_right (optional) │ unpinned │ openarm/sim_isaac/peppy.json5 │ │ openarm_sim_mujoco │ v1 │ camera │ wrist_left (optional) │ unpinned │ openarm/sim_mujoco/peppy.json5 │ │ openarm_sim_mujoco │ v1 │ camera │ wrist_right (optional) │ unpinned │ openarm/sim_mujoco/peppy.json5 │ └────────────────────┴─────┴────────┴────────────────────────┴──────────┴────────────────────────────────┘A slot nothing complements keeps its line and says so, which on a slot that is not optional means no indexed node completes a launch of this one:
Slot engine (sim_rgb_camera_link:v1 as viewer): no indexed node plays the other roleA node’s other links have no such section: a contract slot’s candidates are that contract’s own implementers, one peppy repo show away, and an observer slot watches a pair two other nodes form rather than filling a role itself. A node that declares no pairing slot has no section at all, so its published line is the whole answer.
The PIN column says what a sync does with the claim’s sha256:
PIN value | Meaning |
|---|---|
unpinned | No pin: the claim resolves to the published document. |
pin <sha> (current) | The pin equals the published document’s fingerprint. |
pin <sha> (cached copy in <repository>) | Another repository’s cached copy carries that fingerprint; a sync resolves through it. |
pin <sha> (not in cache) | No cached copy carries the pin; a sync fails. |
unusable pin (<reason>) | The pin is not a fingerprint; a sync refuses the manifest. |
(shadowed by <repository>) means what it means in peppy repo list: a lower-id repository provides the same name:tag, so a launch resolves to that node, which may not make this claim at all.
The show reads the same caches as the search and needs no daemon. A query nothing matches is an error, since the command was asked for a report it cannot print, and so is an identity one repository publishes twice: its report has no answer a launch would accept. --json prints one JSON document with query, matches, and excluded as the search prints them, plus reports: one usage report per matched identity in match order, each with its implementers, consumers, participants, observers, and pairing_slots (one entry per pairing slot a matched node declares, with its link_id, pairing, role, optional, and the peers that could fill it).
Refresh the index
Section titled “Refresh the index”peppy repo refreshRe-scans all configured repositories and rebuilds the node, launcher, contract, pairing, and MCP exposure indexes. peppy repo update is accepted as an alias. During refresh, Peppy:
- Reads
repositories.json5(creates it with defaults on first run). - Skips any repository or path listed in
excluded_repositories.json5. - Reads local directories in place and shallow-clones git repositories.
- Records what each repository’s committed
peppy_repository.json5publishes: nodes and their tags, together with the contract and pairing links each node’s manifest declares, launchers (one per name, no tag), and contracts, pairings, and MCP exposures (keyed by their manifestname:tag). A repository without that file fails its read with instructions to runpeppy repo index. - Reports each discovered node, launcher, contract, pairing, MCP exposure, and excluded repository in real time.
- Caches results in
~/.peppy/cache/nodes.json5,~/.peppy/cache/launchers.json5,~/.peppy/cache/contracts.json5,~/.peppy/cache/pairings.json5, and~/.peppy/cache/mcp_exposures.json5, and records per-repository read status in~/.peppy/cache/repo_status.json5.
On completion it prints a summary, for example Repository refresh complete. 12 node(s), 3 launcher(s), 5 contract(s), 2 pairing(s), 1 MCP exposure(s) found.
When multiple repositories provide the same name:tag pair, the repository with the lower id takes priority. The shadowed entry is still recorded and shown in repo list but does not override the primary source.
When one repository provides the same name:tag twice, there is no winner to pick. That repository fails its refresh; see When a repository fails below.
When a repository fails
Section titled “When a repository fails”A failure is scoped to the repository that caused it. The repositories that read cleanly are indexed as usual, so a problem in one hub never blocks picking up changes from the others.
A repository that fails keeps the entries it last published. Nothing disappears out from under a launcher that references it, and the machine keeps launching everything it could launch before. peppy repo list marks those entries as retained, with the date they were read.
The run itself still succeeds. A repository that could not be read is a state to report, not a reason to stop: everything the run could do, it did. It names every repository that failed in one message, so you fix everything after one run rather than running repeatedly, and ends by pointing you back at the retry:
Repository refresh complete. 26 node(s), 4 launcher(s), 8 contract(s), 2 pairing(s), 0 MCP exposure(s) found.2 of the configured repositories could not be updated. Every other repository was updated normally. - repository 1000 (https://github.com/Peppy-bot/nodes-hub.git (ref: main)) contradicts itself [conflict]: duplicate key `v1`: already declared at uvc_camera_video_reconstruction/python/peppy.json5, declared again at uvc_camera_video_reconstruction/rust/peppy.json5. Kept 18 entries from its last successful read - repository 1002 (https://github.com/Peppy-bot/contracts-hub.git (ref: main)) could not be read [unreachable]: failed to connect to github.com. Kept 8 entries from its last successful readRun `peppy repo refresh` again to pick those repositories up.This is also what lets the installer finish on a machine whose network is flaky. Its last step is a refresh, so a hub that happens to be unreachable used to end an install that had otherwise worked. Now the install completes, tells you which repositories it could not read, and leaves you the same retry.
Only a refresh that could not run at all fails: a repositories.json5 that does not parse or that lists the same id twice, or caches that cannot be written. Nothing was read in that case, so there is nothing to contain.
Two kinds of failure are reported, and the distinction matters because they send you to different places:
| Kind | Meaning | Typical cause |
|---|---|---|
unreachable | The repository could not be read at all. | Network outage, a bad git ref, a local path that is not mounted, a root with no committed peppy_repository.json5, an unrecognized entry in repositories.json5. |
conflict | The repository was read fine, but its committed index contradicts itself or the tree. | An index that fails to parse (a duplicate key, an unknown field, an illegal path), or a listed path that is missing, is of another kind, or declares another identity. |
An unreachable repository is an outage, so the fix is usually to wait or fix connectivity. A conflict is a content bug in the repository, and the message names the exact files, so the fix is a change to that repository.
The report is ordered by repository id and never inherits the filesystem’s directory order, so two machines seeing the same problem report it identically.
Add a repository
Section titled “Add a repository”peppy repo add <source> [--ref <tag-or-branch>] [--top | --id <id>]Adds a new repository to repositories.json5. The source format is auto-detected:
# Local directorypeppy repo add /path/to/my/nodes
# Git repositorypeppy repo add https://github.com/org/repo.git
# Private git repository over SSH (see "Private git repositories over SSH" above)
# Git repository pinned to a branch or tagpeppy repo add https://github.com/org/repo.git --ref v2.0
# Give the new repo top priority (lower `id` than every existing entry)peppy repo add /path/to/my/nodes --top
# Pin the repo under an exact id from the reserved band (>= 2000)peppy repo add /path/to/my/nodes --id 2000The --ref flag is only valid for git sources. By default the new repository is appended with id = max(existing ids) + 1, so it has the lowest priority among configured repositories; pass --top to assign it an id just below the current minimum, giving it the highest priority. The priority id decides which repository wins when several of them provide the same name:tag (see Refresh the index).
Pass --id <id> to pin the new repository under that exact id instead of a derived one. Take ids from the reserved band >= 2000: Peppy’s bundled defaults stay below 2000, so a pinned id never collides with a default. --id conflicts with --top, since both decide which id the entry takes. Pinning an id another entry already holds is refused, naming the id (for example repository id 2000 is already in use).
Remove a repository
Section titled “Remove a repository”peppy repo remove <id>Removes a repository by its numeric ID (shown by peppy repo list). The repository index is automatically refreshed after removal.
Exclude a repository
Section titled “Exclude a repository”peppy repo exclude <source> [--ref <tag-or-branch>]Adds a source to excluded_repositories.json5. Excluded repositories are skipped during peppy repo refresh, and the repository index is automatically refreshed after the exclusion. You can exclude an entire repository or a specific subdirectory within a local repository:
# Exclude a whole git repositorypeppy repo exclude https://github.com/org/repo.git
# Exclude a subdirectory of a local repositorypeppy repo exclude /home/user/projects/private-nodesThe repository index
Section titled “The repository index”A repository index is a peppy_repository.json5 at the root of the tree Peppy scans, stating what
the repository publishes and where each item is declared. peppy repo index
writes it and --check verifies it against the tree, and it is committed alongside the items it
lists, so a contested identity is caught on the branch that created it rather than on every machine
that later refreshes.
The nesting is the uniqueness rule: name, then tag, then the file that declares the item. A second claim on one identity has nowhere to go except a key that is already taken, so a repository cannot state two answers to the same question.
// peppy_repository.json5, at the root of the tree Peppy scans{ peppy_schema: "repository/v1",
nodes: { uvc_camera: { v1: { path: "uvc_camera/rust/peppy.json5" }, v2: { path: "uvc_camera/python/peppy.json5" }, }, }, contracts: { rgb_camera: { v1: { path: "cameras/rgb_camera.json5" } } }, pairings: { joint_link: { v1: { path: "robot/joint_link.json5" } } }, mcp_exposures: { camera_surface: { v1: { path: "exposures/camera_surface.json5" } } },
// One level deep: a launcher is identified by its file stem and carries no tag. launchers: { openarm_v1_teleop: { path: "openarm/openarm_v1_teleop.json5" } },}Rules worth knowing before you hand-edit one:
pathnames the declaring file, not a directory. One rule across all five kinds, since only nodes are directory-shaped.- A repeated key is an error, not a last-one-wins overwrite. The message names the identity and both files.
- Paths stay inside the repository. Absolute paths,
..,., repeated separators, backslashes and trailing separators are all refused, as is a path that reaches its target through a symlink out of the tree. - Only the mapping belongs here. Nothing is copied out of an item’s own manifest, because a copy would drift from the thing it copied.
- Unknown fields and unknown versions are refused rather than ignored, so an index Peppy does not fully understand is rejected outright instead of silently losing part of itself.
- An empty index is valid; a missing one is not. A repository that publishes nothing has an index
with no sections, and
--checkfails on a repository that carries no index at all. “Publishes nothing” and “has no index” are different situations and get different answers. - The file is generated. Comments survive
--check, but the nextpeppy repo indexrun discards them.
A launcher fragment file (peppy_schema: "launcher_fragment/v1") declares no item of its own: it
is a piece of a composed launcher, referenced by the option that wants it (see
Launch files), so it gets no
entry here, takes no launcher name, and is never listed as a launchable stack. The indexer
recognizes the kind, and peppy repo index --check validates every fragment a launcher references
and flattens every legal selection of its component axes, so a broken combination is refused on
the branch that introduced it rather than at the first launch that picks it.
Index a repository
Section titled “Index a repository”peppy repo index [PATH] # write PATH/peppy_repository.json5 (PATH defaults to .)peppy repo index [PATH] --check # verify the committed index instead of writing itpeppy repo index [PATH] --check --validate-mcp-exposures # also validate every listed exposure against its contractsNo form needs a running daemon, so all of them work in CI and on a branch before anything is merged.
Writing walks the repository and records what it finds. If one identity is claimed twice, generation refuses and names both files:
2 node manifests claim `uvc_camera_video_reconstruction:v1`: uvc_camera_video_reconstruction/python/peppy.json5, uvc_camera_video_reconstruction/rust/peppy.json5--check verifies four things against the repository, and reports everything it finds at once:
- every item in the tree is listed (the mistake people actually make is adding an item and forgetting the index, which would otherwise leave it silently invisible);
- every listed path is a real file inside the repository;
- every listed path declares exactly the kind and identity it is filed under;
- every listed path is where the identity is actually declared: an item whose declaring file moved is reported as moved, not as missing.
Run it on every pull request to a repository others consume. Catching a contested identity on the branch of the person who caused it is the entire point: the alternative is every machine in the fleet reporting it the next morning, none of which can fix it.
The remedy for any failure is the same: re-run peppy repo index and commit the result.
Check the exposures a repository publishes
Section titled “Check the exposures a repository publishes”peppy repo index [PATH] --check --validate-mcp-exposuresThe structural check above reads nothing outside the repository. An MCP exposure document
(peppy_schema: "mcp_exposure/v1") also has to agree with contracts that live elsewhere: every
member it selects must exist in its contract with the expected kind, every policy must fit the
member’s message format, and an author sha256 must match the contract’s bytes. Passing
--validate-mcp-exposures adds that check. After the structural check, every exposure the index
lists is validated against the contracts it references, resolved through this machine’s
repository caches, and every violation of every exposure is reported at once. The report names
each exposure by identity and path, so one run tells an author everything to fix.
The check is only as good as the caches, so a contract the caches cannot resolve is a problem
naming that contract, never a pass: register and refresh the contract repository first
(peppy repo add, then peppy repo refresh). A hub’s CI does exactly that, starting a
throwaway daemon for the refresh, then running the check without one. A file that declares the
exposure schema but does not parse as an exposure declares no item and is listed by no index, so
the check reports it by path rather than letting it pass unseen.
MCP exposure is the guide to writing an exposure and deploying it, and
peppy mcp catalog <name:tag> prints the catalog an endpoint for an exposure advertises, from
the same caches.
Using a repository-indexed node
Section titled “Using a repository-indexed node”Once a node appears in peppy repo list, you can add it by its name:tag without supplying a path or URL; Peppy resolves the source through the cached index at ~/.peppy/cache/nodes.json5:
peppy node add uvc_camera:v1This is the shortest form of peppy node add. It works for any node provided by any repository listed in repositories.json5, including the default nodes-hub community repository. When several repositories provide the same name:tag, the resolution rule from repo refresh applies: the repository with the lower id wins.
Resolving a name:tag has three outcomes, not two:
-
Resolved. Exactly one repository wins, either because it is the only one providing the identity or because it has the lowest
id. -
Not found. No repository provides it. The error names the cache file it looked in, and if you have excluded any repositories it says so, since an excluded repository is never indexed and may well have been the one that provided it.
-
Ambiguous. The cache states that the winning repository claims the identity more than once. A generated index cannot express this (each identity is a key that exists once), so hitting it means the cache file was edited by hand. It is a distinct error naming both files, never a “not found”, since reporting it as missing would send you to add a node that is already there twice:
2 node entries in repository 1000 claim `uvc_camera_video_reconstruction:v1`:uvc_camera_video_reconstruction/python/peppy.json5,uvc_camera_video_reconstruction/rust/peppy.json5;fix the repository so one manifest claims it, then run `peppy repo refresh`
One constraint applies to this source shape:
--refis rejected. The git ref (if any) is pinned once inrepositories.json5when you register the repo, not per-add.
If the node you want to add lives in a repository that is not in repositories.json5, keep using the full git URL or HTTP archive form shown in Sharing nodes. That git URL may be HTTPS or SSH; an SSH URL (git@host:owner/repo.git) reaches a private repository under the same authentication described in Private git repositories over SSH.
A manifest can also pin the exact content of a contract or pairing document it references with a sha256 fingerprint, independent of which repository provides it; see Content pins.
Syncing against repositories
Section titled “Syncing against repositories”peppy node sync regenerates a node’s interface code (peppygen) from its peppy.json5. By default, every dependency the node declares must already be in the node stack, otherwise the sync fails with an “X does not exist in the stack” error.
Pass -r (or --include-repositories) to let Peppy fall back to the repository cache when a dependency is missing from the stack:
peppy node sync -rThe lookup order is:
- Node stack: wins whenever the dependency is already in the stack. Stack-resolved deps are listed under
Synchronized from node stack:in the command output. - Repository cache (
~/.peppy/cache/nodes.json5): consulted only when the stack does not have the dependency. Repo-resolved deps are listed underSynchronized from repositories:, each tagged with its source kind (fsorgit).
Repository-resolved git dependencies reuse the same persistent checkout cache as peppy node add, so the same repository is never cloned more than once during a single sync run.
Example output when both layers contribute:
Syncing node from /workspace/my_robot_brain via daemon 'cn-...'Synced node interfaces at /workspace/my_robot_brainSynchronized from node stack: - already_added_dep:v1Synchronized from repositories: - uvc_camera:v1 (git) - lidar_sensor:v1 (fs)A dependency that is missing from both the node stack and every configured repository is a hard failure:
dep `gps_module:v2` not found in node stack or repository cache; run `peppy repo refresh`peppy node add applies the same stack-then-cache lookup automatically, without a flag, but only for the direct dependencies of empty-admitting slots (cardinality: "zero_or_one" or "zero_or_more") that are absent from the stack: their interfaces still feed code generation, each cache resolution is reported in the add output, and a dependency missing from both sources refuses the add with the same error. A dependency whose slot demands an instance must still be in the stack before the add.
Tip: register your local workspace as a repository
Section titled “Tip: register your local workspace as a repository”When a node depends on another node you maintain locally, registering your workspace directory as a fs repository removes the need to peppy node add every dependency just to regenerate peppygen for a downstream node:
peppy repo add ~/code/my-nodespeppy repo index ~/code/my-nodes # writes peppy_repository.json5, stating what the directory publishespeppy repo refreshpeppy node sync -r # picks up uvc_camera, lidar_sensor, ... from ~/code/my-nodesRe-run peppy repo index ~/code/my-nodes and peppy repo refresh after adding, moving, or renaming
a node in that directory: the refresh reads the committed index, and repo list and node sync -r
both read the cache, not the filesystem.
Particularly useful during the early phase of a multi-node project, when the dependency graph is still in flux and you don’t want to re-add nodes after every interface change.
Directory pruning
Section titled “Directory pruning”When peppy repo index walks a repository to write its index, it skips hidden files and directories
(any name with a leading .) plus the following directories:
.git.peppytargetnode_modules.venv__pycache__
A repository’s own .gitignore and .ignore files are honoured too, while a contributor’s global
gitignore and the clone’s .git/info/exclude are not read. peppy repo refresh does not walk the
tree at all: it resolves exactly the paths the committed index names.