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: step 6 — settings (Phase 1 v1 complete). Adds the project settings panel (R-UI-30): edit non-secret .keryx.yaml settings (providers, platform enablement, defaults) — secrets never appear. With the library, editor, assets, and chat, the v1 "author & adjust" surface is complete.

Architecture

pkg/cmd/studio/      cobra command `keryx studio` (MCP-gated, --port/--host)
pkg/studio/
  server.go          stdlib http.Server, localhost bind, graceful shutdown
  mux.go             ServeMux: /healthz, /api/v1, SPA catch-all
  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

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
PUT /api/v1/workspace/{slug}/storyboard reel.ValidateMarshal 422 + per-card issues on invalid; writes only when valid

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.

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. Per-card overlay media (generate-or-upload, R-UI-29) couples with the generation work and lands there.

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. v1 accepts the proposal as a unit; per-op patches are a future enhancement (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.

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).

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).