diff --git a/docs/offscale-plugin.md b/docs/offscale-plugin.md
index 75eb9075..10b78b91 100644
--- a/docs/offscale-plugin.md
+++ b/docs/offscale-plugin.md
@@ -166,6 +166,117 @@ What is compile-time is only the routes and permissions, not the sidecar's exist
---
+## How the frontend ships
+
+**Everything moves to the plugin — the frontend does not stay in this repo.** That rules out treating
+federation as a later problem, and it is what the build model below exists to answer.
+
+### Build to a directory, rebuild on install
+
+Today Bun compiles the SPA at server start, through the HTML import. That changes:
+
+1. `pm2` starts `officer`
+2. it builds the frontend immediately into `build/` (untracked)
+3. it serves `index.html` from that build
+4. installing a plugin triggers a rebuild, and the page auto-refreshes or the user is told to
+5. **no server restart** — `Bun.build()` is a runtime call, not a process lifecycle event
+
+**Same origin throughout.** A separate origin for the API was considered and dropped: it would mean CORS
+and rewriting every endpoint in `useClient` for no gain, since Bun can serve the build itself.
+
+This is why there is no module federation, no import map and no iframe anywhere in this design. Everything
+is compiled together; a plugin simply changes what "everything" is.
+
+### `Plugins.tsx`, generated at build time
+
+`App.tsx` keeps the core routes and gains one map:
+
+```tsx
+plugins.map((plugin) => } />);
+```
+
+The wildcard delegates to the plugin's own router, which React Router nests natively. `plugins` comes from
+a **generated `Plugins.tsx`**, written at build time from what is installed — because a bundler cannot
+follow `import(someRuntimeString)`, the specifier has to be concrete before the build runs.
+
+Everything the shell currently hardcodes per plugin collapses into that one file. Today headscale is named
+in six places, and every one of them is a place to forget:
+
+| Place | What it holds |
+| ----------------------------- | --------------------------------------------------- |
+| `App.tsx` | the `/headscale` + `/headscale/:section` route pair |
+| `Screens/Dashboard/index.tsx` | the screen barrel export |
+| `AppRegistry.tsx` | the panel-meta import and spread |
+| `Dock.tsx` | the tile, in `CORE_DOCK_ITEMS` |
+| `usePageTitle.ts` | the title rule |
+| `officerdev/src/index.ts` | the section-helper re-exports |
+
+**Build time becomes the single source**, including the dock — the runtime `dockItemsFromPlugins` path is
+to be corrected to follow this rather than left as a second source that can disagree.
+
+### Presentation is build-time; permission stays runtime
+
+The one line not to blur. `Plugins.tsx` says a tile exists at `/headscale`. Whether _this_ account sees it
+is still asked per request, because grants change without a rebuild and the entire auth model rests on
+re-reading the role rather than trusting a claim.
+
+---
+
+## Dependencies between plugins
+
+The pilot has two, and they are **different kinds** — the same word covering two problems:
+
+- **service** — `assist.ts` needs `officer-anthropic-proxy`. A runtime call over a wire. Loosely coupled,
+ and it already degrades: `ProxyUnavailable` → 503 `assistant_unavailable`.
+- **code** — `ConsoleView` imports `TerminalView` from the Terminal panel app, and relies on
+ `/api/terminal/ws`. That is in its own bundle at build time. It cannot degrade; it resolves or the panel
+ does not build.
+
+**The rule: a plugin may depend on another, and must degrade when it is absent.** Both of the above are
+optional sections, which is why both are survivable.
+
+### Service dependencies go through the API
+
+**A plugin may call any API endpoint, carrying the user's token, with exactly the permissions that user has
+anywhere else in the app.** A plugin is part of the application, not a guest in it.
+
+Which means a plugin offering a service to other plugins **exposes it as routes**, like everything else. No
+private side channels — and that deletes a real piece of debt: `claude-proxy.ts` currently reaches the
+anthropic proxy by reading its private state file (`DATA_PATH/sidecar/claude-state.json`) to lift a secret.
+Invisible, unversioned, and silently broken the day the proxy moves. An authenticated API call is better
+regardless of plugins.
+
+There is deliberately **no per-plugin permission list**. The bound is the user: a plugin can never exceed
+the account calling it. What carries the weight instead is marketplace review — which makes review a
+**security** boundary, not just a naming one. Worth knowing about the thing you are relying on.
+
+---
+
+## Developing a plugin
+
+The platform is open source, so the development environment is a **platform checkout**. A developer clones
+the platform, runs it in dev, and builds the plugin inside it — the WordPress model.
+
+That dissolves what looked like the hardest problem. There are 13 workspace packages (`components`,
+`hooks`, `state`, `helpers`, `types`, `officerdev`, `widgets`, …) and they resolve purely because
+`"workspaces": ["src/workspaces/*"]` links them by name. So a plugin author writes
+
+```ts
+import { useClient } from 'hooks/useClient';
+import { Card } from 'components/Card';
+```
+
+and it works, with no publishing, no package registry and no version negotiation — because the plugin sits
+inside the workspace like any first-party code.
+
+**And it makes dev-time and build-time the same mechanism.** A plugin builds on the server exactly as it
+built on the laptop, so it cannot work in one and fail in the other.
+
+`[open]` The plugin directory must be gitignored in the platform repo, so work in progress is not swept
+into someone's commit. A plugin's own repository is cloned _into_ a platform checkout, never forked from it.
+
+---
+
## Permissions
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
@@ -290,6 +401,63 @@ one. Do not reintroduce a member-facing enrolment route on the assumption someth
---
+## What headscale actually is — the inventory
+
+Read end to end on 2026-08-14. This is what has to move.
+
+### Backend — 2,406 lines
+
+`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
+logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
+`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
+
+Three things worth knowing before touching it:
+
+- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
+ never passed as a parameter — so no client can act on a server the owner is not currently looking at.
+- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
+ uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
+ bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
+ misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
+ object back. One file containing all of it is the model for a plugin's client layer, not something to
+ undo.
+- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
+ is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
+ branches on whether the body parses.
+
+Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
+`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
+
+### Frontend — 29 files, 27 endpoints
+
+Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
+`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
+`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
+Diagnostics · Console.
+
+It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
+lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
+server is a DB write that re-scopes every query, so it stays a button rather than a URL.
+
+The whole frontend↔host coupling, which becomes the plugin API:
+
+| Import | Why it matters |
+| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
+| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
+| `AppRegistryMeta` | the panel-contribution contract |
+| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
+| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
+| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
+
+### `assist.ts` travels, but stays unwired
+
+The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
+do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
+later. Do not tidy it away as unused code.
+
+---
+
## The state of the app store, as found
It **is** the plugin system, roughly 90% built, with one structural hole.
@@ -335,16 +503,19 @@ used in addition rather than instead — they span orgs, which matters because b
## Open questions
-1. **Frontend code is the hard one.** Everything else on the list is data or a process; the SPA is
- compiled. `public/plugins//` exists but carries only icons. Shipping a third party's React that
- shares the shell's React, router and query client is a different problem class — federation, an iframe,
- or a manifest-driven generic UI. Do not design the package format as though this is solved.
+1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
+ rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
+ everything compiles together and a plugin changes what "everything" is. The developer builds inside a
+ platform checkout, so dev-time and build-time are the same mechanism.
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
something has to apply schema changes over time. Cheap now, miserable to retrofit.
3. **Health, distinct from enabled.** The store already renders amber for "installed and enabled but the
process is not online". That state needs a definition a plugin can satisfy.
-4. **No inter-plugin dependencies.** Measured: zero sidecar-to-sidecar dependencies, and every non-core
- schema references only `auth.ts`. Worth promoting from accident to rule while it is still free.
+4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
+ still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
+ → `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
+ plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
+ Console section does not travel with the plugin.
5. **`service_connections.service` namespacing** before third parties touch it.
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is