Skip to content

studio (web UI)

The studio (pkg/studio) is keryx's local web UI: a single-user web app over a reel workspace, a richer front-end to the same files the CLI edits. Spec: 0011-studio.md. Contract: 0002 §4.

Status: the full pipeline ships as the Command Deck (specs 0028 + 0029). The studio is a persistent-chrome cockpit — wordmark · Author │ Produce │ Publish phase tabs · save state · theme — over a reels rail and the active reel's phase view. Each phase is decomposed onto its own surfaces (spec 0028 §3.2): - Author (shape) — timeline strip · card grid · card inspector (fields only) · chat · Associated reel-level setup. - Produce (make) — the shared timeline+grid spine · immersive 9:16 preview hero · right column: CardMediaPanel (per-card VO + illustration) · ContactSheet · SpendQuotaPanel (spend breakdown + voice quota, spec 0031). - Publish (ship) — PlatformCopy (compose · approve · post) · ConnectionsPanel (token health) · LedgerPanel (posted-state).

Media generation moved to Produce; the per-request spend guard (spend.confirm_above, spec 0031) refuses over-cap generation pending confirm. Real light/dark theming, a shared TabBar mobile switcher, and token-driven styling throughout. Earlier foundations remain: library, validation, uploads, chat (whole-board propose/accept + client-side cherry-pick), settings, project switcher, commit-on-save + remote git (token · local-clone · in-memory · SSH), per-project config + themes with hot-reload (spec 0014), responsive + overflow menu, and the LAN-exposure auth gate (spec 0018).

Architecture

pkg/cmd/studio/      cobra command `keryx studio` (MCP-gated, --port/--host)
pkg/studio/
  server.go          go/controls lifecycle + go/transport server, localhost bind
  gitworker.go       serialises git behind one goroutine (spec 0051 §5)
  outcomes.go        the last git outcome per workspace (spec 0052)
  sweep.go           in-memory takes reaper (0013-D7)
  mux.go             ServeMux: /healthz, /api/v1, SPA catch-all
  registry.go        studio.yaml project registry (user-scoped, non-config)
  handlers_projects.go  project switcher (list/add/switch/forget)
  commit.go          commit-on-save (Committer seam over internal/gitrepo)
  handlers_reels.go  reels-library API over internal/workspace
  handlers_workspace.go  single-reel editor API (storyboard read / validate / save)
  handlers_assets.go     source text, bundle link, cover upload
  handlers_chat.go       SSE chat: stream prose + a whole-board proposal
  chat.go                Chatter seam (live: streams via chat client, then asks)
  handlers_config.go     project settings (allowlisted, non-secret)
  httpjson.go        JSON write/decode + sentinel→status helpers
  embed.go           //go:embed all:web/embed + SPA fallback
  gen.go             //go:generate → builds the SPA (skips without Node)
  web/               Svelte 5 + Vite SPA (source); embed/ is generated

Project switcher (/api/v1/projects, R-UI-28 + R-GIT-2)

GET (list + active), POST (add — {path} for a local dir or {remote, branch?} for a git remote), POST …/switch, POST …/forget. The known projects live in a user-scoped studio.yaml registry (registry.go) — a plain typed YAML in ~/.keryx/, read/written directly, not through the config system. Switching rebinds the active reel root under a write-lock (a.root()) and adopts that project's config + themes (see Per-project config below). forget only drops a registry entry — it never deletes files.

Remote projects (R-GIT-2/5): POST {remote} adds a project by git remote. Local clone is the default/optimal path — it clones (GTB vcs/repo, forge token auth) to a content-addressed cache (~/.keryx/cache/<hash>/) and then behaves as a local project (commit-on-save works on the clone). storage: inmemory is an explicit opt-in edge case (an advanced "no local checkout" UI toggle) for permission-less / no-clone environments: it registers a synthetic project and, on switch, clones the remote straight into RAM (GTB OpenInMemorymemfs, no disk). The active project's reel handlers then read/write a per-project afero.Fs — the in-memory worktree (WorkFS()) for in-memory, the OS fs for local. To bound memory, the RAM copy is held only while active and released on switch-away (re-cloned on return); the UI warns about unsaved/unpushed loss before leaving an in-memory project. forget only drops a registry entry — it never deletes files.

Takes reaper (0013-D7). Candidate takes would otherwise accumulate in RAM unbounded for an in-memory project. runTakeSweeper (a supervised service — see Lifecycle below) runs every 30s and, for the active in-memory project only, frees files in any */takes/ dir older than a grace TTL (studio.takes_ttl, default 3 min) — so re-generating and going back to pick an earlier round works within the window while RAM stays bounded. Selected slots and the OCR ledger are never touched; local-disk projects are a no-op (their takes are cheap files the workspace lifecycle owns).

Lifecycle: three supervised services (spec 0051)

Server.Run does not hand-roll a serve loop. It builds a go/controls controller and registers three services, each started and drained by it:

service what it does
the HTTP server go/transport's server over the studio's own listener
git the serialising git worker (below)
take-sweeper the in-memory takes reaper (above)

keryx chooses the bind address itself and hands the listener over, which is what makes a localhost-only dev UI expressible.

Signals belong to the CLI framework, not to the studio. The controller is built without WithSignals: GTB's root command already installs a SIGINT handler and translates it into cancellation of the context the studio is given. signal.Notify is multicast, so installing a second handler meant two supervisors racing to drive the same shutdown. One owner, observing a context.

Health is liveness, not outcomes. /healthz, /livez and /readyz are mounted on transport's own mux above the application handler and deliberately bypass the middleware chain, so pollers keep working regardless of what the app puts in front of its routes. They report whether a service is running — never what an operation did. A failed push is not a health event: the git worker is healthy precisely because it ran the job and reported the failure. Services therefore register with no WithStatus, which is what keeps a routine push failure from degrading overall_healthy and getting a healthy studio restarted.

Panics are contained per unit of work. controls recovers a StopFunc and a StatusFunc, but not a StartFunc — so a panic in a background service would end the whole process. For an in-memory project that is data loss, because the RAM worktree is the only copy of your work. So each loop recovers around one unit: a sweep tick, a git job. A panicking git job still answers its waiters (a plain failure, with the panic and its stack going to the log rather than the UI) instead of leaving "Commit & push" hanging. superviseStart converts anything that escapes into a returned error, which is the only thing a restart policy acts on; both services restart without limit, since a cap would let a background chore shut the studio down.

Request limits are the framework's, adjusted deliberately. Adopting go/transport's server means adopting its policy defaults, and three of them are wrong for this app — invisible until a request is long, large, or slow:

default transport studio
request body 1 MiB 64 MiB (WithMaxRequestBodyBytes) — a cover image exceeds 1 MiB
write timeout 10s cleared per request on the chat stream, so an LLM reply is not cut mid-answer
read timeout 5s cleared per request on multipart uploads, so a large or slow body still arrives

The two timeouts are relaxed per request rather than server-wide, so the other routes keep the protection. IdleTimeout (120s), MaxHeaderBytes (1 MiB) and the security headers are left at transport's defaults.

Streaming frames come from go/transport (v0.4.0). WriteEvent does the SSE framing and — the reason for adopting it — returns an error, so a stream that has been cut is visible to the handler. keryx's own helper discarded both the write error and the flush, so a client that navigated away was indistinguishable from one still reading, and the chat endpoint carried on generating tokens into a dead connection. It now stops.

Clearing the write deadline stays keryx's job: transport deliberately does not do it, because that would remove the server's only bound on a client that opens a stream and never reads. A writer that reports ErrNotSupported has no deadline to clear and streams on; any other failure means the stream would be cut, so the request is refused up front rather than truncated later.

Git runs on one goroutine (spec 0051 §5)

Git was previously unserialised: each call opened a fresh handle, so two requests could run go-git against the same repo at once. Every git-reaching handler now submits to a single worker.

  • Queued work for the same reel coalesces. A later commit does everything an earlier one would have, plus the edits since. Merging keeps the stronger request — a commit-only arriving behind a queued commit-and-push must not drop the push.
  • Cancelling a request abandons the wait, never the work. A browser navigating away stops the waiting, not the committing; the rail recomputes dirty state on the next listing, so the UI self-heals.
  • Shutdown drains what it accepted under a context that outlives cancellation, because a half-applied commit is worse than a slow one. Each job carries a 2-minute ceiling so an unresponsive remote cannot hold shutdown open.

The git outcome record (spec 0052)

Serialising git behind a worker decoupled the work from the request, which created a gap: an outcome can now be produced with nobody listening. A browser that navigates away stops the waiting, not the committing; and the shutdown drain finishes accepted work after the response is long gone. In both cases the result had nowhere to go.

So the worker records what it did. GET /api/v1/workspace/{slug}/outcome returns the last one — commit and push exactly as the synchronous response carries them, plus when it happened — or 404 when nothing has been recorded, so the studio shows nothing rather than a blank failure for a reel never committed.

where ~/.keryx/outcomes.yaml, beside studio.yaml
keyed by project path and slug
kept the last outcome per workspace; no history
scope git only

It is user-scoped, not in the workspace, deliberately. A push outcome describes this machine's access to a remote — it is not workspace content, and a workspace copied elsewhere must not carry a stale claim about a push that machine never attempted. The workspace alternatives were both worse: .cache/ is swept by keryx reel prune, and the 0040 ledger is git-tracked, so failures would become committed noise.

Commit & push stays synchronous. The response still carries commit/push/media, so nothing about the existing contract or the button's pending state changes. The record is the fallback for when nobody was listening, not a replacement for the answer.

In the UI, opening a reel fetches its record and the bottom bar shows it — but only when it says something worth acting on, and only when there is no live result to show instead. A run that pushed cleanly says nothing: a permanent "last time this worked" badge is noise that teaches people to ignore the spot where failures appear. It renders italic, because it is history rather than news.

Known gap: jobStore — which carries generation, render and post — is still in memory, so those results die on restart. 0052 scopes itself to git on purpose; making the jobs model durable would be its own change.

Commit-on-save + push (R-GIT-3)

After a valid storyboard Save, the studio commits the reel's workspace to the active project's git repo (commit.gointernal/gitrepo → GTB vcs/repo). It is gated on a valid git identity: the effective user.name/user.email (local → global → system, resolved by keryx since go-git's LocalScope doesn't merge global) must both be set, or the commit is skipped — the file is still written, and the PUT storyboard response carries commit {committed, hash, reason} the editor surfaces. For a remote project, a successful commit is pushed (git.auto_push, default on), with push {pushed, reason} shown too ("✓ committed abc + pushed" / "committed — not pushed: …"). Defaults are settable in the Settings panel; a non-git project saves normally. The GitOps seam (commit / push / clone / open-in-memory) is injected — a fake in tests; the git behaviour itself is tested in internal/gitrepo against real temp repos + a bare remote (local and in-memory). For an in-memory project, commit/push run on the held repo (MemRepo) rather than a per-save open; everything else is identical. internal/gitrepo wraps the GTB RepoLike interface, so one wrapper serves both the per-call local *Repo and the held in-memory *ThreadSafeRepo.

Reels-library API (/api/v1/reels, R-UI-24)

A parallel JSON presenter over internal/workspace — the same core the keryx reel CLI uses (spec 0011 §5), so UI and CLI stay in lock-step on disk.

Method · path Workspace op Notes
GET /api/v1/reels List + Status library rows (slug, theme, bundle, status)
POST /api/v1/reels New {slug, theme?, bundle?} → 201; 400/409 on bad/dup slug
DELETE /api/v1/reels/{slug} Remove 204; 404 if absent (the UI confirms)
POST /api/v1/reels/{slug}/rename Rename {to}
POST /api/v1/reels/{slug}/duplicate Duplicate {to} → 201
POST /api/v1/reels/{slug}/link Link {dir} — associate a content dir

Action sub-paths (…/{slug}/rename) rather than the spec's illustrative :rename, because stdlib ServeMux patterns match whole segments. Workspace sentinel errors map to status codes (ErrInvalidSlug→400, ErrExists→409, ErrNotFound→404).

Single-reel editor API (R-UI-½/6, R-API-1)

Method · path Backing Notes
GET /api/v1/workspace/{slug} Load+storyboard+Validate meta, status, storyboard, validation, card_assets (R-UI-16)
PUT /api/v1/workspace/{slug}/storyboard reel.ValidateMarshal 422 + per-card issues on invalid; writes only when valid
GET /api/v1/themes Catalog.ListByType(reel) reel themes + the configured default, for the picker
PUT /api/v1/workspace/{slug}/theme Resolveworkspace.Save switch a reel's theme (R-UI-22); 400 on an unknown theme

Card-list asset status (R-UI-16). card_assets carries, per card, whether a selected illustration (cards/NN.*) and VO take (vo/NN.mp3) exist on disk — the artefacts assembly consumes. The editor card list shows two glyph indicators (▦ illustration · ♪ VO), lit when present. Media also lights live from Card.Media on pick/upload; VO presence is filesystem state the storyboard can't express.

Validation reuses internal/reel.Validate — the same rules the CLI exit-2 and reel build apply (R-WS-9..13) — so the studio's inline checks match the CLI exactly. Errors are per-card ({card, msg}, card -1 = board-level); a malformed JSON body is a board-level error. PUT never writes a board with errors (R-API-1) and persists with the same canonical formatting (reel.Marshal) the CLI uses. The reel theme's palette (from config) drives the R-WS-12 palette-role checks; absent config, those are skipped and the structural rules still apply.

Theme switch (R-UI-22). The editor's theme <select> lists the project's reel themes (from the config catalog, never hardcoded) plus the configured default. Switching PUTs the keyword: keryx validates it Resolves, persists meta.Theme to workspace.yaml (the same file the CLI --theme writes), and returns the workspace re-validated under the new theme's palette — so the editor reflects the new palette-role checks immediately; getWorkspace and putTheme share one buildWorkspace builder. The reel's theme drives generation + validation. Like the other meta/asset writes (source, bundle, cover), a theme switch is not committed immediately — it's staged into the next storyboard Save's commit (switching theme and not saving leaves a persisted-but-uncommitted change). R-UI-22's "existing illustrations were generated under the previous theme — re-roll?" flag is wired via the media work (see Media generation below): a generated Card.Media records its originating theme, and the editor shows a re-roll banner when it no longer matches the reel's theme.

Associated content & assets (R-UI-¾/26)

Method · path Effect
PUT /api/v1/workspace/{slug}/source store pasted seed text → source.md
PUT /api/v1/workspace/{slug}/bundle associate a content dir (workspace.Link)
POST /api/v1/workspace/{slug}/assets multipart kind=covercover.png

The GET workspace payload carries source and has_cover so the editor's associated-content panel renders current state.

Media generation: card · cover · portrait · VO · music (R-UI-29/3/9, specs 0013/0016)

Generate AI illustrations (default Gemini) or upload pre-rendered media for an overlay card, the cover bookend, or an avatar portrait; and generate VO (per card) + the music bed (per reel) via the voice/music providers (default ElevenLabs). Generation is slow and PAID, so unlike every other studio call it runs as an async job: the POST returns 202 {job_id, cost_estimate} and the editor polls the job for live state, cost, and timing, then picks a candidate take into the slot.

All five kinds are one mechanism — theme + inputs → job → candidate takes → gallery/audition → pick into a slot — so the core (gencmd.GenerateInto, FS-explicit so it works on an in-memory worktree, now dispatching image and audio targets), the Generator seam, and the async/cost/serve/list machinery are shared; only input-gathering and the pick effect differ. Cost is priced per axis (Cost.Unit: image · vo · music): images flat per image, VO per 1000 characters of narration, music per second of bed (the VO-driven length, probed via the render seam's BedSeconds), from per-kind config price keys (price_per_image / price_per_1k_chars / price_per_second, spec 0016).

Discarding a bad generation (spec 0041). Generation is a taste loop, and the occasional take is plainly wrong — a garbled VO, an illustration with mangled text. A small in the corner of each take tile (and after Use this on VO rows) bins it. It is deliberately the corner action: the tile's main click still picks.

The interaction is optimistic + undo, not a confirm modal (D1): the tile vanishes at once and an UndoToast counts down from a fixed 15 seconds with an Undo button. Dismissing the toast early commits the discard rather than cancelling it, and so does unmounting — the toast is the guard, so waving it away means "yes, I meant that".

Nothing is destroyed while the window runs. The take is moved aside to .cache/discarded/<rel> (D4), so undo is a rename back and a closed tab loses no bytes — it just leaves the parked copy, which reel prune sweeps (D8, free: prune already removes .cache/). The server runs no timer; the countdown is entirely in the browser, and the fail-safe direction is keep the bytes.

The promoted take is protected (D5) — the control is not rendered for it — because discarding the take in use would leave its slot dangling mid-edit.

Method · path Effect
POST …/workspace/{slug}/takes/discard/{path...} park the take (202)
POST …/workspace/{slug}/takes/restore/{path...} undo (200)
DELETE …/workspace/{slug}/takes/{path...} commit the discard (204)

The take is addressed by its workspace-relative path, so one implementation serves cards, cover, portrait, VO and music (internal/takes: SlotFor maps a take to the slot it occupies when promoted, which is what makes the in-use guard kind-agnostic). 409 is the promoted take, 404 an unknown one, 400 a path that is not a take at all.

"Remove from object storage" means dereference, then reclaim (D2). Under 0040 blobs are content-addressed and shared — the same bytes may back another reel, or be a rollback target in media.log — so discard never deletes a blob directly. It drops the local file (and, for a promoted file, its manifest entry); the blob is then potentially unreferenced and media gc --prune collects it, with the whole-root + ledger checks that make that safe. Settings → Storage carries a Reclaim storage button running that same sweep (D3), so it is reachable without the CLI; it reports what it freed, or says plainly that nothing was unreferenced.

Discard is deliberately absent from the contact sheet: that surface is for visual verification and sequencing — reading the board as a set and judging its order — not for destructive actions. Spot a dud there, select the card, and bin it in the take gallery you were opening anyway to pick its replacement.

Voice character quota (spec 0016). GET /api/v1/quota returns the voice account's {used, limit, remaining, reset_at} — read-only, resolved via the optional provider.QuotaReporter capability (ElevenLabs' subscription API). The VO/music generation panels show it as a "N of M characters left" indicator, and the voice quota CLI prints the same. 503 when no reporter is wired, 422 when the provider tracks no quota, 502 on a credential/upstream failure — the FE hides the widget on any of these.

Spend ledger + Spend & quota panel (spec 0031). Every completed paid generation appends its settled cost to a workspace-owned spend.json ledger (next to takes/ and screen.json) — one event per job, written on finish before the job is marked done. GET /api/v1/workspace/{slug}/spend sums it by unit + total (an un-generated reel is $0). The Produce-right SpendQuotaPanel reads it beside the voice quota, phase-scoped telemetry (0029 §8 D-tel); cost is a cue, not an invoice. Every paid job is captured — per-card VO + illustration, cover, portrait, music, and the Publish AI-compose (a social copy row) — so the total reflects the whole reel's generation spend.

Spend guard (spec 0031 §5; 0001 §Cost-accounting(d)). A pre-generation cap, spend.confirm_above.amount (currency, default 10, for image/music/social) + .characters (voice, default 50000), global + per-project, 0 disables an axis. A request whose estimate crosses the cap is refused with 412 + {axis, estimate}; the client shows a confirm and retries with ?confirm=true (server-enforced, the studio equivalent of the CLI --yes). The CLI keeps its own count-based reel make confirm for now; unifying it onto the same threshold is a fast-follow.

Method · path Effect
GET .../spend reel spend summary from the ledger ({currency, total, by_unit, events}; $0 when un-generated)
POST .../cards/{n}/generate start a card-takes job → 202 {id, cost} (scene from the saved board); 412 {axis, estimate} when over the spend cap → retry ?confirm=true
POST .../cover/generate start a cover-takes job ({scene, theme?})
POST .../portrait/refs multipart — upload a reference photo (image-to-image input)
POST .../portrait/generate start a portrait job from the uploaded refs
GET .../{cards/{n}|cover|portrait}/takes list candidate take paths for the gallery
POST .../{…}/pick select a take into its slot; card pick records Card.Media (with theme)
POST .../cards/{n}/media multipart — upload card media, used as-is (no scene, no theme)
POST .../cards/{n}/vo/generate · …/vo/takes · …/vo/pick VO takes for the card's narration → vo/NN.mp3 (line = card index)
POST .../music/generate · …/music/takes · …/music/pick the reel's music-bed takes → music.mp3
GET .../jobs/{id} poll {state, cost_estimate, cost_actual, eta_ms, elapsed_ms, takes}
GET .../file/{path...} serve a take/selected file by workspace-relative path (traversal-guarded)

Cost is surfaced, not gated (D2). Generation stays on every surface (CLI, studio, MCP) — driving image generation from an assistant is a core keryx use case — and the control is disclosure: a cost estimate up front, the actual on the finished job. Only post/approve/auth (outward-facing, irreversible) stay MCP-gated.

Three-tier persistence (§6.1). The workspace files are the truth (cards/takes/…, the pick in storyboard.json, commit-on-save); the in-memory job store holds live state only (a poll for a forgotten id → 404 → "ask the files"); the browser's localStorage holds the in-flight job id so a refresh re-attaches. N (takes per generation) is configured per project (studio.take_count, default 4), not asked per call.

Theme re-roll (R-UI-22). A generated card take records the reel theme it was made under on Card.Media.theme; when the reel's theme later differs, the editor shows a "made under the old theme — re-roll?" banner. Uploaded media is theme-independent.

Use reel cover. Besides generating or uploading, any overlay card can reuse the reel's cover art as its panel (the natural fill for opening/closing cards) — recorded on the board as media: {kind: image, source: cover, path: cover.png}, no spend, no new file. The render resolves cover.png workspace-relative like any other media path; theme staleness for it is tracked on the cover itself, not per card.

Uncommitted-reel indicator. The rail marks any reel whose workspace carries saved-but-uncommitted changes with a quiet amber dot beside its name (tooltip → "Commit & push"). The reels list computes it with one git status per fetch (GitOps.DirtySlugs, both the local and in-memory backends), and the workspace signals every save/commit so the rail refetches. It only fires when the owning project actually tracks its reel workspaces — a project that gitignores reels/ reports no changes (and its Commit & push is equally inert).

The dot is media-aware too (spec 0039 D4). Git alone can't answer "is this reel safe?", because the media is deliberately git-ignored — a workspace can be spotless in git while holding megabytes of unpushed takes. So the same dot also raises when a workspace's media has drifted from its media.lock: media that is new, re-rolled (content changed under a pinned path), or deleted locally. The comparison is by sha256, not path or size, so a re-roll that happens to produce a same-sized file is still caught.

Hashing a project's media on every list would be far too slow (a real project is hundreds of megabytes), so hashes are memoised on (path, size, mod time) via mediastore.HashCache — an unchanged file costs one stat, and only genuinely rewritten media is re-hashed. When no object store is configured the media half is skipped entirely: a local-only project has nowhere to push, and a permanent dot would be noise rather than information (R-STO-5).

Commit & push syncs media to the object store (spec 0039). When the project configures a storage: backend, the commit action first pushes the workspace's changed media (incremental by hash) and re-pins media.lock, so the manifest rides in the same commit as the board; the commit note reports what moved ("… · 3 media pushed"). Unconfigured storage skips the sync with a reason and never blocks the commit (R-STO-5); a real sync failure is surfaced the same way. The seam is Deps.Storage (StorageResolver) — tests fake the store.

Workspaces hydrate on open (0039 D3). Opening a reel fires POST …/media/pull: every media.lock entry missing locally is fetched at its pinned version ("fetching media…" in the bottom bar; previews cache-bust when files arrive). Local edits are never overwritten, and a failed or unconfigured hydrate never blocks the open — the studio carries on with what's on disk.

Contact sheet — the text-leak screen (R-UI-10, + OCR flags S2). Living in the Produce right column (the "make" surface, spec 0029 §8 / D7), it renders a live, whole-board grid of every card's selected illustration, so text leaks (words the image model rendered into the art) are caught at a glance before rendering — the web-native equivalent of the CLI cards sheet. Cards missing an illustration are flagged (a dashed tile + a count); clicking any tile selects that card so its media (VO + illustration) opens in the adjacent CardMediaPanel to re-roll.

On top of the human scan, the sheet overlays the OCR verdicts that cards screen (R-GEN-26) computes: a flagged card gets an amber ⚠ ribbon naming the detected glyphs, plus a "· N flagged" count — assistive, the eye stays the final judge. Two endpoints back it (ContactSheet.svelte):

  • GET …/cards/screens reads the cached verdict for each card's selected illustration — a pure screen.json ledger read, no vision provider and no spend — loaded when the Sheet opens. Cards never screened come back unscreened, so the sheet stays the no-provider fallback.
  • POST …/cards/screen (the Screen now button) OCRs the selected illustrations on demand via the configured vision provider (providers.image), caching verdicts by content hash (shared with the CLI + the auto-pick). It reports 503 when no screener is wired and 422 with a clear message when no vision provider is configured.

Render & preview (R-UI-8/11, spec 0017)

POST /api/v1/workspace/{slug}/render {silent?, theme?} renders the saved storyboard to an mp4 and returns 202 {id}; the editor polls the shared job (elapsed_ms, then output) and plays the result in a <video> via GET …/file/{output}. Silent (R-UI-8) is a fast draft — storyboard timing, no audio deps — written to reel-<slug>-draft.mp4; full (R-UI-11) is VO-driven timing + the music bed, reel-<slug>.mp4 (distinct files so a draft never clobbers a good render).

Render is a long, FREE, local job: no API spend (so no cost cue — money is for paid generation only), one render per workspace at a time (a second request → 409). It runs through a per-project Renderer seam wrapping build.RenderInto (the render core extracted from reel build), resolving the renderer + theme against the active project's config.

Cancel (S4, spec 0017 §7). No queue — instead POST …/render/cancel aborts the in-flight render (cancels its context → kills the ffmpeg exec; 204, or 404 when nothing is rendering). The render finishes as a cancelled failure ("render cancelled") and frees the slot; the RenderPanel shows a Cancel button while rendering. Cancel + restart is the studio's concurrency model (renders are minutes-long — a queue would rarely be what a single user wants).

Live progress (spec 0026). The job carries a percent (0..100) fed from the render backend, and both backends stream it: the native ffmpeg backend parses -progress, and the in-memory afmpeg backend reads NDJSON progress records the ffmpeg-wasi engine writes. RenderPanel shows NN% · Ns. A negative percent means "cannot determine completion" rather than 0% — the caller has to be able to tell a stuck bar from an unknowable one.

Render works on any project, local or in-memory. The earlier local-only gate is gone: the render core reads inputs from, and writes the mp4 into, an afero.Fs. The afmpeg backend renders any filesystem natively; the shell-out ffmpeg backend needs real files, so it materialises a non-OS filesystem into a temp directory and copies the result back. The mp4 is streamed via the afero.File ReadSeeker (range requests → <video> scrubbing without buffering the whole file).

Exposing the studio: the auth gate (R-API-3, spec 0018)

By default the studio binds localhost and is open (single-user dev). Bind it beyond localhost — keryx studio --host 0.0.0.0 to reach it from a phone or another machine — and an exposure gate engages: a random token is minted at startup (crypto/rand, never persisted, rotates per run) and printed in the listen URL (http://host:port/?token=…, Jupyter-style). Open that URL and the gate turns the token into an HttpOnly, SameSite=Strict session cookie; the browser then sends it on every request — fetch and the <img>/<audio>/<video> src loads that can't carry an Authorization header — so the whole cockpit (media included) works while the data surface stays gated.

The gate is GTB's AuthMiddleware with both a bearer verifier (for API/MCP clients) and a cookie verifier (WithCookieVerifier, contributed to GTB v0.24.0 for exactly this browser case); /api/* requires a valid credential, the SPA shell + /healthz stay public so the page can load and capture the token. A bind that can't build the gate fails closed — the server refuses to serve rather than expose ungated.

Known limitation: the cookie is not Secure — the studio is http on a trusted LAN, so the token is visible to a LAN MITM. This is acceptable for a trusted-LAN dev tool; the https path is future work (a phpboyscout trusted-CA). Don't expose the studio on an untrusted network.

Chat (R-UI-5, R-API-⅖/6)

POST /api/v1/workspace/{slug}/chat takes {message, storyboard} (the live working board) and streams SSE: token events (the assistant's prose), then a patch event — a whole-board proposal {base, summary, storyboard} — then done. The endpoint never writes the storyboard (R-API-2). The client (a fetch-ReadableStream reader, since EventSource can't POST) shows the prose and a card-by-card diff; accept rebases (the working board must still match base, else re-ask) and applies the board into the editor — the user then Saves, which re-validates via PUT. Per-op cherry-pick is done client-side (no server ops): when the proposal keeps the card count, each changed card carries an accept/reject toggle and web/src/lib/proposal.js (materializeSelection) assembles the applied board from the selection — proposed card at accepted indices, working card elsewhere — so it re-validates identically and can't mis-apply a stale op. Structural add/remove proposals apply whole-board (spec 0011 §4, 0002 R-API-5).

The LLM mechanics sit behind a narrow Chatter seam (live: stream via the GTB chat client, then ask for the board), so the endpoint is tested with a fake — no live provider. With no provider configured the endpoint returns 503.

Project settings (R-UI-30, R-CFG-2/4)

GET·PUT /api/v1/config read/write the project's non-secret .keryx.yaml. The headless token fallback can write secrets into the config file, so this is guarded by an explicit allowlist of non-secret dotted keys (providers, platform enablement + non-secret identifiers, theme defaults, backend selections) — never a denylist. GET returns only allowlisted keys (a secret can't leak); PUT rejects any key outside the allowlist (a request can't smuggle a secret or unknown key into the config). Secrets stay in the env/keychain. The settings panel renders the returned keys grouped by section (Providers · Storage · Version control · Socials · Auth, plus Themes/Avatars/Advanced).

The Storage tab (spec 0039) edits storage.provider, storage.prefix, and storage.s3.{bucket,region,endpoint} — the object-store config, all non-secret (credentials resolve via the provider chain). It adds a Verify button (POST /api/v1/storage/verify → the backend's Store.Verify) that confirms the bucket is reachable with versioning enabled and shows the result inline; Verify is disabled while unsaved so it always probes what's on disk.

Per-project config & themes (R-CFG-1, spec 0014)

props.Config is the global layer and is never mutated. The studio holds a map[projectKey]*projectConfig — each entry a self-contained Containable (plus its theme catalog) built from that project's repo-root .keryx.yaml deep-merged over an allowlist of inherited global subtrees (themes, providers, workspace, git, studio, voicesprojectconfig.go). Switching is a map lookup; a.config() / a.themesCatalog() return the active project's entry (or global when it has no .keryx.yaml) under a.mu, so every config/theme read is per-project and concurrency-safe (this also removed a latent a.themes read/write race). platforms.* / auth.* are never inherited from global — they carry secret fallbacks + account/infra wiring, so they can't leak into a project container.

GET /config shows the active project's effective config (allowlist-filtered, so a secret in a project file is still never shown); PUT /config writes only the submitted keys to the active project's .keryx.yaml (created if absent; the in-memory worktree for an in-memory project), then rebuilds that project's config + themes. A malformed existing project file is a 422 (never clobbered, R-CFG-4).

Hot-reload (R-CFG-3). A local project's .keryx.yaml is watched on disk (projectwatch.go, fsnotify on the project dir); an external edit debounces then rebuilds that project's cached config + themes — the live propagation the CLI gets. Only the OS filesystem is watchable, so in-memory worktrees fall back to load-on-switch. Watchers are stopped on forget. Inherited global subtrees are deep-copied into each project container (viper's merge mutates in place — sharing would corrupt global + sibling projects).

Server

A stdlib http.Server bound with its own net.ListenConfig listener so it can default to localhost (R-API-3) and honour --host/--port (port 0 = ephemeral). It reuses GTB's pkg/http.MaxBytesMiddleware to bound request bodies. GTB's pkg/http.NewServer was evaluated but binds all interfaces with TLS on — wrong for a localhost plain-http dev UI — so it isn't used for the listener (spec 0011 §11.1). Shutdown drains gracefully on context cancel.

Embedding & the UI bundle

//go:embed all:web/embed embeds the built SPA. The directory always contains a committed placeholder.html, so the embed compiles even with no Node build; the SPA handler serves the real index.html when present, else the placeholder ("install a release for the full UI"). Unknown paths resolve to the app shell so client-side routes work.

The bundle is built by go generate (scripts/build-web.sh), which runs as part of just build / just ci and goreleaser's before hook. It is graceful: no npm → it skips and leaves the placeholder, so Node-less builds and CI stay green. The built bundle is gitignored (only the placeholder is committed); a go install therefore serves the placeholder.

Frontend

Plain Svelte 5 (runes) + Vite SPA (no SvelteKit), mounted into #app. A thin lib/api.js fetch wrapper talks to /api/v1 (throws on non-2xx, surfacing the {error} body). vite.config.js proxies /api + /healthz to a local keryx studio --port 8765 for HMR development (just web-dev).

Responsive layout (R-UI-20/21). Each phase view is mobile-first: the base CSS is a single column showing one pane at a time with a sticky bottom tab bar (≥44px touch targets) to switch between them. Author (AuthorView.svelte, the "shape" surface) lays out cards · card inspector (fields only) · chat · associated; Produce (ProduceView.svelte, the "make" surface) lays out stage (card grid + 9:16 preview hero) · media (the selected card's CardMediaPanel) · contact sheet · spend. Publish (PublishView.svelte, the "ship" surface) lays out the composer (PlatformCopy) · connections · ledger. Chat is a first-class tab (the primary mobile authoring surface). All three views share the TabBar.svelte mobile switcher. At ≥880px a media query switches to the desktop grid — Author is a three-pane grid (card list · inspector · chat, with associated content full-width beneath) with the card list and chat panes collapsible (pane-toggles, persisted in localStorage); Produce and Publish are stage/composer beside a stacked right column (Produce: CardMediaPanel · ContactSheet · SpendQuotaPanel; Publish: ConnectionsPanel · LedgerPanel). The top bars (App shell + editor) flex-wrap and compact on narrow screens, with the long reel slug + project name truncated and non-essential status hidden, so nothing scrolls horizontally at ~400px; reorder/row touch targets are ≥44px on mobile. Cards reorder (R-UI-14) by drag-and-drop (a desktop grip handle, native HTML5 DnD — no dependency) or the keyboard/touch up·down controls (the accessible path; the grip is hidden on touch); either updates the card order in storyboard.json on Save. The library/settings views are vertical lists that already reflow. The secondary controls (project switcher + settings in the topbar, the reel theme picker in the editor) collapse behind a ⋯ overflow menu on phones (OverflowMenu.svelte, R-UI-20/30): inline on desktop, a tap-to-open dropdown at ≤600px — a pure-CSS switch between the two layouts. Theming (spec 0029 §5) is token-driven: light is the :root default, dark applies under prefers-color-scheme and an explicit data-theme="dark" (the in-chrome toggle beats the OS preference in both directions), applied before mount so there's no flash. The frontend has a full test harness: vitest (component + lib unit tests, jsdom) and Playwright (the built SPA in a real browser, API mocked) — see Testing.

Publish cockpit: compose · AI-compose · approve (R-UI-19/25/27, spec 0019)

The Publish phase (spec 0029 §8) is decomposed onto three surfaces — all bound to one shared PublishSession owned by PublishView: PlatformCopy (centre — the composer + approve + post), ConnectionsPanel (right — token health), and LedgerPanel (right — posted-state record). PlatformCopy surfaces per-platform social composition over the shared social.json ledger: each platform (Instagram · YouTube · TikTok · LinkedIn) has a composer — copy / hashtags / link / title (where it has one) — with live constraint warnings and the hard, approval-blocking violations, a status badge (draft → approved → posted), and Save / AI compose / Approve actions.

Method · path Effect
GET …/social the per-platform set + warnings/violations + a revision token
PUT …/social/{platform} compose/edit a platform's copy {rev, text, hashtags, link, title, scheduled_at}
POST …/social/{platform}/approve draft→approved {rev}refused (422) on a hard violation (R-POST-10)
POST …/social/{platform}/gen AI-compose a draft (R-SOC-4) — async job (202 + cost cue); poll, then re-GET
POST …/social/post post now the approved platforms {platforms?} (R-UI-27) — async job; poll, then re-GET
GET /api/v1/connections read-only per-platform token health (D6) — global, no secrets
POST /api/v1/connections/check live token probe (dry-run refresh, S5) — merges live health, no token stored

Three behaviours come from the shared internal/social core (CLI + studio both get them): R-SOC-8 — editing an approved/posted variant's content drops it back to draft (so a posted variant can't drift silently); R-SOC-5 — the per-platform constraints are config-resolved (platforms.<p>.constraints.*, the built-in values as defaults), tunable without a rebuild; and an optimistic-concurrency guard (D7) — every mutation carries the revision token (a content hash of social.json) and a write whose token is stale gets a 409 ("reload — it changed"), so a studio edit can't clobber a concurrent CLI post.

AI composer (R-SOC-4). The AI compose button drafts a platform's copy from the reel's storyboard as an async job (it calls the chat provider, so it reuses the media job harness + a cost cue). It's behind the SocialComposer seam (compose.go, live via the shared chat-client factory → socialcmd.GenInto; faked in tests — no spend). A fresh AI draft is always a draft (never carries a prior approval). With no chat provider configured the endpoint returns 503.

Caption file (R-CAP-4). On every social save (CLI social set/gen and the studio), when the reel has a linked bundle, keryx writes a human-readable reel-caption.md into that bundle — a first-class, committed deliverable of per-platform title / text / hashtags / link. No linked bundle → skipped (not an error). Rendered by the shared social.WriteCaption; the studio path is best-effort (a caption write never fails the social save).

Post-now (R-UI-27) — the one irreversible action. The Post now button posts the approved platforms behind an explicit named confirm dialog (which platforms · "cannot be undone"), as an async job over the Poster seam (poster.gopostcmd.NewFor against the active project; faked in tests — no real posting in CI). Defence-in-depth (§6): it's human-initiated only, the approved-gate lives in postcmd (a non-approved platform is refused — the same gate the CLI post enforces), it's reachable only through the 2D-gated /api (localhost open; a bearer/cookie when exposed), and it's never on MCP. Per-platform results come back on the job; the pane reloads /social for the posted badges + view-post links. Posting is not a file-generating job, so it bypasses the take-basenaming job runner. With no poster wired the endpoint returns 503.

Connections (D6) — token health. ConnectionsPanel on the Publish-right shows each platform's enabled flag + a health dot (connected · expiring · expired · unknown) read from the non-secret expiry metadata already in config (platforms.<p>.*_expires_at; YouTube tracks none → unknown) — metadata-only, no network. A dead/expiring token is flagged before a publish fails.

Live "check now" (S5). POST /api/v1/connections/check runs a live probe — a dry-run refresh of each enabled platform (the same mechanism as the CLI auth refresh --dry-run, reused in-process via refreshcmd) — and merges the verdict (a ringed dot + a detail tooltip, checked=true) over the static view. It reads tokens to probe them, like the CLI, but never stores or exposes them — only the health verdict is returned; token capture still stays the CLI's MCP-gated auth (R-CFG-2's spirit). 503 when no checker is wired; 502 when the probe can't run. Injected as the ConnectionChecker seam (faked in tests — no network, no token access).

Build & develop

just web-build   # build the Svelte bundle (skips without Node)
just web-dev     # Vite HMR; run `keryx studio --port 8765` alongside
just build       # go build — runs go generate (incl. the web build) first

Testing

The server (/healthz, localhost bind, graceful shutdown), the SPA-fallback logic (against a synthetic FS, so it's deterministic regardless of whether the bundle is built), and every API surface are unit-tested (pkg/studio/*_test.go). The API tests drive the mux over an in-memory afero FS and assert the same on-disk outcomes the CLI produces (e.g. a POST /reels is visible to workspace.List; an invalid PUT storyboard → 422 with the file unchanged; chat never writes the board; GET /config never returns a secret).

A cross-process godog harness (features/studio.feature, test/e2e/steps/world_studio_test.go) starts a real keryx studio server in the scenario's project dir and drives it over /api/v1 alongside the CLI — proving genuine parity: a reel created via the API is visible to keryx reel list, an invalid storyboard PUT is rejected and leaves the file unchanged, and a delete propagates. Env-gated like the rest of the e2e suite (INT_TEST=1).

The frontend (pkg/studio/web) has its own harness: vitest unit-tests the components and lib/ state machines against jsdom (the api client, reelSession/ produceSession/publishSession/mediaJob, the pure reorder/flags, and each panel), and Playwright drives the built SPA in a real browser with the JSON API mocked (e2e/*.spec.js) — asserting the phase routing, the shape→make split, drag reorder, and the media/spend/publish surfaces render. Run with npm test / npm run e2e in pkg/studio/web (also covered by just ci).