Agreed in conversation, nothing implemented. Light stops being a variant and becomes the baseline — chat, terminal, file browser — and the other fourteen sidecars arrive by the user asking for them from an app store, eventually including sidecars the user did not write. Mostly not a rewrite, for three reasons already true: every API route stays mounted regardless of which sidecars run, officer already spawns nothing, and service_connections already solves the multi-user case. What is new is provisioning, per-sidecar schema, and persisted install state. Docker: officer is the installer, never the owner. Real compose files in the user's own directory, started as him, found again by label. `docker compose down` works, and the containers outlive Officer. Per-sidecar schema is right here specifically because third-party plugins are a real goal, and the dependency graph makes it tractable: measured across 19 schema files, every sidecar depends on auth.ts and nothing else, with no sidecar-to-sidecar edges anywhere. So the plugin contract is "you may reference users.id" — which also makes full uninstall well-defined, since nothing else points at a plugin's tables. service_connections stays core and shared rather than per-service, because it already does the part nobody would get right alone: a NULL url means "inherit the instance", so the owner's row is the instance and members hold only their own credential, making "members never see the instance URL" a property of the schema instead of a filter someone has to remember. Records six open questions rather than settling them, including plugin migrations, ID namespacing for a marketplace, and where plugin-specific config lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.5 KiB
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_connectionsalready 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.ymlper service, written into a user-owned directory (~/officer-services/<service>/), from our template. - Started with
docker compose up -das the owner, not as officer's own identity. - Found again by label (
officer.sidecar=<id>), not by holding a handle.
Consequences, which are the point:
docker ps,docker logs,docker compose downall 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:
- Namespaced IDs.
serviceis 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). - 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 aconfigJSONB for the remainder — withurlstaying 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 useservice_connectionsunder 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
user:in compose, and Podman support for rootless.- Plugin migrations: who applies them, how versioned, how upgraded.
configJSONB onservice_connections— or a different escape hatch.- ID namespacing authority.
- What the app store does when Docker is absent — hide "provision", or refuse to install?
- Does an installed-but-unhealthy sidecar surface in the UI as broken, or as not installed?