Build a NEW optional vertical (an "add-on") in the Cos repo the framework-native way — one four-layer slice (nav + API + data + MCP) gated by ONE Settings.addons.[id] flag, with all data folded onto cases.json. Use when adding a whole new feature area (a "health" dashboard, a "finance" tracker, a "reading list", etc.), when reviewing/refactoring a feature that was built OUTSIDE the framework (its own *-store.ts, its own data/*.json, hardcoded nav, an unregistered route prefix), or whenever yo...
4 stars
0 votes
0 copies
2 views
Added September 2, 2026
ai-agentsrustgonoderefactoringapidocumentation
Works with
cli
api
mcp
Security analysis
A92/100
mediumInstalls packages at runtime which could introduce malicious dependencies
Installs into .claude/skills of the current project.
Are you the author of Add An Addon?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/philipyaz-add-an-addon)
---
name: add-an-addon
description: Build a NEW optional vertical (an "add-on") in the Cos repo the framework-native way — one four-layer slice (nav + API + data + MCP) gated by ONE Settings.addons.[id] flag, with all data folded onto cases.json. Use when adding a whole new feature area (a "health" dashboard, a "finance" tracker, a "reading list", etc.), when reviewing/refactoring a feature that was built OUTSIDE the framework (its own *-store.ts, its own data/*.json, hardcoded nav, an unregistered route prefix), or whenever you're about to mint a new persistence store / new top-level nav and need to know whether it should instead be an add-on. The worked example is Nutrition & Chef (board/lib/addons.ts, mcp/nutrition-server, board/app/api/nutrition, tests/api-nutrition-gate.mjs); the cautionary example is the Health & Athlete PR this skill was written from.
---
# Add an add-on (the framework-native way)
An **add-on** is an optional, self-contained vertical layered over the core board. The
framework already exists — your job is to **parametrise it with one manifest**, not to invent
a parallel one. Copy **Nutrition & Chef** (`board/lib/addons.ts`, `mcp/nutrition-server/`,
`board/app/api/nutrition/`, `tests/api-nutrition-gate.mjs`) beat for beat. Read
`docs/architecture/addons.md` first; it is the spec, this is the recipe.
## WHEN this applies — and the tell that you're doing it wrong
Use this skill when you are adding a **whole feature area** (its own pages + routes + agent
tools) that should ship **disabled by default** and not bloat the core.
**The tell — STOP and make it an add-on if you catch yourself about to:**
- create a **new `board/lib/<x>-store.ts`** or a **new `board/data/<x>.json`** → the data
belongs on `cases.json` via `DBShape`, written through the existing `mutate()`.
- **hardcode a nav entry** into the sidebar's core list → nav comes from the manifest's
`navItems`, revealed only when the flag is on.
- add an `/api/<x>/*` route prefix that **nothing in `ADDON_REGISTRY` claims** → register it.
- mint a **second persistence chokepoint**, a second SSE channel, a second backup path → there
is exactly one of each, and riding `cases.json` gives you all three for free.
Does **NOT** apply to: a tweak to a **core** surface (cases/events/reminders/priorities — those
are always-on, never gated), or a pure agent-skill with no new data/nav/routes.
## The mental model (memorise this)
> **One four-layer slice, gated by ONE flag. Writes close; reads stay open.**
> **A component is a STATE MACHINE — it never calls an LLM; the agent is the intelligence.**
An add-on is a **deterministic state machine**: it persists state and exposes it via an **API + an
MCP server**, and that is the whole contract. It **does NOT call an LLM / the Anthropic API.** Any
generative step (a training plan, a summary, a drafted reply) is produced by the **external agent**
(your operator skill, Claude Cowork) and written back through an **MCP write tool** / a `POST`; the
add-on only validates, versions, attributes, stores, and serves it. Deterministic **server-side
compute** (stats, projections, aggregations) is fine — only **generative LLM inference** is
delegated. **The sole exception in the whole repo is the vault MCP (`mcp/vault-server`)**, which
embeds the Claude Agent SDK and is itself an agent. If you catch yourself reaching for
`@anthropic-ai/sdk` (or any LLM client) inside an add-on route or MCP server, **stop** — that
generative step belongs in the agent's context, not the component.
- **Four layers:** **nav** + **API** + **data** + **MCP server** — the same machinery the core
uses, parametrised by an `AddonManifest`. Nothing about an add-on is special-cased.
- **ONE flag:** `Settings.addons.<id>.enabled`, persisted **in `cases.json`** (board state, not
config). `isAddonEnabled(db, id)` is `true` only when it is exactly `true`; absent === off.
- **Writes close:** every mutation calls `assertAddonEnabled(db, id)` **inside `mutate()`** →
`NotFoundError` → **404** when disabled (atomic with the write, closes the TOCTOU).
- **Reads stay open:** every `GET` is **ungated**. Disabling **freezes** an add-on; it never
hides or deletes a byte. Re-enabling resumes exactly where it left off.
- **WHY data rides `cases.json`:** you inherit **free SSE live-update** (one `version` bump
reaches every tab), **free encrypted backup** (the whole file is snapshotted), and **free
attribution** (`human` from the UI, `agent` from the MCP). A standalone store throws all
three away and you'd have to rebuild them badly.
Two clocks, on purpose: the **in-board** surface (nav + API gate) activates the instant you flip
the flag via SSE; the **agent** surface (the MCP bridge) activates only when someone runs the
setup skill on that machine. Flipping the DB flag does **not** start a daemon — nothing hot-loads.
## The 9-step walkthrough (copy nutrition at each step)
### 1. Data — on `DBShape` in `board/lib/types.ts`, plus a singleton if you need one
- Add your owned **array(s)** as optional fields on `DBShape` (e.g. `healthEntries?: HealthEntry[]`,
mirroring `foodLogs?`). Bump `SCHEMA_VERSION`; the change is **purely additive** so old boards
read unchanged.
- A non-array **singleton** (a profile/goal object) is a bare field — `athleteProfile?` mirrors
`nutritionGoal?` — and is **deliberately NOT in `dataArrays`** (that list is arrays only).
- Carry the new fields through `migration` + `validate` + `ensureFile` and add typed
`getX`/`setX` store helpers with **sticky `createdAt`** (mirror `getNutritionGoal`/`setNutritionGoal`).
- Define all **vocabulary as `VALID_*` arrays in `types.ts`** and import them everywhere — the
route validator AND the UI bind to the **same** arrays. Never redefine an enum inline; never
store a label in a non-English language.
### 2. Manifest — ONE `AddonManifest` literal in `ADDON_REGISTRY`
Add one entry to `board/lib/addons.ts` (copy `NUTRITION_ADDON`). This **single source of truth**
gives you the `/addons` catalog row, the toggle, the nav, the gate, and the bridge probe — for
free. Fill: `id`, `title`, `description`, `icon`, `navItems[]`, `apiPrefixes[]`,
`dataArrays[]` (**arrays only** — omit singletons), the `mcp` block, and any `dependsOn[]`.
**Do not** add bespoke helpers; the four existing ones (`listAddons`, `getAddon`,
`isAddonEnabled`, `assertAddonEnabled`) are the entire gate.
### 3. API — routes that gate writes inside `mutate()`, leave GETs open, reuse helpers
For each resource under `board/app/api/<prefix>/`:
- **GET = ungated.** `readDB()`, filter, return `{ ..., version: db.version }`. A disabled
add-on's data must still read.
- **POST / PATCH / DELETE = gated.** Do the read-modify-write **inside `mutate()`** and call
`assertAddonEnabled(db, "<id>")` as the **first line inside the lock** (gate + id-mint + write
are one critical section). Map thrown store errors with `storeErrorToResponse`.
- **Reuse `@/lib/route-helpers`** — `isISODate` (validate every date), `resolveActor` (write
attribution), `storeErrorToResponse`. **Never re-inline** them.
- **NEVER call a sibling route over loopback HTTP.** No `fetch` to your own `/api/*`. If two
routes share logic, extract a function into `board/lib/<x>.ts` and call it **in-process**.
- **Zero `console.log`** in `board/app/api` — and absolutely no logging of user/biometric data.
- **A route NEVER calls an LLM.** The component is a state machine; the **agent** generates. A
"coach/summary" endpoint is a **CRUD seam** that **accepts** an already-generated artifact (the
agent built it in its own context and `POST`s it) — validate the artifact's JSON **before** the
store write (never persist an unvalidated body), version it, attribute it, store it. Deterministic
**compute** (stats, a projection, an aggregator in `board/lib/<x>.ts`) is fine — only *generative
inference* is off-limits. The **vault MCP is the one exception** (it embeds the Agent SDK).
### 4. Nav — from the manifest only
The sidebar reads `navItems` off the manifest and shows the group **only when the add-on is
enabled**. **Do NOT touch the sidebar's core/daily nav array** — adding your links there
hardcodes them on for everyone and defeats the gate.
### 5. MCP server — a THIN fetch wrapper, no business logic
Create `mcp/<name>-server/server.mjs` using **`packages/mcp-kit`** (`err`, `text`, `str`,
`start`, `baseUrl`, `makeBoardApi`) — copy `mcp/nutrition-server/server.mjs`. Every tool is a
thin `fetch` over your `/api/*` routes on `CRM_BASE_URL`; **no business logic and NO LLM calls in
the MCP** (the intelligence lives in the operator skill / the agent; the vault MCP is the one server
that embeds an LLM, and it is the exception, not the template). Every WRITE tool sends
`{ actor: "agent" }` + an `x-actor: agent` header. A disabled add-on 404s writes → surface as a
`Not found.` tool error. Adding a new `mcp/<x>-server` makes it a **root npm workspace member**:
run `npm install` at the repo root and **commit `package-lock.json`**, or CI's `npm ci` fails.
### 6. Install path — ONE descriptor + a port in the loader (manifest-driven, both OSes)
Supervision is **manifest-driven** (see [`mcp/CLAUDE.md`](../../../mcp/CLAUDE.md)): you define the
service **once** as a co-located descriptor and the generators render every platform from it. **No**
committed plist template, **no** `ensure-bridges.sh` edit, **no** second port map.
- Add the port to **`config/load-config.sh`** ONLY: a default `: "${<NAME>_BRIDGE_PORT:=80NN}"`, the
derived `: "${<NAME>_BRIDGE_URL:=http://localhost:$<NAME>_BRIDGE_PORT}"`, and **both** names on the
`export` line. Then mirror it into **`config/cos.env.example`** for documentation. This is the
canonical port on every OS.
- Drop **ONE** descriptor `mcp/<name>-server/<name>.service.json` (`schemaVersion:1`,
`kind/runtime:"bridge"`, `addon:"<id>"`, `portVar:"<NAME>_BRIDGE_PORT"`, `stdio:["${NODE_BIN}",
"${REPO_ROOT}/mcp/<name>-server/server.mjs"]`, `env:{CRM_BASE_URL:"${BOARD_URL}",
COS_DEVICE_ID:"${COS_DEVICE_ID}", COS_DEVICE_ROLE:"${COS_DEVICE_ROLE}"}`,
`clients:["claude-code","cowork"]`, `probe:{type:"httpListen"}`, `idleExit:true`). Copy
`mcp/nutrition-server/nutrition.service.json` beat for beat. The `addon:"<id>"` field is what makes
the probe treat the bridge as **OPTIONAL** and skip it silently on a board that never installed it —
there is no hand-maintained registry line or skip-`case` anymore.
- **If your write tools need a machine-local secret** (e.g. the vault's `ANTHROPIC_API_KEY`; no add-on
ships one today), add `secrets:["<TOKEN>"]` + `secretWrapper:"${REPO_ROOT}/mcp/<name>-server/launch.sh"`
to the descriptor and ship a `launch.sh` (copy `mcp/vault-server/launch.sh`) that sources
`config/secrets.env` then `exec "$@"` — gen-launchd renders the shared supergateway argv
(preload included) after the wrapper, so the secret never lands in the rendered plist or
`.mcp.json`. Most add-ons need no secret (copy nutrition, which has none).
- **Do NOT smuggle in pm2** or any new process manager, and **never hand-edit `.mcp.json`** (it is a
generated, CI-checked artifact). The bridge is a launchd LaunchAgent rendered by `gen-launchd.mjs`;
that is the only mechanism.
### 7. Skills — one setup skill + one operator skill
- **`/<x>-mcp-setup`** (copy `nutrition-mcp-setup`): renders + loads the launchd bridge plist,
wires the `.mcp.json` + Cowork stdio entry, points `<X>_BRIDGE_PORT`, verifies the bridge.
- An **operator skill** (copy `/nutrition-chef`) that turns plain language into structured writes
via your MCP — this is where any estimation/judgement lives, not in the MCP.
### 8. Docs — `docs/features/<x>.md` wired into `mkdocs.yml`
Write the feature page under `docs/features/<x>.md` and add it to the `nav:` in `mkdocs.yml`
(under `features:`, next to `Nutrition & Chef`). **No loose root `*.md`.** Cross-link with
relative `.md` paths; validate with `uvx --with mkdocs-material mkdocs build --strict`.
### 9. Tests — gate + CRUD + unit, wired into `tests/run.sh`
- **`tests/api-<x>-gate.mjs`** — copy `tests/api-nutrition-gate.mjs` exactly: snapshot
`cases.json`, prove **disabled → GET 200 but POST/PATCH/DELETE 404**, **enable bumps
`db.version`**, **enabled → POST 201**, unknown id → 404, non-boolean `enabled` → 400; restore
in `finally`.
- **CRUD** coverage per resource and **unit tests** for any pure logic in `board/lib/<x>.ts`
(summarisers, target engines) — assert the **canonical field names**, not the producer's
incidental shape.
- Add all of them to `tests/run.sh`.
## Cross-add-on dependencies (soft by default)
When your add-on **reads** another add-on's data (e.g. a coach folding in the food log):
- Declare a **soft edge**: `dependsOn: [{ id: "<other>", required: false }]`. The catalog
surfaces "works better with `<other>`"; runtime posture is **unchanged**.
- **Reads stay open.** Read the other add-on's array directly (`db.foodLogs`) and default with
`?? []`. That `?? []` only ever fires when the other add-on was **never installed** (the field
is absent) — a **disabled** add-on still has readable data.
- **NEVER gate a cross-read on the other add-on's `isAddonEnabled`.** That would hide
frozen-but-readable data and violate "reads stay open". Soft deps never auto-enable and never
hard-gate. Reserve `required: true` for a future dependent that is genuinely useless alone.
## Copy CHECKLIST — tick every box before the PR
- [ ] Owned **array(s)** on `DBShape` (`types.ts`); `SCHEMA_VERSION` bumped; migration + validate
+ ensureFile carry them.
- [ ] Any **singleton** is a bare field with sticky-`createdAt` `getX`/`setX` helpers; **not** in
`dataArrays`.
- [ ] All vocabulary as `VALID_*` arrays in `types.ts`, imported by both route + UI; **English only**.
- [ ] **ONE** `AddonManifest` in `ADDON_REGISTRY`; no new gate helpers.
- [ ] GETs **ungated**; every write calls `assertAddonEnabled` **inside `mutate()`**.
- [ ] Routes reuse `isISODate` / `resolveActor` / `storeErrorToResponse`; **no re-inline**.
- [ ] **No loopback `fetch`** to own `/api/*`; shared logic in `board/lib/<x>.ts`, in-process.
- [ ] **Zero `console.log`** in `board/app/api`; no PII/biometric logging.
- [ ] **No LLM client** imported anywhere in the add-on (route OR MCP) — the component is a state
machine; the agent generates and `POST`s. Any "coach" route is a CRUD seam that **validates
the artifact JSON before the store write**. (Vault is the sole LLM-bearing server.)
- [ ] Nav from manifest only; **core sidebar array untouched**.
- [ ] MCP = thin `mcp-kit` fetch wrapper; `{ actor: "agent" }` on writes; `npm install` at root +
`package-lock.json` committed.
- [ ] **ONE** co-located descriptor `mcp/<name>-server/<name>.service.json` (`schemaVersion:1`,
`addon:"<id>"`, `portVar`); `<X>_BRIDGE_PORT` (+ derived `_URL`) added to `config/load-config.sh`
and documented in `config/cos.env.example`; **no committed plist template, no `ensure-bridges.sh`
edit, no pm2**; `.mcp.json` regenerated via `gen-mcp-json.mjs` (never hand-edited).
- [ ] `/<x>-mcp-setup` + operator skill.
- [ ] `docs/features/<x>.md` in `mkdocs.yml`; `mkdocs build --strict` clean.
- [ ] `api-<x>-gate.mjs` + CRUD + unit tests, all in `run.sh`.
## Lessons from the Health & Athlete PR (anti-pattern catalog)
Each of these was a real mistake in the feature this skill reviews. Don't repeat them.
- **Standalone store.** `board/lib/health-store.ts` + a separate data file — re-implementing what
`cases.json` gives free and **losing SSE + backup + attribution**. → Fold onto `DBShape`;
retire the standalone store. (Repoint every `@/lib/health-store` import to `@/lib/fitness`.)
- **Unregistered feature.** Pages + routes shipped with **no `ADDON_REGISTRY` entry**, so no
catalog row, no toggle, no gate. → One manifest gives all of it.
- **Ungated writes.** Mutations with no `assertAddonEnabled` (or the check only at the route edge,
not inside `mutate()`). → Gate **inside the lock**.
- **Hardcoded nav.** Links jammed into the core sidebar list, on for everyone. → `navItems` only.
- **Split-brain taxonomy.** The producer stored type `"hrv"` but consumers queried
`"heart_rate_variability"`; summarise/trends read `data.duration_min` / `data.avg_ms` /
`data.bpm` / `data.count` while the producer wrote `data.value`. **Producer and consumer
disagreed on the type string and the field**, so reports silently came back empty. → **Canonicalise
one taxonomy** (the HAE ingest shape; metric entries use `data.value`, day `ts`) and make every
consumer read it. And: the test that **masked it** asserted the producer's incidental shape
instead of the canonical contract — **tests must assert the contract**, or they certify the bug.
- **Loopback self-calls.** A route `fetch`-ing its sibling `/api/*` over HTTP. → Extract to
`board/lib/<x>.ts`, call in-process.
- **Re-inlined helpers + unvalidated artifact JSON.** Date checks re-written instead of `isISODate`;
an agent-supplied artifact body written **straight to the store** without validation. → Reuse
route-helpers; validate the artifact shape before any write (raw bodies are never trusted).
- **An LLM call inside a component.** A route or MCP server that imports `@anthropic-ai/sdk` and
generates a plan/summary/reply server-side — turning a state machine into a key-bearing,
offline-broken, non-deterministically-testable thing. (This is exactly what the fitness add-on's
removed board-side `GET /api/fitness/training-plan|weekly-review|pre-workout-brief` generate
routes did.) → **Delegate generation to the agent**; expose a **CRUD seam** (`POST
/api/fitness/coaching` + the `save_*` MCP tools) that accepts the agent's already-generated,
validated artifact and persists it. The **vault MCP** is the only server that may embed an LLM.
- **Debug PII logging.** `console.log` of biometric data in API routes. → Zero `console.log`.
- **Silent missed-day data drop.** The HAE ingest converter kept only *today's* points and
discarded the rest of the re-sent history, so a single missed sync lost those days **permanently**
(and the UTC `today` misclassified late-evening points). → Don't filter the caller's data to one
day; rely on dedup-by-id and compute day boundaries in the user's timezone.
- **Missing install path + smuggled pm2.** No service descriptor / no `<X>_BRIDGE_PORT`, and a new
**pm2** dependency sneaked in. → ONE co-located descriptor (`<name>.service.json`) + the
`<X>_BRIDGE_PORT` in `load-config.sh`; install via the generators (the probe picks it up
automatically from `addon:`); **pm2 is dropped from this PR.**
- **Missing docs / tests.** No `docs/features/<x>.md`, no gate test. → Both are required, wired
into `mkdocs.yml` / `run.sh`.
- **French in an English codebase.** Stored vocabulary, prompts, enums, and UI labels in French.
→ **ALL** stored vocabulary, prompts, enums, and labels in **English**; bind to the `VALID_*`
arrays in `types.ts`.