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: Phase 1 (author & adjust) — SIGNED OFF (2026-06-26). Every MUST and SHOULD is implemented: library, mode-adaptive editor + validation, associated content + uploads, chat (whole-board propose/accept), settings, project switcher, commit-on-save + remote git (token auth, local-clone + in-memory), per-card/cover/ portrait media generation (spec 0013), theme picker + re-roll, card-list asset status (R-UI-16), responsive + overflow menu (R-UI-20/30), dark mode, and per-project config + themes with hot-reload (spec 0014).

Below-SHOULD backlog — cleared (2026-07-04). All three deferred items landed: frontend tests (vitest harness: the api client's SSE chat/patch dispatch + error surfacing, and the Svelte component path); remote-git clone branch selection (single-branch clone) + SSH auth via forge config (how-to, R-GIT-4); and per-op chat patch cherry-pick (R-UI-15) — client-side, a per-card selection over the working/proposed boards, no server ops. Next: Phase 2 — preview & produce (in-browser preview, takes audition, full render, the Publish panel, and LAN-exposure auth).

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 + 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, 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()) 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. A runTakeSweeper goroutine (started by Server.Run, stopped on shutdown) 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).

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 — spec §6) 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).

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.

Method · path Effect
POST .../cards/{n}/generate start a card-takes job → 202 {id, cost} (scene from the saved board)
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.

Contact sheet — the text-leak screen (R-UI-10, + OCR flags S2). A distinct Sheet tab 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 jumps to that card's editor 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 when it can stream progress — the native ffmpeg backend parses -progress; RenderPanel shows NN% · Ns. The in-memory afmpeg backend buffers its output, so it reports no percentage and the panel shows elapsed-only — a graceful degrade until afmpeg gains a streaming stderr (afmpeg#1).

Render is LOCAL-ONLY (spec 0015 D1, confirmed by the #68 ffmpeg-binding spike): it shells out to the ffmpeg binary over real OS files, so an in-memory project responds 409 with "switch to a local checkout to render". The eventual in-memory path is the sibling afmpeg project. 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.

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). The editor is mobile-first: the base CSS is a single column showing one pane at a time (Editor.svelte regions: cards · editor · chat · assets) with a sticky bottom tab bar (≥44px touch targets) to switch between them — chat is a first-class tab (the primary mobile authoring surface). At ≥880px a media query switches to the desktop three-pane grid (card list · editor · chat, with associated content full-width beneath); the card list and chat panes collapse via the pane-toggles controls and the collapsed state persists in localStorage. 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. Dark mode (R-UI / nice-to-have) follows prefers-color-scheme via the CSS custom properties. (No API surface — pure layout; the bundle build is the gate, there's no frontend test harness.)

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

The Publish pane 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. A strip at the top of the pane 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).