← docs

Wire a project with petbox-wire

petbox-wire is the CLI that connects a project directory to PetBox: it persists the API key where every agent looks, writes the per-harness MCP configs and skills, installs the global session hooks, and compiles agent role files from a portable agent definition. Use it instead of hand-editing configs — the connect guide explains what the wiring gives your agent once it is in place.

Requires Node ≥ 23.6 (the kit is plain TypeScript run through native type-stripping — no build step, no dependencies).

1. Wire one project

Run the full wire once per project directory. You need an API key for that project first — mint it on the project's Connect agent page in the UI; petbox-wire never mints keys.

Pass the key as an environment variable, not --key <KEY> — npm writes the full command line
of every invocation to ~/.npm/_logs/*.log in plain text with no rotation, so an argument-passed
key sits there readable indefinitely; an env-var assignment is never part of that logged argv.
petbox-wire already reads the key straight from process.env[VAR] when --key is omitted, so
no extra flag is needed:

# bash / zsh
PETBOX_<PROJECT>_API_KEY=<API_KEY> npx petbox-wire <dir> <projectKey>

# PowerShell
$env:PETBOX_<PROJECT>_API_KEY='<API_KEY>'; npx petbox-wire <dir> <projectKey>

It validates the key against the server before persisting anything, so a bad key never lands in your stores. Re-running is idempotent and self-heals a half-wired machine.

Flag Effect
--env VAR Name of the environment variable holding the key. Overrides the derived / registered name.
--key KEY The API key, passed directly. Still supported, but every use prints a warning: npm logs the full argv (this key included) to ~/.npm/_logs/*.log in plain text with no rotation. Prefer the environment variable above. Omitted → taken from the environment variable, then from ~/.petbox/keys.json.
--workspace WS Workspace stamped into the generated skill. Omitted → the workspace the server reports for your key. There is no hardcoded fallback: if the server reports none and you pass no flag, the wire stops with a usage error (exit 2).
--cleanup-legacy Remove wiring artefacts left by older kit versions from the project.
--telemetry Wire Claude Code OTLP export into the project's .claude/settings.json (off by default; Claude Code only).
--telemetry-log <name> Target named log for telemetry (default cc-telemetry); the log is created if missing.
--help, -h Usage banner, exit 0.

What the full wire writes into <dir>: .mcp.json (Claude Code), .opencode/opencode.json (opencode), .factory/mcp.json (Factory Droid — merged, not overwritten, so team servers survive), and the kit's eight skills — petbox, petbox-agent-factory, petbox-methodology, petbox-write-economy, petbox-node-authoring, petbox-analysis-workspace, petbox-factory-run, petbox-second-reading — each as a SKILL.md written into both skill surfaces, .claude/skills/<name>/ (Claude Code natively, and opencode through its Claude-compatible discovery path) and .factory/skills/<name>/ (Factory Droid's native root). All three MCP configs reference the key as ${VAR} / {env:VAR} — the key itself is never written into a project file.

Three frontmatter keys ride along on every skill the kit writes, and they are three independent axes — doc/agent-wiring.md §2f in the repo carries the full contract:

  • petbox: managed — provenance. This is the only thing the write guard trusts to recognise a file as the kit's own before it overwrites or deletes it. Put petbox: manual in a file at one of those paths and the kit hands the path back to you: never rewritten, never removed, and — unlike a stranger's file — logged as yours rather than as a refusal, so it can never reach the run's exit code. Any other content there (no marker at all) is refused loudly and left byte-for-byte alone.
  • petbox-digest: auto | manual — whether the skill enters the salience index the kit's opencode plugin injects into opencode's system prompt. auto for petbox, petbox-methodology, petbox-write-economy and petbox-node-authoring; manual for the other four. It changes nothing on Claude Code or Droid.
  • disable-model-invocation: true — the key Claude Code and Droid honour to refuse a model-initiated call, so the skill only runs when a human asks for it by name. Carried by petbox-agent-factory, petbox-analysis-workspace and petbox-factory-run. petbox-second-reading deliberately does not carry it: it stays out of the digest yet remains callable by an agent that decides it applies.

Note: on a fresh machine the environment variable only exists in new terminals (Windows user-scope env; POSIX ~/.petbox/env.sh sourced from your login profiles). The kit's own hooks read ~/.petbox/keys.json directly, but that file does not always hold a copy of the key: when the wire obtained the key from a live environment variable, it writes a ${VAR} reference there instead — never a copy of the secret (see §9 below). A hook whose own process inherits that variable resolves the reference and works immediately; one that doesn't fails loudly at session start, naming the missing variable, rather than silently starting without a key.

2. The env-var name

The key is always held in an environment variable named PETBOX_<PROJECT>_API_KEY. The project key is upper-cased, every run of non-alphanumeric characters collapses to a single _, leading and trailing _ are trimmed, and PETBOX_ is prefixed. So kpvotes → PETBOX_KPVOTES_API_KEY and $system → PETBOX_SYSTEM_API_KEY. This is the same name the UI Connect agent page and the onboarding runbook show you, so the two paths agree.

--env VAR overrides the derived name, and a re-run reuses the name already recorded in the registry for that directory — an existing project never gets renamed under you. If your machine was wired before this scheme landed it may still carry an older name (e.g. _SYSTEM_API_KEY); when a config's ${VAR} doesn't resolve, check ~/.petbox/keys.json for the name you actually have.

3. Commands

Command What it does
petbox-wire <dir> <projectKey> Full wire (above): key → validate → persist → kit copy → registry → project files → hooks → smoke.
petbox-wire update Mirrors this package's src/ into the stable kit at ~/.petbox/wire/ (orphan cleanup + content fingerprint). Nothing else: no keys, no registry, no hooks reinstall, no MCP/skills, no sticky flags. It does not compile agent files — that's apply.
petbox-wire apply [--offline] Compiles per-harness agent role files from the agent definition (built from files: base < user < project) + your local role→model binding.
petbox-wire layers [dir...] Shows the definition cascade: which layers exist on this machine, what each did to the roster, and which layer supplied every field. Read-only. Exit 0 clean / 1 a cascade error / 2 usage / 3 could not check — never confused with each other.
petbox-wire status [--offline] Prints FACT (never a verdict) about the current roster: per role × harness, the materialized artifact path, its bound model, and where that model came from — roster (~/.petbox/roles.json), seed (built-in preview, nothing written yet) or none (a problem — nothing to resolve from). Plus a four-pillar summary: definition layers (which are present, and which supplied each field), roster completeness, memory canon, skill files. Always exits 0 unless status itself crashes.
petbox-wire doctor [--offline] Resolves the agent definition the same way apply does (the file cascade base < user < project) and runs the truthfulness gate for every known harness against it, printing OK or each violation, plus the layers and their per-field provenance. Also reports skill-file drift and the session-banner budget margin. A broken layer is a hard failure (exit 1, the file named). --offline skips the network-backed checks up front (no skill-drift or banner check) — the definition resolve and the truthfulness gate still run, because neither touches a network.
petbox-wire roles Prints the active profile and its role→model bindings from ~/.petbox/roles.json — per role, the model, the provider serving it, and whether the kit or you bound it. Offline; an empty store exits 0 with a message — it never invents a model.
petbox-wire roles export Writes a bootstrap copy of roles.json to stdout (no secrets). Pipe it to a file on a new machine.
petbox-wire profile use <name> Sets activeProfile in ~/.petbox/roles.json (creating an empty profile shell if new). Re-run apply afterwards — this does not compile anything. Offline.
petbox-wire model set <role> <model> [--agent <id>] [--profile <name>] [--allow-unknown-model] The way to edit roles.json — not by hand. Binds one role to a model for the given harness (--agent, default claude-code; aliases cc/claude, factory/factory-droid/droid, opencode). For claude-code the model must be a tier alias (sonnet|opus|haiku|fable|inherit) — the Task tool's model parameter is a closed enum of exactly those. A foreign-harness id (e.g. a droid custom:* id in a claude-code binding) is refused unless --allow-unknown-model forces it. Records the binding as yours, so the kit never rewrites it when its own defaults change (a hand edit of roles.json gets the same protection, but only from the run after it). Offline. Prints next: petbox-wire apply — it never compiles artifacts itself.
petbox-wire model unset <role> [--agent <id>] [--profile <name>] Clears one role's binding for the given harness. A fair-empty binding is sometimes intentional (e.g. the machine lacks access to the tier a role would otherwise be bound to) — the role then inherits the session model, and apply warns about that honestly. Offline. Prints next: petbox-wire apply.

update, apply, status, doctor, roles, profile and model take no <dir> <projectKey>; they resolve the project themselves (or don't need one).

4. Where a roster comes from

An agent roster is assembled from three independent sources, each with its own owner:

  1. The portable agent definition — file-authoritative. Roles, tiers, required capabilities, spawn/escalation rules. Built by laying ordered layers over each other, lowest first: base (the kit's own default-agents.json, shipped in the package, always present) < user (~/.petbox/agents/) < project (<project root>/.petbox/agents/). Nothing is fetched: the kit does not ask a server what the roles are. It is portable: it carries no model ids — a definition containing role.model is rejected.
  2. The local role→model binding — machine-authoritative. ~/.petbox/roles.json: activeProfile + profiles.<name>.agents.<harness>.roles.<role>.model. Never uploaded, never invented; if a role is unbound, no model: line is emitted (a Factory droid gets model: inherit). Edit it with petbox-wire model set / model unset (see the commands table above) — not by hand; both print next: petbox-wire apply because neither compiles artifacts itself. petbox-wire status (also above) shows exactly where a role's current model came from (roster/seed/none).
  3. The harness capability matrix — kit data. Ships with the npm package and states, per harness, which capabilities exist (mcp_subagent, hooks, spawn_subagents, …). Known harnesses: claude-code, opencode, droid.

The gate between them is truthfulness: a role may only require capabilities the target harness actually declares. A role that fails is skipped and reported — never silently written with the offending line dropped. Clean roles in the same run are still written.

5. apply — compiled agent files

npx petbox-wire apply            # definition from base < user < project, always
npx petbox-wire apply --offline  # also skip the workspace probe behind the skill refresh
npx petbox-wire layers           # which layers exist, and which one gave which field

apply finds the artifact target directory by git rev-parse --show-toplevel from cwd — the git worktree apply is actually running in — falling back to cwd itself only when cwd is not inside a git working tree at all. It deliberately does not consult the registry (~/.petbox/projects.json) for this: the registry answers project identity (which project/key/base-URL), not where artifacts land. Running apply from inside a worktree therefore writes into that worktree, never into the primary tree it was branched from — an earlier version resolved the target the same way it resolved project identity (registry longest-prefix) and could silently rewrite the primary tree's agent files from a worktree checked out on a different branch; that bug is fixed. apply always prints which root it resolved and how (git/cwd).

It then builds the definition from the file cascade base < user < project — no network on that leg, so --offline does not change it — and writes, under that root:

Harness Path
Claude Code .claude/agents/petbox-<role>.md
opencode .opencode/agent/petbox-<role>.md
Factory Droid .factory/droids/petbox-<role>.md

Emitted file (and frontmatter name:) are namespaced petbox-<role> — role.slug and ~/.petbox/roles.json themselves stay unprefixed; only the render is. model: frontmatter is written only when the role is bound (an unbound droid gets model: inherit) — it never invents a concrete model id.

Warning: every generated file carries a petbox: managed origin marker, and apply overwrites files that carry it. It does the opposite for a file that doesn't — a real, non-PetBox file sitting at that exact path — where it refuses (loud, non-zero exit) to touch it at all, rather than clobbering it. Do not hand-edit a petbox: managed file; changes belong in a definition layer (~/.petbox/agents or <root>/.petbox/agents) or in roles.json (models), then re-apply. A pre-namespacing leftover (e.g. .claude/agents/worker.md) that PetBox itself owns is removed once its petbox-<role>.md replacement is written; a same-named file without the marker is left alone either way.

6. Offline, and what a broken layer does

The definition needs no network and has no cache: its layers are local files already. A layer directory that does not exist is a layer with no opinion — the ordinary case on a fresh machine, and never a warning.

A layer that IS there and cannot be read, parsed or validated fails loudly, and the shape of that depends on what the command is doing:

  • apply and doctor BUILD artifacts, so they refuse: exit 1, the absolute path and the parser's own message on stderr, and nothing written or changed.
  • The SessionStart hooks RENDER a banner, so they degrade: the process still exits 0 (a hook that crashes a session is worse than one that degrades), but the banner opens with a marker line naming the broken file, the protocol under it comes from the kit base, and ~/.petbox/wire.log gets a trace.

In neither case is a previously-successful result substituted for the unreadable one. That substitution is exactly what turns a broken source into a silent one — it is why there is no last-known-good copy of a definition anywhere.

--offline therefore has nothing to do with the definition. It skips the network calls the kit still makes: the /api/auth/validate workspace probe behind the skill refresh (apply, doctor, status), the memory-canon fetch, and doctor's banner-budget check. roles, roles export, profile use, model set and model unset are offline by construction — no network path exists for them at all.

The SessionStart memory canon still lives on the server and still has a cache: ~/.petbox/cache/<project>.canon.md.

7. Exit codes

Code Meaning
0 Success — every requested step ran and every known harness wrote every role.
1 Hard failure — invalid definition, unexpected throw, a refused clobbering write, or a rejected/unreachable API key.
2 Usage / bad arguments.
3 Truthfulness policy block — some roles or harnesses were refused. A partial write is possible.
4 INCOMPLETE — a requested step did not run for a reason you did not ask for (e.g. the workspace probe that gates the skills refresh failed), even though nothing was refused and no policy fired. An intentional skip (--offline, an unregistered project directory) stays 0 — this code exists so a script can tell "partial" from "clean" without reading stdout. doctor never reports 4 (it skips no step of its own).

Note: the full-wire path is not limited to 2/1. Its own visible steps (self-smoke, then the apply pass that seeds bindings and compiles artifacts) can each fail without aborting the run, and the reported exit code is the strongest of them by priority 1 > 3 > 4 > 0 — so a full wire whose final apply step hits a truthfulness block or an incomplete skill refresh surfaces 3 or 4, not just 1. Usage errors (2) still end the run immediately during argument parsing, before any step can compete.

Exit 3 is a policy outcome, not a crash: the definition asked for something a harness does not offer. Fix the definition (or accept the skip); don't retry. Exit 4 similarly is not a crash — re-run petbox-wire apply to retry just the step that was skipped.

8. Scopes and endpoints

The CLI does not read definitions from the server at all any more, so no agents:* scope is needed to wire or apply.

Endpoint Used by
GET /api/auth/validate Full wire — key validation before anything is persisted; also reports the workspace the key belongs to. Also the workspace probe apply, doctor and status each run (unless --offline) to gate their skill-file refresh/checks — a failed probe here is what makes apply/full-wire exit 4 (INCOMPLETE).
GET /api/memory/{project}/canon SessionStart hook — the memory canon (cached to ~/.petbox/cache/). This is the only context the wiring injects; there is no per-prompt injection. Also read by doctor's banner-budget check and status's four-pillar summary.
POST /api/logs/{project}/logs Full wire — ensures the telemetry log exists.
POST /api/sessions/{project}/wire-smoke Full wire — the final self-smoke that proves the key round-trips.

9. What lives under ~/.petbox/

Path Contents
wire/ The stable kit copy (hooks and scripts point here, so wiring survives npx cache eviction). Refresh with update.
projects.json Registry: directory prefix → project, env-var name, base URL. Resolved by longest prefix against cwd.
keys.json Flat { "<ENV_VAR>": "<key-or-reference>" } map the kit hooks read directly. Each value is either the API key itself, or a ${VAR} / $VAR reference to another environment variable — the wire writes a reference automatically whenever it obtained the key from a live env var, so the secret itself is never copied into the file. A reference the current process's environment cannot resolve fails loudly at the point of resolution, before any network request, naming the missing variable — never a silent empty key. Tightened to 0600 on POSIX.
env.sh POSIX only — regenerated from the key store, sourced from your login profiles.
roles.json Local role→model bindings + activeProfile. Machine-owned; never uploaded.
agents/ Optional machine-wide definition layer (layer.json + petbox-<slug>.{json,md,append.md}). Absent = no opinion.
cache/<project>.canon.md LKG memory canon. (The definition has no cache — its layers are local files.)
wire.log Trace of silent-failure-shaped events; doctor prints its most recent lines (empty/absent is normal, not a failure).

These are not secrets you should commit anywhere, and nothing here is regenerated by update except the kit itself.