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 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. 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.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 — 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=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).
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/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 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, 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). 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.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. 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).