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.yamlsettings (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
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)¶
GET (list + active), POST (add a local dir), POST …/switch, POST …/forget.
The known projects live in a user-scoped studio.yaml registry (registry.go,
spec §2.1) — 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()); config + themes are user-global today, so they don't change on switch
(per-project config is the fast-follow tied to "config in the project repo", spec
§2.2). forget only drops a registry entry — it never deletes files. Commit-on-save
(R-GIT-3) and remote-git projects (R-GIT-2/5) are the tracked fast-follows.
Commit-on-save (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, go-git). It is
gated on a valid git identity: the effective user.name/user.email
(local→global→system) must both be set, or the commit is skipped — the file is
still written, and the PUT storyboard response carries a commit
{committed, hash, reason} the editor surfaces ("✓ committed abc12345" or "saved
— not committed: set git identity"). Default on (git.commit_on_save, settable in
the Settings panel); a non-git project reports "not a git repository" and saves
normally. The Committer is injected (a fake in tests; the git behaviour itself is
tested in internal/gitrepo). Remote push is the R-GIT-2/5 fast-follow.
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.Validate→Marshal |
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=cover → cover.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).