# Sidecars as installable apps **Status: DESIGN, agreed in conversation 2026-08-10. Nothing implemented.** This supersedes the framing of `sidecar-bootstrapping.md`, which stays as the record of how the mechanics work _today_. The goal: a clean machine runs chat, the terminal and the file browser, and **everything else arrives by the user asking for it** — from an app store inside Officer. Eventually including sidecars the user did not write. --- ## Why this is mostly not a rewrite Three things are already true, which is why "nothing exactly blocks it": - **Every API route stays mounted regardless of which sidecars run.** The light profile's own comment states it: features whose sidecars are absent report themselves unavailable rather than disappearing. So the app store never needs to mount or unmount routes. - **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by capability. Installing one is starting a process, not teaching officer about it. - **`service_connections` already solves the multi-user case**, including the part nobody would get right independently — see below. What is genuinely new: provisioning containers, per-sidecar schema, and persisted install state. --- ## Light becomes the baseline `ecosystem.light.config.cjs` stops being a variant and becomes what a fresh install runs: ``` officer · officer-anthropic-proxy · officer-agent · officer-opencode · officer-pty · officer-gitea ``` Chat, terminal, file browser. The file-browsing APIs live in the main process, so they cost nothing extra. The other fourteen become app-store entries. --- ## Three install shapes The prompt the user sees depends on which of these the sidecar is. This is the taxonomy the installer branches on: | Shape | What install means | Examples | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | **Point at an instance you already have** | Ask for URL + credential, write `service_connections`, start the sidecar | gitea, memos, photos (Immich), jellyfin, invoiceshelf, headscale | | **Provision one** | Render our compose template, `docker compose up -d`, wait for health, write the connection _we already know_, start the sidecar | vault (Vaultwarden), slskd, transmission, caldav (Radicale), and any of the above where the user has none | | **Configuration only** | Ask for credentials, start the sidecar. No service to reach | email (IMAP), notify, music, wallet, vnc | A sidecar can be more than one: Gitea is "existing instance" for someone who runs one and "provision" for someone who does not. The prompt is the fork. --- ## Docker: we install, the user owns **Officer is the installer, never the owner.** Concretely: - A real `docker-compose.yml` per service, written into a **user-owned directory** (`~/officer-services//`), from our template. - Started with `docker compose up -d` **as the owner**, not as officer's own identity. - Found again by **label** (`officer.sidecar=`), not by holding a handle. Consequences, which are the point: - `docker ps`, `docker logs`, `docker compose down` all behave normally. - If Officer is removed, the containers keep running and stay manageable. - A user who runs his own estate can edit the compose file — it is his file, in his directory. - We can always find what we installed without pretending to own it. The template is what makes this non-technical-user-friendly: sensible defaults, ports, volumes and health checks already correct, so "install Gitea" does not become a tutorial. `[open]` Rootful Docker runs container processes as root unless `user:` is set. Do we set it? And do we support Podman for people who want genuinely rootless? --- ## Install state Two independent flags, because they answer different questions: - **`installed`** — the thing exists: container provisioned, config written, schema applied. - **`enabled`** — the process should be running. That yields the three outcomes asked for: | Action | Effect | | ------------------------ | ------------------------------------------------------------------------------------ | | **Disable** | Stop the sidecar. Container, config, schema and data all stay. Re-enable is instant. | | **Uninstall, keep data** | Stop, remove the process. Leave container volumes and rows. | | **Full uninstall** | Also `docker compose down -v` and drop the sidecar's tables. | The middle one is the in-between; the user chooses disposal at uninstall time rather than us guessing. **Install must be idempotent and resumable.** Provision → health → config → schema → start is five steps and any of them can fail. The failure mode to design against is a half-installed service that neither works nor uninstalls. Each step records what it did; re-running install resumes rather than restarts. --- ## Per-sidecar schema Today all 42 tables live in one Drizzle schema and arrive together via `bun db:push`. That changes: **each sidecar owns its own schema and applies it on install.** This is right _because third-party plugins are a real goal_. For our own fourteen it would be over-engineering — an unused table costs nothing — but a marketplace plugin cannot ship a table into a schema it does not own. **The dependency graph makes this tractable.** Measured across the 19 non-core schema files: ``` core: auth.ts, server.ts, chat-events.ts (depend on nothing) sidecars: every single one -> auth.ts, and nothing else ``` There is **no sidecar-to-sidecar dependency anywhere**. One file (`user-data.ts`) touches two, and it is core. So the contract for a plugin's schema is nearly the smallest it could be: > **You may reference `users.id`. You may not reference anything else.** Which also makes full uninstall well-defined: drop the tables this sidecar declared. Nothing else points at them, by construction. `[open]` Where do a plugin's migrations live, and what applies them — the installer, or the sidecar on first boot? Versioning and upgrade are unsolved here. --- ## `service_connections` is part of the contract Decided: it stays **core and shared**, one table, with each plugin identified by its own ID — rather than a connections table per service. It already does the hard part. The row is keyed `(userId, service)` and **a NULL `url` means "inherit the instance"**: the owner's row carries the URL and _is_ the instance; every other user's row carries only their own credential and resolves the base from the owner's row at read time. So "members never see the instance URL" is a property of the schema rather than a filter someone must remember on every response — and a member cannot supply a URL, which closes what would otherwise be a per-user SSRF hop wearing a settings form. Gitea is the first service of this kind; five sidecars use the table today (memos, wallet, transmission, slskd, gitea). A third-party plugin inherits all of that for free, which is the argument for sharing the table: it is the part nobody would get right independently. Two things it needs before third parties touch it: 1. **Namespaced IDs.** `service` is free text — deliberately, so adding a service is not a schema change. With a marketplace, two plugins could both claim `"gitea"` and collide on the unique index. Needs a convention (reverse-DNS, or IDs issued by the marketplace). 2. **Somewhere for plugin-specific config.** The columns are shaped around the services that exist: `url`, `username`, `secret`, `path`, `version`. A plugin needing anything else has nowhere to put it, and adding a column per plugin defeats the shared table. Likely a `config` JSONB for the remainder — with `url` staying first-class, because the inheritance rule above depends on it being a real column. --- ## The API contract, when we open this up What a plugin author is promised, and bound by. To be written properly; the shape is: - **Register** by name + capabilities over `/api/sidecar/register`; be reachable by capability. - **Declare** an ID, an install shape, a compose template (if it provisions), a config prompt, and a schema. - **May reference** `users.id`, and use `service_connections` under its own ID. - **May not** reference another plugin's tables, or write outside its own. - **Must** tolerate being disabled, re-enabled, and uninstalled. --- ## Open questions 1. `user:` in compose, and Podman support for rootless. 2. Plugin migrations: who applies them, how versioned, how upgraded. 3. `config` JSONB on `service_connections` — or a different escape hatch. 4. ID namespacing authority. 5. What the app store does when Docker is absent — hide "provision", or refuse to install? 6. Does an installed-but-unhealthy sidecar surface in the UI as broken, or as not installed?