sd-server (stable-diffusion.cpp, Vulkan) serving Qwen-Image 2.1 on this
machine. It already speaks OpenAI's image API, which is the vocabulary
`ImageRequest` is written in, so there is no translation layer — only the parts
that are true of a local GPU and not of a hosted one.
── keyless is a category, not an exception ──
`credentials.ts` decided a provider was usable by asking whether a key existed,
so a service that takes none read as unconfigured forever. The fix is an
adapter declaring `keyless`, not a dummy secret: a dummy would record a
credential that does not exist and make the secret store lie about what is
configured, and that store is the one place meant to be the truth about them.
A keyless adapter never reaches `keyFor` at all. That function's own comment
says why it uses `getSecret` rather than `getKey` — so a missing FOREIGN
credential is never minted — and a provider with no credential by design has no
business in that path.
whisper, OCR and TTS already run on this machine, so this will not be the last.
── measured, not assumed ──
The integration doc said "tens of seconds, not two". The server's own log says
512x512 completes in ~185s and 1024x1024 samples at ~41.4s/step over 20 steps,
about 14 minutes. And it serialises strictly: a second caller waits for the
first to finish entirely, so a queued 512 can wait 17 minutes before a byte
comes back.
So: 512 is the default and the substitute for `auto`, 1024 is offered with the
cost in its label, and `timeout: false` is a requirement rather than caution —
every default anyone would reach for is far under 17 minutes. The caller's
`signal` still aborts, because a user cancelling is not a client giving up.
── the model id moved, because only it could ──
`GET /v1/models` returns `sd-cpp-local`, hardcoded in sd-server with no
override. The catalogue now agrees with the server and carries "Qwen-Image 2.1
(local)" in the label, so requests name a model the server knows while the UI
still says something meaningful.
── not shipped: edit() ──
Same GPU, same queue. It doubles the ways generation can be blocked while the
generate path is unproven in anger, and `ImageAdapter` makes absence the way to
say so — the route answers 400 instead of the queue finding out late.
Verified: /v1/models answers, provider statuses report qwen-local as
configured/keyless/usable, and the shipped entry survives the merge with the
stored catalogue.json. NOT verified end to end — a generation is occupying the
GPU and I left it alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
plugin npm dependencies are INTERSECTED into one plugins/node_modules, and
^0.33 and ^0.35 do not overlap — a 0.x caret pins the minor, so the install
failed outright rather than picking one. av-club could not come down: its
vocabulary exposes autoOrient as an input option, which is sharp 0.34+.
atelier calls exactly one sharp method, .metadata(), in
sidecar/generations/inspect.ts. resolved to 0.35.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
first dated version. plugins were all sitting on 1.0.0, which said nothing about
when a build was made or which of two copies is newer — the platform already
uses YYYY.MM.DD.NN and this follows it.
the version lives in manifest.ts because a plugin has no package.json; that is
the field the marketplace publishes and the installer compares.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
opencode discovers AGENTS.md and nothing else; claude code discovers CLAUDE.md.
this repo had neither, so an agent sent to change something here arrived with no
guidance at all under either.
it deliberately does NOT repeat README.md. that file says what the plugin is,
for someone deciding whether to install it. this one answers the other question:
i have to change something in here, what will i break.
the facts in it are read from the repository rather than recalled — the panel
keys from web/panels.ts, the tables from db/schema.ts, the permission keys, the
host binaries and the route claims from manifest.ts. presence is the declaration
everywhere in this system, so the file says what the platform will actually
conclude from these files existing.
CLAUDE.md beside it is a pointer, not a copy: two copies drift and the stale one
is the one somebody reads.
tags are how the marketplace is browsed rather than scrolled: a list of them on
the left, the plugins that carry one on the right. a closed vocabulary
(PLUGIN_TAGS in the platform) because free text becomes homelab, home-lab, Home
Lab and selfhosted inside a month, and a filter list of synonyms is worse than
no filter at all.
declared here rather than curated in the catalogue, so a plugin can say what it
is and nobody edits a table when one ships. the marketplace caches it on sync,
exactly as it already does for label and summary.
Drizzle generates names past Postgres's 63-character identifier limit. Postgres
stores the truncated form, drizzle compares against its own longer one, and every
push on every host drops and re-adds constraints that are already identical.
Deferred as harmless noise since 2026-08-19. What ended that was a deploy plan
saying "expect no changes, stop if it wants to alter something" — which this made
impossible to satisfy, so the deploy agent had to overrule its own instructions.
Verified by pushing twice against a scratch database: the fleet-wide second-push
churn went from ten statements to two, and the two that remain are a composite
primary key that cannot be fixed either way.
Naming a foreign key means removing the inline .references() or drizzle creates
both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It described a separate atelier Postgres database, providers as a section in
Officer's settings, and a BLOCKERS.md that was never committed here — none of
which is true. It is now what the code does, and why: the three words that are
not interchangeable, where keys live and why getSecret rather than getKey, the
catalogue's four rules, why refresh is a button, what a refresh can and cannot
learn, and the two ceilings the 240s poll sits under.
Written so that someone arriving cold can carry on from here.
The dropdown was listing "connections" — a row per adapter + model + key — which
made choosing a model an act of configuration, put a key icon and a bin inside a
menu item, and showed five accounts nobody had created.
A provider is now a vendor Officer curates. Every one is listed, set up or not,
because a vendor you have no key for is exactly what you need to see in order to
add one. The model is chosen per generation, from that provider's entry in the
catalogue, beside it.
With the model off the row, all that remained of a "provider" was the key for
OpenAI — and Officer already stores keys: one active value per purpose, 0600
inside a 0700 directory, outside the backup tarball, created on first use so
nothing needs provisioning. So `atelier_providers` is dropped, along with
`generations.provider_id` that pointed into it. `provider` and `model` stay as
text on each generation, which were always the historical fact rather than a
pointer to configuration.
`getSecret`, never `getKey`: this is a foreign credential, and getKey mints a
random value for a purpose it cannot find — which for someone else's API key
means authenticating with something invented.
Resolution follows: a request names a model, and the catalogue says whose it is,
rather than a database being asked which stored row served it. A model nobody
in the catalogue claims is still accepted — the catalogue is advisory, and a
model released today should work before we ship a file that mentions it.
Two routes replace five: PUT and DELETE on /providers/:name/key. Deleting a key
that came from the environment is refused rather than faked — it belongs to the
install, not to this screen.
The daily job is gone. A refresh only matters at one moment — you are picking a
provider and want to know its models are current — and at that moment a button
answers the question, where a nightly pass can only have already answered it,
invisibly, or not. It also bought a standing obligation: something hitting a
vendor from every install forever whether anyone opens Atelier or not.
So the add dialog is now the curated file made visible. Provider comes from the
catalogue, models come from that provider's entry in it, and two buttons re-read
it from the vendor: one for the provider in front of you, one for all of them.
`POST /providers/catalogue/refresh?provider=` is the only trigger there is.
The dialog also says how old the answer is — "as shipped" until a refresh has
ever run, which is worth showing, because it means the models listed are exactly
what the plugin was released with.
A provider with no refresher answers 400 rather than a silent success: from a
button, "nothing happened" and "nothing could happen" look identical.
The five connections in the database were never configured by anyone — a
migration minted one per model from the adapter's own list, so the table held
five decisions nobody made. What that list actually is, is a CATALOGUE: what
you can choose from, which is not the same kind of thing as what you chose.
So it is a file. `sidecar/catalogue/shipped.ts` is the curated baseline, in
git, reviewed; `$OFFICER_ROOT/data/atelier/catalogue.json` is what a refresh
writes, and it wins where it has something to say. Data dir, not the plugin
dir — a plugin directory is a clone an update replaces, which is the rule
env.ts already states for images.
Refresh runs in the sidecar, daily, and only on boot when the file is actually
stale — this process has restarted twenty times in an afternoon and should not
hit OpenAI twenty times for it.
Two sources, unequal, and the code says so. `GET /v1/models` is authoritative
and thin: ids and nothing else, so a model matching /^(gpt-image|dall-e)/
appears the day it ships without a release. Prices come off an HTML page
because there is no pricing endpoint for any plan, and that parser is written
to give up rather than guess — a guess gets written to disk and then believed.
When it gives up it says so in the log; the durable replacement is handing the
page to a model for structured output, which Officer can already do locally.
Nothing a refresh cannot establish overwrites a curated value: fields merge
one by one, not objects wholesale, so an id-only listing cannot wipe the sizes
and prices that were written by hand. Retiring is decided by the merge, which
can tell a complete listing from a request that failed halfway — and it marks
`deprecated` rather than deleting, because the gallery labels 88 historical
rows from this list.
Per-model sizes, qualities and transparency now travel with each entry, which
is what will let the form stop offering dall-e-2 a transparent background it
cannot do. The catalogue is advisory throughout: validate.ts still refuses to
enforce per-model rules, exactly as before.
Four panels became three, and the arrangement carries the change: everything
that organises or configures is stacked in one narrow column, and the whole of
the rest is one surface for looking at images.
Collections is a LIST, not a grid of covers — it sits in a narrow column beside
the gallery, where two cards per row meant four collections visible. A row is a
name, a count, and rename/delete on hover. Create is a dialog rather than an
always-present input, and a filter field replaces it in that space, since
finding one is the frequent act and making one is not.
Selecting a collection now shows it in the GALLERY. It used to replace the list
inside its own panel, which meant the selector disappeared the moment it was
used, and both panels kept their own copy of one collection's images and
disagreed after any edit. `?collection=` is read by the gallery now; the
collections hook stopped fetching detail entirely.
Deleting asks the question that cannot be undone: keep the images, or delete
them too. That needed a server route — DELETE /collections/:id?images=delete —
which reads the members BEFORE the collection goes, since deleting it takes the
memberships with it. Unchecked by default: the recoverable answer is the one
you get by pressing Enter without reading.
Providers stopped being a panel. It was a card per provider, each with a key
field and three buttons, filling a quarter of the screen for something set once
— it is a dropdown at the top of Generate now, listing "adapter · model" with a
delete on each, and adding one opens a dialog. The form and the dropdown share
one provider list (useProviders); they were two fetches, so adding a provider
left the form showing the old models until a reload.
Each panel's root was a plain div with padding, so anything past the panel's
height was simply not reachable — the gallery showed its first two rows of
sixty images, and the Providers panel cut off mid-card. The shell hands a
panel a fixed-height box and does not scroll it for you; plugins/music has
had `h-full overflow-y-auto` on every panel root since it was written.
Collections gets it in both places, the list and the detail view, which are
two separate returns in one file and would otherwise disagree.
Both masters already existed in OfficerIcons — the painter duck with the
beret and palette — so the plugin was showing a flat #f59e0b square for want
of a copy step, not for want of art.
The icon master is 1312x1199, not square, so it is trimmed to the artwork
before it is fitted to 256: resizing that canvas directly would have drawn the
duck at about four fifths the size everything else in the dock uses. Splash is
a straight 1254 -> 1024, and PLACEHOLDER.md goes with them.
the platform now merges every plugin's `npmDependencies` into one shared
install at plugins/node_modules/, so this declares a RANGE and shares whatever
the next image plugin resolves rather than carrying its own 50 MB copy of
sharp's platform binaries.
replaces the package.json and lockfile added an hour earlier. that version
worked and was the normal thing for a git repository to do; it just did not
survive the question "what happens when a second plugin wants sharp".
sharp was a direct dependency of the PLATFORM, added during the extraction
because there was nowhere else to put a javascript package. that broke on the
first other machine: alpha pulled the package.json, nobody ran bun install, and
this sidecar crash-looped on "Cannot find package 'sharp'".
the platform now installs a plugin's own packages into the plugin's own
directory (src/servers/plugins/npm-deps.ts), so this ships its own
package.json and lockfile like any other repository — and uninstalling atelier
takes sharp with it instead of leaving it in the platform's lockfile forever.
the lockfile is committed and frozen on install, same discipline the platform
holds itself to. sharp is load-bearing here, not incidental: inspect.ts uses it
to measure alpha channel and opacity, which is how a `background: transparent`
request that silently produced an opaque image gets caught.
`IntegrationsSettings/index.tsx` has carried the rule since email was extracted
on 2026-08-18: a plugin contributes panels, not settings sections. the
extraction brief said the opposite and the brief was wrong.
settings living in the platform mean this plugin is not self-contained — it
cannot be configured on an older platform, and uninstalling would leave a dead
"Atelier" section in the settings screen for someone to delete by hand.
so: a fourth panel, `atelier-providers`, and the default layout stacks it under
Collections in the right column. smallest and last because it is configured once
and then ignored — but it has to be REACHABLE, which is the whole point.
Generate's empty state pointed at "Settings → Integrations → Atelier". it now
points at the Providers panel, which is where the key actually goes.
the extraction kept atelier's own postgres database, because the brief said to
when the tables already had rows. the owner reversed it, and the reason is not
tidiness: a plugin that behaves differently from every other plugin is a
plugin nobody can reason about.
the separate database meant a second connection string to set before install,
a second thing to back up, a second thing to get wrong on a new machine, and
failure modes no other plugin shares — none of it bought anything except
avoiding a migration.
four tables now: atelier_collections, atelier_collection_items,
atelier_providers, atelier_generations. index and check constraint names carry
the prefix too — those are global in postgres and are the one thing that would
otherwise collide with another plugin.
db/schema.ts re-exports the definitions rather than duplicating them: they stay
beside the code that queries them in sidecar/db/, and that file exists to be
found by gen-plugin-schemas.
ATELIER_POSTGRES_URL is gone. db.ts reads OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL,
which the platform already sets.
the standalone service's rows are NOT here and nothing depends on them being
here — the old service is still running against the old database, untouched,
and migrating is a separate watched step after this is proven on alpha.
generatePluginsModule + rebuildFrontend, called directly with a synthetic install state, exercises
the same Bun.build + Tailwind path a real install performs — no server running, no port bound. It
compiled cleanly: 3 outputs, 2.2s, zero errors. Stronger than tsgo alone (catches bundler-side module
resolution and JSX/CSS processing), but does not confirm the result renders correctly in a browser —
noted as still open.
PLUGIN.md is the record: what moved, what changed on the way (authenticated image serving, the
secret-store OpenAI key fallback, provider_settings left behind), what stays running untouched (the
standalone service the mobile app still talks to), why there is no db/ directory, and what verification
did and deliberately did not cover.
README.md is the marketplace shop window — what it does, what it needs (its own Postgres database,
an OpenAI key), and an honest limitations section: two Ateliers can exist side by side right now, and
there is no per-user data model yet.
ported from the standalone app's hash-tab UI. The panels do not coordinate through props (the panel
contract only ever hands a component its panelId) or usePanelChannel-as-selection:
- Gallery's lightbox is ?selected=<id>, Collections' detail is ?collection=<id> — different names
since both panels render on the same route at once
- Generate bumps useAtelierRefresh (added to officerdev/src/channels.ts) when a job finishes;
Gallery subscribes to it — a genuine signal, not a selection, so it stays a channel
images render through GET /files/:filename with a ?token= query param appended client-side
(lib/api.ts's fileUrl), the same escape hatch FileViewer and the music plugin use for exactly this:
a browser cannot set an Authorization header on an <img src>.
Providers is not here — see PLUGIN.md for why it became a platform settings screen instead.
assets/icon.png and assets/splash.png are [placeholder] flat swatches, not real artwork — see
assets/PLACEHOLDER.md.
ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only
adapter today), the generation queue, collections, and the database layer — kept on its own
Postgres database rather than folded into officer_db, since both collections and provider_settings
had rows on the machine this was extracted from.
what changed rather than just moved:
- images are served from an authenticated GET /files/:filename instead of an unauthenticated
PUBLIC_URL-prefixed static route
- the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter
- provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has
no callers left anywhere in the server code, only a migration script that also did not move
ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers
liveness.
not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not
been written, so this is not yet a discoverable plugin.