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