diff --git a/PLUGIN.md b/PLUGIN.md
new file mode 100644
index 0000000..7bc21ba
--- /dev/null
+++ b/PLUGIN.md
@@ -0,0 +1,169 @@
+# Atelier — extracted from a standalone service into a plugin
+
+**Status: extracted 2026-08-20, unattended overnight run.** Read `BLOCKERS.md` alongside this file —
+it has the decisions made without being able to ask, in the order they were found. This file is what
+moved, what didn't, and why; that one is what was uncertain.
+
+Atelier is image generation as a service: a provider-shaped API over OpenAI's image endpoints, storing
+what it makes so every image has a durable reference. It came from
+`~/officer-dev/dockers/officer-atelier`, a standalone Bun/Hono/Postgres server predating the plugin
+system, still running in production as `officer-atelier` (PM2, port 3002, `atelier.officer.dev`) — see
+"What did not move" below before assuming it is safe to touch.
+
+---
+
+## What moved, and what changed on the way
+
+**Nothing pointed the other direction.** Unlike offscale and music, a grep of the whole platform for
+`atelier` found no reverse dependency — no widget, no relay, nothing importing from the standalone
+service. It came out whole with no seam to design around, which is the exception rather than the rule
+for this kind of extraction.
+
+### The backend — `sidecar/`
+
+Providers (adapter-shaped: `types.ts`, `openai.ts`, an `index.ts` that dispatches — OpenAI is the only
+adapter today, and the point of the shape is that a second one is a new file, not a new screen), the
+generation queue (Postgres-backed, `MAX_CONCURRENT_GENERATIONS` workers), collections, and the database
+layer moved essentially unchanged. Three things did not:
+
+1. **Images are served from `GET /files/:filename`, authenticated, not an unauthenticated `PUBLIC_URL`
+ route.** The standalone service served `/images/*` off Bun's own route table with no session,
+ because every image needed "a permanent URL" reachable by the mobile app and anything else on the
+ tailnet. Inside Officer that is not an option — see "What you may NOT do" in the kickoff prompt. The
+ platform's own `?token=` escape hatch (already used by `FileViewer/file-types.ts` and the music
+ plugin's `MusicPlayerHost` for exactly this — a browser cannot put an `Authorization` header on an
+ `
`) covers it: the JSON API returns `/files/` (relative, no host, no token — the
+ sidecar cannot know the platform's public origin any more and does not need to), and the frontend
+ appends its own token when rendering (`web/lib/api.ts`'s `fileUrl`).
+
+2. **The OpenAI-key bootstrap fallback reads the secret store before `.env`.** `keyForProvider` still
+ prefers a provider row's own encrypted key; the fallback that used to be `process.env.OPENAI_API_KEY`
+ directly is now `getSecret('atelier--key')` first, `.env` second — `getSecret`, never
+ `getKey`, because a foreign credential must not be silently substituted with a random one. Set it
+ with `bun run secret:set atelier-openai-key`.
+
+3. **`provider_settings` did not come along.** It is the one-row-per-adapter shape `providers` replaced
+ (see the standalone repo's own `providers/schema.ts` comment and `scripts/migrate-providers.ts`), and
+ grepping the server code found no caller anywhere except that migration script — which also did not
+ move. Its table and its one row stay in the `atelier` database, untouched. If a machine's migration
+ never ran, that is a real gap, but it is the standalone service's gap, not one this extraction
+ introduced.
+
+`ping`/`echo` (smoke-test routes, no caller) did not move either — the sidecar's `/_health` covers
+liveness, matching every other HTTP sidecar in this codebase.
+
+### The database — stays separate, does not fold into officer_db
+
+Decision 3 said to check whether `collections` and `provider_settings` were empty before folding them
+into `officer_db`. Both had rows (2 and 1), so the `atelier` Postgres database stays separate — the
+sidecar keeps its own connection (`sidecar/db/db.ts`), reading `ATELIER_POSTGRES_URL` from the platform
+`.env`. **This is new territory for the plugin system**: `db/schema.ts` at a plugin's root is the
+`officer_db`-prefixed-tables convention (`pushPluginSchema`, `_` prefix), and this plugin has no
+such file — `discoverPlugins()` correctly reports `schema: null` for it, and the install flow's schema
+step is simply skipped. Nothing in `src/servers/plugins/` needed to change for this, because "no
+`db/schema.ts`" is already a valid state the discovery contract handles — a plugin with an internal,
+self-managed database is indistinguishable from a plugin with no tables at all, as far as the platform
+is concerned. Worth knowing before assuming every plugin's data lives in `officer_db`.
+
+**Not set automatically.** `ATELIER_POSTGRES_URL` has no default — see `BLOCKERS.md` for why (mainly:
+pointing it at the live production `atelier` database while that database's own standalone service is
+still running risks two processes racing the same generation queue). The owner has to add it before
+installing.
+
+**Migrating into `officer_db` as `atelier_*`-prefixed tables is real follow-up work**, not done here —
+five tables (`generations`, `collections`, `collection_items`, `providers`, and the dead
+`provider_settings`, which should be dropped rather than migrated), real data, needs a human watching it.
+
+### The frontend — three panels, plus a settings screen
+
+`web/panels.ts` + `web/layout.ts`: Generate, Gallery (+ Lightbox), Collections — the ≈1,200 lines of
+real work the brief identified, ported with the shadcn components Officer already ships
+(`@/components/ui/*` — the standalone service's own `src/apps/frontend/components/ui/*` did not move,
+per decision 1). Providers did not become a fourth panel:
+
+**Providers is a platform Settings screen** (`Settings → Integrations → Atelier`,
+`src/apps/officer-web/.../IntegrationsSettings/AtelierProviders.tsx`), per decision 2. Worth flagging
+loudly: **`IntegrationsSettings/index.tsx` itself carries a comment, written five days earlier when email
+was extracted, that says the opposite** — "a plugin contributes panels, not settings sections, so it
+now lives inside the email workspace itself." Decision 2 was explicit and pre-made, so it was followed
+anyway, and the contradiction is recorded in `BLOCKERS.md` rather than silently resolved either way.
+`AtelierProviders.tsx` calls `/api/atelier/providers` over plain HTTP via `useClient()` — it imports
+nothing from `plugins/atelier/`, so nothing here reaches back across the plugin boundary; it is exactly
+the same relationship any authenticated frontend has with any authenticated backend.
+
+Panel coordination is URL + one channel, never props (a panel component only ever receives `panelId`):
+
+- Gallery's lightbox is `?selected=`; Collections' detail is `?collection=` — two different
+ names because both panels render on the same route at once and would otherwise collide.
+- Generate bumps `useAtelierRefresh` (added to `src/workspaces/officerdev/src/channels.ts`, same
+ stable-bump shape as `useFilesRefresh`) when a job finishes; Gallery subscribes to it. This replaces
+ the standalone app's `onFinished` callback prop, which only worked because `App.tsx` lifted the
+ gallery's state above both tabs — not an option once they are independent panels.
+
+The default layout is a fixed horizontal three-way split (Generate 26% / Gallery 48% / Collections
+26%) rather than the standalone app's hash-based tabs — the `LayoutNode` type has no tab-group concept,
+only `group` (split) and `panel`, so three panels means three panes. Users can resize or rearrange
+freely; this is only the starting arrangement.
+
+### `sharp` — promoted to a direct dependency
+
+`generations/inspect.ts` needs it to measure width/height/alpha-channel/opacity of a written image —
+load-bearing, not incidental: it is how a `background: transparent` request that silently produced an
+opaque image gets caught, which the standalone repo's own comments call "the single most consequential
+failure for an asset destined to be an app icon." It was already resolved in `bun.lock` as an optional
+transitive dependency of `next`; adding it as a direct dependency pulled in no new package versions —
+verified by reading the lockfile diff before committing it.
+
+---
+
+## What did not move, and must not be touched
+
+**The standalone `officer-atelier` service keeps running, unchanged.** Its own `ecosystem.config.cjs`
+says plainly: "this is the instance the mobile app talks to, so it holds still." It serves
+`https://atelier.officer.dev`, holds real data (87 generations, 2 collections, 5 providers on the
+machine this was extracted from), and this extraction did not stop it, did not touch its nginx config,
+and did not touch the mobile app. **Cutting the mobile app over to the plugin's route — or deciding
+whether to at all — is the owner's call, not something this run made unilaterally.** Until that happens,
+having both installed means two independent Atelier deployments existing side by side, which is
+expected, not a bug.
+
+---
+
+## Verification
+
+- `bunx tsgo`: clean.
+- `bun test`: baseline was 1041 pass / 0 fail across 90 files; after this extraction, 1069 pass / 0 fail
+ across 92 files — the two new files are `sidecar/serialize.test.ts` and
+ `sidecar/generations/response.test.ts`, both ported from the standalone repo (URLs updated for the new
+ `/files/` scheme). No regressions, no file count drop.
+- `discoverPlugins()` finds the plugin, the manifest validates, `schema: null` as expected (separate
+ database).
+- The platform-side proxy router (`api/router.ts`) mounts and correctly answers `503 atelier sidecar
+ not available` with no sidecar running — same behaviour as offscale's.
+- The sidecar's internal Hono app was exercised directly (`buildHonoApp()`, real HTTP requests, no port
+ bound) against the `atelier_dev` Postgres database: `/providers`, `/providers/adapters`,
+ `/collections`, `/images` all answered `200` with real rows; `/files/` answered `404`; an
+ unknown route answered the OpenAI-shaped `404` envelope. Old provider rows correctly failed to
+ decrypt (a fresh `secret.key` was generated for this checkout, as intended — a key that will not
+ decrypt is broken, not absent, and `keyForProvider` reports `configured: false` rather than crashing).
+- **Not done**: a live PM2 install/enable/disable/uninstall/purge cycle through `/marketplace`, per the
+ runbook's own verification table. This machine's live `officer` process serves the owner's real
+ session and its own `plugins/` directory is the main checkout, not this worktree — installing through
+ it would write real rows into the shared `officer` Postgres database from code that only exists here.
+ That crosses into "affects shared systems beyond the local environment," so it was not done
+ unattended. The safer checks above cover the same ground read-only or against an isolated database.
+
+---
+
+## Open, for whoever picks this up
+
+1. **Publish to `gitea.officer.dev/plugins/atelier` and register it in the marketplace.** Not done — see
+ `BLOCKERS.md` for why and the exact commands.
+2. **`ATELIER_POSTGRES_URL` has to be set before install.** No default, on purpose.
+3. **The `atelier` → `officer_db` migration**, described above, once someone can watch it happen.
+4. **`assets/icon.png` and `assets/splash.png` are `[placeholder]`** flat swatches — see
+ `assets/PLACEHOLDER.md`.
+5. **A real PM2 install/enable/disable/uninstall/purge cycle**, on a machine where doing so does not
+ touch the owner's live session or shared database.
+6. **Deciding whether and when to cut the mobile app over** from `atelier.officer.dev` to the plugin's
+ route, and what happens to the standalone service afterward.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6ed195e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,73 @@
+# Atelier
+
+Image generation for an Officer server: a provider-shaped API over OpenAI's image endpoints, storing
+what it makes so every image has a durable reference — a gallery you can browse, collections you can
+organise, and a job an agent can poll rather than block on.
+
+Atelier was a standalone service before this plugin existed
+(`~/officer-dev/dockers/officer-atelier`, still running as `officer-atelier`, still what the mobile app
+talks to). This plugin is a fresh extraction into Officer, not a replacement for it yet — see
+`PLUGIN.md` before assuming the two are interchangeable.
+
+## What you get
+
+- **Generate** — a prompt, a model, size/quality/background, and a job you watch settle. `n` images
+ become `n` independently-tracked rows, so one failure out of four does not discard the three that
+ worked.
+- **Gallery** — everything generated, newest first, with a lightbox: pan, zoom about the cursor,
+ checkerboard/dark/light backdrops for telling a transparent background from an opaque one that only
+ looks it.
+- **Collections** — named sets of images. A generation request can name one by name and it is created on
+ first use; the gallery and the lightbox can file an image into one after the fact.
+- **A provider-shaped backend.** "OpenAI" is code, written once; a *provider* is a row — which model,
+ whose key, on or off — and several can share one adapter. Today OpenAI is the only adapter; the point
+ of the shape is that a second one is a new file, not a redesign.
+- **An OpenAI-compatible job API**, for agents. `POST /api/atelier/images/generations` accepts OpenAI's
+ request body verbatim and answers `202` with a job id rather than blocking for the length of the
+ generation — the caller polls `GET /api/atelier/images/generations/:id`, optionally with `?wait=` to
+ long-poll instead of retrying in a loop.
+
+## Requirements
+
+- An Officer server, version 1.0.0 or later.
+- A **separate Postgres database**, and its own environment variable: `ATELIER_POSTGRES_URL`. Atelier's
+ tables (`generations`, `collections`, `collection_items`, `providers`) do not live in `officer_db` —
+ see `PLUGIN.md` for why, and set this before installing. There is no default.
+- An OpenAI API key, set with `bun run secret:set atelier-openai-key`, or configured per-provider in
+ Settings → Integrations → Atelier, or (for an install that predates the secret store) as
+ `OPENAI_API_KEY` in `.env`.
+
+## Install
+
+From the Officer marketplace: find Atelier, install. That mounts `/api/atelier` and `/atelier`, starts
+the `officer-atelier` sidecar under PM2, and connects it to whatever `ATELIER_POSTGRES_URL` points at —
+which has to already have the schema (the standalone repo's own `bun db:push` creates it; this plugin
+does not run migrations against a database it does not own the schema of).
+
+Uninstalling stops the sidecar and unmounts the route. **It never touches your database** — Atelier's
+tables and rows are not this plugin's to drop, since it does not own the schema push for them either.
+
+## Permissions
+
+One permission, `atelier`, grantable per role at read or write.
+
+- **Read** lets an account use Generate, browse the Gallery and Collections, and see which providers are
+ configured (never a key, in any form — not even masked).
+- **Write** additionally lets an account add, edit or delete providers, including their API keys. A
+ provider's key authorises real spend on someone's OpenAI account, so treat write here the way you
+ would treat write on any billing-adjacent surface.
+
+Providers is not a panel — it is a platform Settings screen (Settings → Integrations → Atelier), because
+it is owner-level configuration rather than a working view. See `PLUGIN.md` for why.
+
+## Limitations, honestly
+
+- **Two Ateliers can exist at once.** This plugin and the standalone service are independent — installing
+ this one does not migrate the standalone one's data, stop it, or repoint the mobile app. See
+ `PLUGIN.md`.
+- **No per-user data.** Every account with `read` sees the same gallery and the same collections — there
+ is no ownership model yet, the same simple shape offscale shipped with first.
+- **The database is separate from `officer_db`**, which means Atelier's tables are not covered by
+ `bun db:push`, are not part of the platform's usual backup story unless you back up the second
+ database too, and need their own connection string. Migrating them in is real, tracked follow-up work
+ — see `PLUGIN.md`.