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 (spec0028§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, spec0031). - 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, spec0031) refuses over-cap generation pending confirm. Real light/dark theming, a sharedTabBarmobile 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 (spec0014), responsive + overflow menu, and the LAN-exposure auth gate (spec0018).
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 OpenInMemory → memfs, 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.go → internal/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.Validate→Marshal |
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 |
Resolve→workspace.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=cover → cover.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/screensreads the cached verdict for each card's selected illustration — a purescreen.jsonledger 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 reports503when no screener is wired and422with 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, voices — projectconfig.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.go → postcmd.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).