Skip to content

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 .json5 file whose body declares peppy_schema: "node/v1" is treated as a node config, regardless of its filename, keyed by the name:tag declared in its manifest.
  • Launchers, identified by content: every .json5 file whose body declares peppy_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.json5 becomes the launcher named openarm01_sim_teleop).
  • Contracts, identified by content: every .json5 file whose body declares peppy_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 the name:tag declared in its manifest; nodes claim contracts by name:tag in manifest.implements.
  • Pairings, identified by content: every .json5 file whose body declares peppy_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 the name:tag declared in its manifest; see Pairing.
  • MCP exposures, identified by content: every .json5 file whose body declares peppy_schema: "mcp_exposure/v1" is treated as an MCP exposure, regardless of its filename, keyed by the name:tag declared 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 by name:tag and the server built into peppy serves them, and peppy repo index --check --validate-mcp-exposures validates 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 on main, id 1000): a curated collection of ready-to-use nodes.
  • launchers-hub (https://github.com/Peppy-bot/launchers-hub.git, tracked on main, id 1001): community launch files that compose nodes from the hubs.
  • contracts-hub (https://github.com/Peppy-bot/contracts-hub.git, tracked on main, id 1002): shared contract definitions that nodes claim by name:tag in manifest.implements.
  • mcp-hub (https://github.com/Peppy-bot/mcp-hub.git, tracked on main, id 1003): MCP exposure documents that a launcher lists by name:tag and the server built into peppy serves.
  • pairings-hub (https://github.com/Peppy-bot/pairings-hub.git, tracked on main, id 1004): shared pairing definitions that nodes play roles in by name:tag in depends_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.

Repository configuration lives in ~/.peppy/conf/, and the indexes built by peppy repo refresh are written to ~/.peppy/cache/:

FilePurpose
conf/repositories.json5Repositories to scan
conf/excluded_repositories.json5Repositories (or subdirectories) to skip
cache/nodes.json5Index of nodes discovered across repositories, with the contract and pairing links each node’s manifest declares
cache/launchers.json5Index of launch files discovered across repositories
cache/contracts.json5Index of contract definitions discovered across repositories
cache/pairings.json5Index of pairing definitions discovered across repositories
cache/mcp_exposures.json5Index of MCP exposure documents discovered across repositories
cache/repo_status.json5Per-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:

repositories.json5
[
{ id: 1, type: "fs", path: "/home/user" },
{ id: 2, type: "git", url: "https://github.com/Peppy-bot/nodes-hub.git", ref: "main" },
]
TypeFieldsDescription
fspathA local directory, read through its committed peppy_repository.json5.
giturl, 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).

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 IdentityAgent names are offered first, as the URL’s user (git when the URL names none). Without that directive the agent is the one SSH_AUTH_SOCK names; IdentityAgent none offers no agent.
  • The host’s IdentityFile list follows, in ssh’s order, each path offered when it exists as a file. That list is ssh’s own defaults under ~/.ssh unless the configuration names others.
  • The host key is checked against ~/.ssh/known_hosts before 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.

Terminal window
peppy repo init

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

Terminal window
peppy repo list

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

Terminal window
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.

Terminal window
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 role

A 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 valueMeaning
unpinnedNo 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).

Terminal window
peppy repo refresh

Re-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.json5 publishes: 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 manifest name:tag). A repository without that file fails its read with instructions to run peppy 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.

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 read
Run `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:

KindMeaningTypical cause
unreachableThe 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.
conflictThe 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.

Terminal window
peppy repo add <source> [--ref <tag-or-branch>] [--top | --id <id>]

Adds a new repository to repositories.json5. The source format is auto-detected:

Terminal window
# Local directory
peppy repo add /path/to/my/nodes
# Git repository
peppy repo add https://github.com/org/repo.git
# Private git repository over SSH (see "Private git repositories over SSH" above)
peppy repo add [email protected]:org/private-repo.git
# Git repository pinned to a branch or tag
peppy 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 2000

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

Terminal window
peppy repo remove <id>

Removes a repository by its numeric ID (shown by peppy repo list). The repository index is automatically refreshed after removal.

Terminal window
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:

Terminal window
# Exclude a whole git repository
peppy repo exclude https://github.com/org/repo.git
# Exclude a subdirectory of a local repository
peppy repo exclude /home/user/projects/private-nodes

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:

  • path names 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 --check fails 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 next peppy repo index run 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.

Terminal window
peppy repo index [PATH] # write PATH/peppy_repository.json5 (PATH defaults to .)
peppy repo index [PATH] --check # verify the committed index instead of writing it
peppy repo index [PATH] --check --validate-mcp-exposures # also validate every listed exposure against its contracts

No 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”
Terminal window
peppy repo index [PATH] --check --validate-mcp-exposures

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

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:

Terminal window
peppy node add uvc_camera:v1

This 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:

  • --ref is rejected. The git ref (if any) is pinned once in repositories.json5 when 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.

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:

Terminal window
peppy node sync -r

The lookup order is:

  1. 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.
  2. Repository cache (~/.peppy/cache/nodes.json5): consulted only when the stack does not have the dependency. Repo-resolved deps are listed under Synchronized from repositories:, each tagged with its source kind (fs or git).

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_brain
Synchronized from node stack:
- already_added_dep:v1
Synchronized 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:

Terminal window
peppy repo add ~/code/my-nodes
peppy repo index ~/code/my-nodes # writes peppy_repository.json5, stating what the directory publishes
peppy repo refresh
peppy node sync -r # picks up uvc_camera, lidar_sensor, ... from ~/code/my-nodes

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

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
  • .peppy
  • target
  • node_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.