Bundles — the Bundle Contract
A bundle is the modular unit of Crow's extension layer: a directory under bundles/<id>/ described by a manifest.json. A bundle may provide any combination of surfaces — a containerized service (Docker), an MCP server (tools), a dashboard panel, and skills — hence "bundle = service + tools + skills". The contract is surface-based: a bundle is only required to satisfy the rules for the surfaces it actually declares.
Where bundles come from
- Source of truth: each
bundles/<id>/manifest.json. - Install catalog:
registry/add-ons.jsonis generated from the manifests bynpm run build-registry— never hand-edit it. It is committed (lockfile model) and a test fails if it drifts.
Universal required fields
Every manifest must have:
| Field | Rule |
|---|---|
id | must equal the directory name |
name | non-empty |
description | non-empty |
type | bundle | mcp-server | skill (a coarse category tag, not what drives required fields) |
category | non-empty |
version (semver) and author are optional but shape-checked when present (some first-party model/media bundles ship without them).
Surfaces (declare what you provide)
A surface is "declared" by the presence of its key. Each declared surface is validated for shape and that its referenced files exist under the bundle dir:
| Surface | Shape | Integrity |
|---|---|---|
docker | { "composefile": "docker-compose.yml" } | the composefile exists |
server | { "command": "node", "args": ["server/index.js"], "envKeys": [...] }, or null | entry file checked only when command is node and args[0] is a path (external npx/uv servers are exempt) |
panel | "panel/<id>.js" or { "id": "...", "extends": "..." } | string form: the file exists; object form: shape-only (resolved at runtime) |
panelRoutes | "panel/routes.js" | the file exists |
skills | ["skills/<id>.md", ...] | every path exists |
ports / port / webUI.port | integers (1–65535); webUI may also be null | — |
requires.bundles / optional_bundles | ["<bundle-id>", ...] | each id is a bundles/<id> dir with a manifest.json (a real bundle) |
env_vars | [{ "name": "X", "description": "...", "required": false, "secret": false, "default": "" }] | each entry has a name |
Refresh on a version bump. When a bundle's version changes, the gateway re-copies its code into the installed copy (~/.crow/bundles/<id>/). Docker bundles get a narrow set (server/, panel/, skills/, package*.json, declared roots) — never the compose file or anything a container mounts. A docker bundle that needs more opts in with "refreshFiles": ["docker-compose.yml", "<plugin dir>"]: plain bundle-relative paths, never .env*, data, node_modules, .git, manifest.json, or a path a read-write bind mount reaches (those are refused). Running containers keep the old files until the bundle is restarted.
Skills stay current. On every boot (version bump or not) the gateway syncs each installed bundle's skills into ~/.crow/skills/. A copy is replaced only when it is provably unedited: it matches what Crow last wrote (~/.crow/skill-installs.json) or a past shipped version listed in bundles/skill-history.json. An edited copy is kept and the owner is notified once per new version; a copy that matches nothing known is kept without a notification; a symlink is never touched. After editing a bundle skill, run node scripts/build-skill-history.mjs and commit the ledger (CI runs --check, which also requires the ledger to stay append-only against the base commit). CROW_SKILL_SYNC=on|report|off sets the mode per gateway (report logs and writes nothing). node scripts/ops/skill-sync-report.mjs --home <CROW_HOME> is a read-only dry run for one instance.
Opting an installed copy out. An installed copy that you maintain by hand (for example a fork deployed over a first-party bundle id) is skipped when its installed manifest (~/.crow/bundles/<id>/manifest.json) carries "refresh": false. The gateway logs the skip on each boot while the versions differ. Only the literal false opts out, and the missing-file repair (a deleted panel/, settings-section.js or manifest.json) still runs.
Running from the installed copy
A bundle runs from its installed copy, never from the app checkout. The MCP server is spawned from <CROW_HOME>/bundles/<id>/, and the gateway loads a second copy of the panel and panel-routes files from <CROW_HOME>/panels/<id>.js and <CROW_HOME>/panels/<id>-routes.js. A relative path out of the bundle (../../../servers/db.js, or ../server/x.js from a panel file) resolves only in the checkout: the installed server dies at spawn with ERR_MODULE_NOT_FOUND, an installed panel never loads, and an import inside a try/catch quietly switches the feature off.
- App code (
servers/…, another first-party bundle's module): from the MCP server,import { appImport } from "./app-root.js"andawait appImport("servers/db.js")(copyserver/app-root.jsfrom an existing bundle); from code the gateway loads, use the panel handler'sappRootorprocess.env.CROW_APP_ROOT, which the gateway exports for itself and its children. - The bundle's own modules from a panel file: by path from this instance's installed copy,
join(process.env.CROW_HOME || join(homedir(), ".crow"), "bundles", "<id>", "server", …). Never hardcode~/.crow: co-hosted instances each have their ownCROW_HOME.
tests/bundle-installed-copy-imports.test.js lays every bundle out as an install does, in a scratch home outside the repo, and fails on any import that resolves only from the checkout.
Unknown fields are allowed (the schema is lenient) — bundle-specific extras like capabilities, companion, storage, providers, sttProfileSeed pass through untouched. The canonical shape is registry/manifest.schema.json.
Voice display phrase lists (capabilities.voice_intent)
An add-on whose tools an assistant may use by voice can say which words mean "this request is for me":
"capabilities": {
"tools": [{ "name": "mb_play" }],
"voice_intent": { "en": ["music", "song", "songs", "next track"], "es": ["musica", "cancion", "canciones"] }
}Each entry is a plain phrase of one to four words (letters and digits; accents and case are ignored), at most 40 per language, in en and es. Matching is by whole words, so list the singular and the plural. A voice display offers the add-on's tools to its quick model only on a turn that contains one of these phrases, or on the turn right after one that used them. An add-on with no list is used only on turns sent to the larger model, and the display's Diagnostics list shows it as not offered. Patterns are not accepted: anything that is not a short plain phrase is ignored.
Draft / unpublished
"draft": trueexcludes a bundle from the generated registry.- An untracked bundle dir (not committed to git) is treated as an implicit draft — excluded and reported, never auto-published. This keeps work-in-progress out of the registry.
Contributing a third-party bundle (provenance)
Third-party contributions are welcome, and they are listed honestly — never presented as first-party. Declare provenance in the manifest:
{ "origin": "community" }origin: "community"→ the generated registry entry carriesofficial: false(plus theoriginfield), and the store renders a Community badge on the card and a "not verified by Crow" caution in the install modal.- Omitting
origin(ororigin: "official") keepsofficial: true. A manifestofficialfield is always ignored — the registry derives it; you cannot opt into the Official badge by declaring it. - Community bundles are not eligible for
featuredplacement or curated collections.
The bar for a third-party listing (reviewers will check all of these):
- A real, reachable upstream with a disclosed operator (who runs the service, and under what terms — a public terms/privacy page counts; anonymity does not).
- An accurate
author— the contributor or vendor, never "Crow". - Functional out of the box: the bundle's declared surfaces (skills, server, docker) must actually work with what the bundle installs. A skill describing API workflows nothing in the bundle can execute is not functional.
- Scope honesty: the skill/manifest must not claim less access than the upstream API grants without saying so (e.g. "read-only" atop a write-capable key).
Validate + generate
npm run build-registry -- --check # validate all manifests + drift-check (CI)
npm run build-registry # regenerate registry/add-ons.json
npm run test:bundle-contract # the node:test gate--check prints a per-bundle audit (id, type, surfaces, status) and exits nonzero on any invalid manifest or if the committed registry is out of date.
Minimal example
bundles/your-bundle/
├── manifest.json
├── docker-compose.yml (if it ships a service)
├── server/index.js (if it provides MCP tools)
├── panel/your-bundle.js (if it adds a dashboard panel)
└── skills/your-bundle.md (if it adds skills){
"id": "your-bundle",
"name": "Your Bundle",
"version": "1.0.0",
"description": "What it does",
"type": "bundle",
"author": "You",
"category": "utilities",
"docker": { "composefile": "docker-compose.yml" },
"server": { "command": "node", "args": ["server/index.js"], "envKeys": ["YOUR_API_KEY"] },
"panel": "panel/your-bundle.js",
"skills": ["skills/your-bundle.md"],
"requires": { "env": ["YOUR_API_KEY"] },
"env_vars": [
{ "name": "YOUR_API_KEY", "description": "API key", "required": true, "secret": true }
]
}After adding or editing a bundle, run npm run build-registry and commit both the manifest and the regenerated registry/add-ons.json.
Collections
registry/collections.json groups official bundles into curated, one-click "starter collections" (Home Server, Education, Research, Development) surfaced on the Extensions page's Browse view. Each collection is { id, name, description, icon, members }, where every member is { id, kind, you_need? }.
Membership is constrained by hard rules, enforced by tests/extensions-collections.test.js:
- Official: every member
idmust exist inregistry/add-ons.jsonand have a manifest underbundles/<id>/. - Non-privileged, non-consent: no member may set
privileged: trueorconsent_required: true— a one-click install must never bypass the consent gate. - Non-GPU: no member may declare
requires.gpuorrequires.min_vram_gb— collections are host-independent, not tuned to any one machine's hardware. - No host networking, no Docker socket: no member's
docker-compose.ymlmay usenetwork_mode: hostor mount/var/run/docker.sock—validateComposeFilerefuses both without the privileged/consent gate, so such a member would fail the one-click install. - Dependency-closed and topologically ordered: every
requires.bundlesdependency of a member must itself be a member of the same collection, and must appear earlier in themembersarray than its dependent. kindmatches compose presence: a member with adocker-compose.ymlmust bekind: "deploys"; a member without one is"builtin"or"connects".connectsmembers declareyou_need: a member that connects to something the user already runs (e.g. an existing Home Assistant instance) must setkind: "connects"and a non-emptyyou_needstring describing what the user needs to bring.
At install time the gateway does not trust the loaded JSON blindly — it re-validates each member's manifest against these same rules from the on-disk bundles/<id>/manifest.json files before running the install job, so a collection can't be used to smuggle in a bundle whose manifest changed (or was removed) since collections.json was written.
Deployment invariant: co-hosted gateways need distinct CROW_HOME
The one-click install path guards against concurrent installs with an in-process busy flag plus an installed.json file under CROW_HOME. Both are per-process, not cross-process: if two gateway processes are co-hosted and share the same ~/.crow (the same CROW_HOME), they can race on installed.json and on the install-set busy gate, corrupting installed-bundle bookkeeping. Every gateway instance — including scratch/throwaway ones spun up for testing — must run with its own distinct CROW_HOME.