# Extracting a feature into a plugin > **Handing this to a fresh agent?** `plugins/EXTRACT-A-PLUGIN-PROMPT.md` is the kickoff prompt — paste it, > replace the feature name, and it points back here. This file is the authority; that one is the way in. The runbook, written the day offscale became the first one. Follow it for music, then for the rest. **This directory holds documentation, not plugins.** Every plugin is its own repository as of 2026-08-15, and arrives in `plugins//` by `git clone` when somebody installs it — so on a fresh checkout the four references below are URLs, and on a machine where they are installed they are also directories. Both are given. **Read first, in this order:** 1. [`plugins/offscale` → `PLUGIN.md`](https://gitea.officer.dev/plugins/offscale/src/branch/main/PLUGIN.md) — every decision and why, including the three that reversed 2. [`plugins/example`](https://gitea.officer.dev/plugins/example) — the reference implementation, deliberately the smallest thing that is still a real plugin 3. [`plugins/offscale`](https://gitea.officer.dev/plugins/offscale) — the worked example, all four parts 4. [`plugins/music` → `PLUGIN.md`](https://gitea.officer.dev/plugins/music/src/branch/main/PLUGIN.md) — the MESSY worked example: three pieces that stayed behind, and why each is a seam rather than a loose end. Read it if your feature has anything the platform also uses. 5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`. This one IS in this repository: the platform owns the plugin system, and only the plugins left. --- ## The rules. These are not preferences **Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts` (`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders `WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden. **Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a resource is shared or per-user — is the plugin's own job, in its own queries. **A plugin never hardcodes a colour.** Every colour comes from a semantic token — `bg-destructive`, `text-muted-foreground`, `bg-busy/15`, `border-border` — and never from Tailwind's palette (`text-emerald-400`, `bg-zinc-800`, `border-red-500/30`). Palette classes do not follow the theme, so a plugin using them looks wrong under every theme but the one its author happened to have. The contract is `src/workspaces/theme/README.md`; the closed set of state tones is `components/Data/tone.ts`. Pay this down **as part of the extraction**, not afterwards. You are already rewriting the imports, and the counts are not small: Soulseek carries 313 palette classes, Invoices 95, Wallet 68, Jellyfin 48, Photos 47, Transmission 41. Left alone, an extraction exports its colour debt into a repository nobody will revisit — and the platform's own ratchet (`bun run check:theme`) stops covering it the moment the directory leaves. Two shortcuts that are not shortcuts. `text-white` and `bg-black/60` are fine ONLY over media — a video, a photograph — where they sit on someone's content rather than on a themed surface; everywhere else they are palette classes like any other. And a colour that carries no state (a brand accent, a chart series) belongs in the plugin's own variables, declared once, not sprinkled as literals. **Say `permissions`, never the other word.** It already means three things in this codebase. **The manifest holds only what a directory listing cannot say.** Identity facts and human choices: `publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything structural is convention — presence is the declaration: ``` manifest.ts required api/router.ts a backend router, mounted at mountPrefix() db/schema.ts tables, prefixed _ sidecar/index.ts a process (.mjs instead means node) web/panels.ts panels — REQUIRED with web/ web/layout.ts layout — REQUIRED with web/ ``` **A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare `osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed, and a package name per package manager. Absent means self-sufficient, which offscale and example are. Music added the field; see its PLUGIN.md for what it is guarding against. `appName` is the **directory name**. The sidecar runtime is the **file extension**. **Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing anywhere else means two systems, and only one gets tested. **Only an explicit tick destroys data.** The generated schema barrel follows plugin **directories**, not the install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables the moment it was stopped. Deleting a plugin removes its directory, and the confirmation's "also delete all its data" checkbox is the one thing that drops its tables. Unticked, the rows outlive the plugin and show up in the orphan list on the same screen. --- ## The order that worked 1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it. 2. **Move the backend**: `sidecar/` → `plugins//sidecar/`, `api//router.ts` → `plugins//api/router.ts` (export `router`, not `Router`), `officer_db/src//*` → `plugins//db/`. 3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take `officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one reference a plugin may make. 4. **Write `manifest.ts`.** 5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become `officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient` and `helpers/clipboard` stay as they are. 6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and import, the `permissions/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`, `AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and **both** database barrels (`index.ts` and `schema.ts`). 7. **`bunx tsgo`** until clean. It finds the wiring you missed. 8. **Verify on the live server** — see below. 9. **Commit and push.** Message says what moved, what it found, and what is still open. --- ## Verification — run all of it ``` bun test # record the baseline BEFORE you start. Any new failure is yours pm2 restart officer ``` Then through `/plugins`, watching PM2 and the browser at each step: | Step | Expect | | ------------------------------- | ----------------------------------------------------------- | | install | streamed log; schema applied; sidecar online; route mounted | | the plugin's API | answers | | the plugin's screen | renders as a Workspace | | dock | tile appears | | permissions page | its permission is listed, read/write/none | | disable | route 404s, sidecar stops, **tables and rows survive** | | enable | comes back | | delete, checkbox unticked | route gone, `pm2 list` loses it, directory gone, **data still there** | | install again | fresh clone, and it finds those rows | | delete, checkbox ticked | tables dropped too — a machine that never had it | A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists. --- ## Traps, all of which cost real time once - **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot `:server` event and subscribes when the router is first imported — at mount. Start first and the announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in `install.ts`; do not reorder it. - **`src/servers/sidecar/protocol.ts` still declares `:server` per sidecar.** Music will need its line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is pending for the whole protocol. - **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`. - **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it. - **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a contract change. Regenerate rather than hand-edit. - **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `permissionAvailability` derives from `sidecar_installs`, and a plugin never gets a row there — its install state is `plugin_installs`. A leftover catalogue entry therefore makes the permission permanently `unavailable`, which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in `catalogue.ts` is the one to read. - **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src` until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it. - **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the module cache holds it for the lifetime of the process — so editing a manifest while developing changes nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either. - **A router that proxies BY HAND must call `registerProxiedPrefix`.** `bodyParser` runs on the whole protected router and reads JSON and form bodies, which DISTURBS the request stream — so a catch-all that then forwards `ctx.req.raw.body` forwards an already-consumed stream, Bun's fetch throws, and the proxy reports the sidecar as unreachable. Every bodyless GET keeps working, so it presents as "the sidecar is down" for POSTs only, which is a long way from the cause. `createSidecarProxy` registers the prefix itself, so core sidecars and any plugin using it never meet this; vault proxies by hand and its extraction dropped the registration on 2026-08-18, costing an afternoon. If your plugin hand-rolls a proxy: `registerProxiedPrefix(PREFIX)` beside the mount, and check no handler under it reads `ctx.get('body')`. - **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around it. Find those before you plan the split; they decide it for you. --- ## Music is done. What it changed about this runbook Extracted 2026-08-15 and verified live through the whole table above. [`plugins/music` → `PLUGIN.md`](https://gitea.officer.dev/plugins/music/src/branch/main/PLUGIN.md) is the record; the parts worth carrying forward are already folded into the rules and traps above. The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and both were found by reading the import graph rather than by reasoning about what music "is". Offscale had none, so it came out whole and made the job look cleaner than it is. The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts` and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do not tidy them into a plugin as a warm-up. ### The global-overlay question is answered, and the answer is no Music was the first feature wanting to render on every route. It does not get to, and neither will the next one: a shell slot for a plugin-provided component reopens "there is no way to export a component", which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`, gated on its plugin's permission so it switches itself off with the plugin. Reopen this only for a feature where the overlay is the whole product, and expect to argue for it. --- ## What changed on 2026-08-16. Read this before following the steps above A day of installing plugins onto a machine that had never had one found four things. The runbook above is still right about the shape of an extraction; these change how you verify it. **The install no longer runs `bun db:push`, and you should not either.** A plugin's tables are created by `pushPluginSchema`: `drizzle-kit generate` against the plugin's schema alone, into an empty directory, then the statements applied one at a time with "already exists" skipped. It cannot emit a DROP of anything. The global push is a trap on any machine holding tables the current schema does not declare — a plugin that is not on disk, anything left from a rename. drizzle-kit sees a new table appearing while others are due to be dropped and asks, interactively, whether it is a rename. **That prompt needs a TTY.** `--force` does not answer it, `< /dev/null` does not answer it, piping newlines does not either — all measured. With no terminal it applies NOTHING and exits 0, so an install reports success and the plugin fails its first query with 42P01. `--force` would be worse if it worked: the destructive statements are `drop table` for every plugin whose directory is absent. So: the row in the verification table that said to run `bun db:push` while stopped is gone. The property it was checking — stopping a plugin does not destroy data — is now checked by deleting it with the checkbox unticked, installing again, and finding your rows still there. **Delete WITH the checkbox to test the install twice.** Anything less is not a fresh install: the marketplace hides a plugin whose directory exists, and tables that are already there make `db:push` a no-op, so a broken schema stays broken and passes. Ticked, it drops the tables, deletes the directory and regenerates the barrel — and afterwards the plugin installs from the catalogue exactly as a stranger's machine would. **That is the only honest test of an install.** **Uninstall was removed on 2026-08-21.** It sat between disable and delete, keeping the directory and the data, and the screen could not show the difference: an uninstalled plugin stayed in the list under a heading that said "Installed" while its directory silently decided what the next install would run. That is not hypothetical — it is how a jellyfin install on alpha re-applied a checkout from two days earlier, reporting success at every step. The verbs are now enable, disable and delete. **The install now verifies its own tables.** After the push it asks Postgres whether each declared table exists and fails the install naming what is missing. If your extraction reports "schema push reported success but did not create …", the push is the problem, not your schema. **Tables with no owner are now reported.** The Marketplace screen lists any table no code on the machine claims, with a row estimate. During an extraction this is how you notice you renamed a table and left the old one behind — it is exactly the state that produced the trap above. --- ## The marketplace is part of the job now An extracted plugin is not finished when it installs from disk. It has to be installable from `marketplace.officer.dev`, which is three things: 1. **A public repository that clones anonymously over https.** Check it with `GIT_TERMINAL_PROMPT=0 git clone --depth 1 /tmp/probe` — an SSH remote only works on the machine that published it, and the catalogue rewrites SSH to HTTPS precisely so this cannot be got wrong. 2. **A README.** It renders on the plugin's page in the store AND in the platform's install pane, so it is the shop window as much as the documentation. `plugins/example/README.md` is the model, and the house voice applies: state what is missing as plainly as what works. 3. **A catalogue row.** `bun --env-file=.env scripts/marketplace-sync.ts` reads every plugin directory's `manifest.ts` and README and upserts them. Add a matching entry to `BUNDLED` in `src/servers/plugins/marketplace.ts` as well — that is the offline fallback, and a machine with no internet shows it. `docs/marketplace.md` is the design; `docs/marketplace-todo.md` is what it does not do yet. **The screen is `/marketplace` now, not `/plugins`.** The API kept its name. --- ## Two more rules, learned the same day **The platform must not know what your plugin is about.** Not just its code — its VOCABULARY. Ten comments in `src/` still explained what Headscale was, months after the code left, and each one was correct and in the wrong repository. When you finish, grep the platform for your feature's domain words, not only its identifiers, and keep only dated history: "X was here until " is a record; explaining what X does is a leak. **Do not add a spawn that trusts `$PATH`.** An install died on `Executable not found in $PATH: "bunx"`, because PM2 starts the platform with a PATH that has `bun` and not `bunx`. Resolve a path (`node_modules/.bin/…`, `process.execPath`) as `pushPluginSchema` now does. --- ## Which one next No decision has been made. What the tree says, for whoever picks it: - **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin, invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which is exactly what its extraction needs. - **`hono.ts` still has fifteen commented mounts.** Same list, roughly. - **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am. - **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a way neither of the first two did. Read `docs/secret-store.md` first. ## Still open, platform-wide. Do not rediscover these - **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet; music would have been the first and cliamp being out of scope is what let it pass. - **`assertPermissionTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`, now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes. The live example is the two cliamp sockets: served in the route table, claimed by no permission, and invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact. - **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this - **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the owner's. Its own job, not the platform's. - **`protocol.ts` declares `:server` per sidecar.** `music:server` and `headscale:server` are both still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix. - **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through `readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire. --- ## Where the plugins went | Plugin | Repository | In this repo? | | ---------- | ------------------------------------ | ------------- | | `example` | `gitea.officer.dev/plugins/example` | no | | `offscale` | `gitea.officer.dev/plugins/offscale` | no | | `music` | `gitea.officer.dev/plugins/music` | no | All three are public and clone anonymously over https, which is what the marketplace requires — it clones with no credentials on purpose, so a private plugin cannot be installed from it at all. `.gitignore` ignores `/plugins/*/` — directories only, so this file and anything beside it stay tracked. The rule exists because a cloned plugin carries its own `.git`, and a tracked one turns `git add -A` into a commit of a gitlink: a pointer to a commit this repository does not contain. That is not a mistake to be careful about, it is what installing a plugin does, so it is handled by rule.