π¨ Studio¶
Studio is the local web cockpit: a live run feed, the kanban board, a pending-change review UI, a run trace, and editors for the pipeline, agents, skills and markdown memory. It ships as a Vite + React + TypeScript SPA embedded in the binary β no CDN, no network at runtime.
slmcode studio # β http://127.0.0.1:7420/?t=<token>, opens a browser
slmcode studio --listen :9000 # custom address
slmcode studio --kill # terminate an existing slmcode holding the port
slmcode studio --no-port-auto # fail instead of moving to a free port
slmcode studio --dev-cors # allow the Vite dev server (npm run dev in web/)
slmcode studio --no-auth # drop the session token (loopback enforcement stays)
The printed URL carries the session token. Open that URL, not a bare http://127.0.0.1:7420 β the HTML shell is authenticated too, so an untokenised navigation gets a 401 page telling you to go back to the terminal, and every /api/* call gets a bare 401. The CLI states which mode it is in (auth: session token required (the URL above carries it), or a warning that auth is disabled). See Security model for the cookie the token mints and for an honest account of what it does and does not protect against.
Ctrl+C shuts down gracefully: an in-flight run unwinds and every SSE stream closes, rather than responses being truncated mid-write.
If the configured port is busy, Studio moves to the next free one and says so. Killing whatever holds the port is never automatic: --kill only ever signals a process whose executable is exactly slmcode.
The listen address comes from listen in config (127.0.0.1:7420 by default) unless --listen overrides it.
Building the UI¶
Studio's front end is a React 18 + Vite + TypeScript SPA in web/. make ui-react builds it and copies web/dist/* into cmd/slmcode/ui/, which the binary embeds with //go:embed all:ui. Released binaries ship it already built; a binary you built yourself does not have it until you run:
Without that, everything still works except the web page: slmcode studio starts, serves the API, prints its tokenised URL β and warns on startup that the UI is not built. The page you get says the same and gives the command. That placeholder is compiled into the server (pkg/server), not checked into cmd/slmcode/ui/: the only tracked file there is .gitkeep, so building the UI never dirties a tracked file, and go:embed still has something to embed on a fresh clone.
web/package-lock.json is out of date
web/package.json gained vitest, @testing-library/* and eslint; the lock predates them, so npm ci refuses to run. make bootstrap reports this and falls back to npm install, which regenerates web/package-lock.json β commit the regenerated lock. See Troubleshooting.
For UI work: cd web && npm run dev (Vite dev server), then make ui-react to fold it back into the binary. make web-check runs the SPA's lint, typecheck, tests and build.
Pages¶
| Route | Page | What it does |
|---|---|---|
/ | Live | The run as a place: the team floor (each team at its table with its manager, members, monitors and tickets; contract conduits between tables; a spark when a manager moves a ticket), the phase journey above it, a ticker of what is happening right now, and one activity column beside it β the log by default, with tasks, fixes, files and the result as filters |
/board | Board | Kanban β add, edit, delete, move, delegate, drag mid-run |
/review | Review | Pending changes from permission: review, as diffs, with per-file apply/reject |
/runs | Runs | Run history, and a per-run trace with per-phase wall time and token/cost attribution |
/pipeline | Pipeline | Edit the phase graph, bind agents to phases, insert slots, configure the execute loop |
/agents | Agents | Create/edit/delete custom specialists with a full prompt editor |
/teams | Teams | The team library β build teams from existing agents and give each a project manager, send a request to teams and see who would work on it and how, edit the org chart and its frozen contract, and read how the managers and teams collaborated on a run |
/blocks | Blocks | Browse and apply pipeline / agent / quality / pack / team blocks |
/files | Files | Workspace tree browser, read-only, with diff against the last checkpoint |
/skills | Skills | Manage SKILL.md packs |
/docs/:id | Docs | Split-pane markdown editor for CONTEXT / PLAN / TASKS / SCRATCH / MEMORY |
/settings | Settings | Provider, model, stacks, packs, HITL modes, parallelism, MCP, API keys |
A global HITL modal surfaces clarify / plan-approve / continue / escalate / shell gates from any page β you no longer have to be on the Live view to answer one. It traps Tab and answers Esc by returning focus to the decision (a gate cannot be dismissed β the harness is waiting). A connection badge shows stream health, and an error boundary keeps one broken panel from blanking the app.
When a task gets stuck¶
A blocked or failed card tells its story instead of showing one error line: the Attempts timeline (attempt_log β "attempt 2 failed because β¦"), the gate-retry count, the review verdict, and the structured criteria checklist. Under it, a next-step row:
| Action | What it does |
|---|---|
| Send back to Ready | PATCH /api/tasks/{id} β column ready_to_dev; the next wave picks it up |
| Retry | POST /api/tasks/{id}/retry β same, keeping the attempt log so the next try knows what failed (falls back to the Ready move on a server without the endpoint; 409 means no board is loaded) |
| Reassign team | opens the edit form on the team control |
| Steer | prefills the live-feedback composer with @task:ID β from the board it navigates to Live first |
The same row appears in the Live task panel, whose Blocked / Failed counters are filters over the list. The panel accepts a focusTaskId prop (and Live reads /?task=ID) to expand, scroll to and flash one task.
When a run ends¶
The result panel's What next row offers whichever of these apply: Resume interrupted run, Open blocked on Board (/board?column=blocked), Review N pending, and Run again with this prompt. The app also fires a toast, flashes the tab title while the tab is hidden, sends a browser notification when the tab is hidden and permission was already granted (Studio never asks for it on its own), and throws a two-second confetti burst on a green run β not under prefers-reduced-motion.
Keyboard¶
| Keys | Does |
|---|---|
? | shortcut sheet |
βK / Ctrl+K | command palette β pages, tasks by id or title, agents, teams, recent runs, theme |
/ | focus the run prompt |
ββ΅ / Ctrl+Enter | run the prompt, while it is focused |
β. / Ctrl+. | stop the active run, while the prompt is focused |
g then l b r p a t k f s h , | go to Live, Board, Review, Pipeline, Agents, Teams, Blocks, Files, Skills, Runs, Settings |
Esc | close a dialog, the sheet or the palette |
Plain-key shortcuts are inert while typing in a field; the modifier ones are not.
The Live floor¶
The centre of the Live view is a 3D floor, drawn with three.js: one round table per team on its own rug, the manager at the head with the team's board on the wall behind them (a column per state, a tile per ticket, the counts above), the members around the table, each with a monitor that lights up when the log says that agent is working and a pop-out over their head naming the ticket. Tickets lie on the table, colored by state, and a thread runs from each one to the monitor of whoever holds it, with beads travelling along it while they type. The frozen contract runs between tables as conduits with packets flowing from the provider to the consumer; a consumer waiting on a clause it has not been given turns its conduit amber. A gate paints the table's rim green or red.
Whoever is working is unmistakable: their monitor lights up, lines of code rise off the screen, a pool of light and a breathing ring mark the seat, and the pop-out over their head says which ticket and what they just said. Several people can be at it at once β one per ticket in flight β and idle people glance at whoever is. The pipeline's own people β the planner, splitter, architect, explorer and the rest, who sit at no table β stand on a stage at the left under a screen naming the phase the run is in and what is being said; the one speaking is lit, the rest wait in the wings until their phase.
The floor moves when the run does. A manager sending someone onto a ticket is an arc from the head seat with the ticket's name; a ticket moving from one person to another is a spark across the table with the reason; a ticket that appears drops onto the table, one that finishes or fails bursts; idle people glance at whoever is working. Every change also slides in as a card in the feed at the right edge β T4 appeared on Backend, go-worker started on T2, T2 done β, Backend: gate green β for twenty seconds, and each card is a link.
Everything is clickable. A person opens their dossier: seat and team, who manages them (or, for a manager, who they manage and what they do), whether they are working right now and on what, the last thing they said, the tickets they hold and have touched, and their recent lines from the log. A ticket opens its own: state, holder, everyone who has touched it, any handoff, and open in Tasks. Every name in a dossier is a link to the next one, so the floor can be explored by clicking around; the camera glides to whatever is selected. Drag to orbit, wheel to zoom, right-drag to pan, click a table to frame it, follow to keep whoever is working in the middle, spin for a slow tour, Esc or a click on empty floor to clear.
A run with no org chart shows the pipeline crew β the planner, splitter, worker, reviewer and tester the composition chose β at one table, and a single library team staffing a run shows as that team, with its manager, board and gate. A seat the team left empty and the pipeline fills (a tester on a team that names none) sits at the table too, drawn as borrowed and labelled from the pipeline.
Where WebGL is unavailable, or on request (the 3D / map toggle), the same floor is drawn as a flat map with the same dossier and feed. Both honor prefers-reduced-motion. On a touch device with no stored preference the map is the default; below the sm breakpoint the toggle sits in a strip under the floor and the feed opens from it as a bottom sheet. Every person and ticket in the 3D scene is also a visually hidden button, so the floor can be walked with a keyboard: a dossier takes focus when it opens and gives it back when it closes, and Esc closes it unless it was typed into a field.
The scene keeps its state between visits. The selection lives in the URL β /?task=T4 opens a ticket's dossier, /?agent=go-worker a person's, with an optional &team= β so a dossier survives navigating away, a reload, and being pasted to someone. The camera, follow, spin and the pulse feed live in a module-level store (mirrored to sessionStorage), so coming back from the Board finds the floor where it was left; reset view restores the camera through OrbitControls rather than remounting the scene. The render loop runs continuously only while something moves (a run, live tickets, fresh pulses, follow, spin, a camera glide), drops to on-demand when the floor is still, and stops while the tab is hidden. If the browser loses the WebGL context the page falls back to the flat map and says so.
One board, one stream¶
Every page that shows tasks reads the same board store (web/src/hooks/ useBoardStore.ts, provided from App): the Board, the Live floor, the ticker and the Teams page. It is seeded once from GET /api/tasks and GET /api/squads, then kept current by the live stream, and re-read every 30 s as a safety net, on both edges of a run, and when the stream reconnects. Before the server's first push, a structural log line (a task starting or finishing, a run starting or ending) also triggers one debounced re-read, so a server without the events below still feels live. Pages call refresh() after a write and upsertTask() for the optimistic paint. Nothing polls on its own timer any more; the Sidebar's review badge comes from the stream too.
Two SSE kinds are board events: they are folded into the store and never become rows in the log, since one row per column move would bury the story the log tells with task_start / task_done:
| Kind | Payload | Effect |
|---|---|---|
task_update | task_id, data.task β the task exactly as GET /api/tasks returns it | replaced by id in the store (appended if new); marks the store live |
review_pending | data.pending β the review queue's length | updates the Review badge at once |
Their sequence ids still advance the reconnect cursor, and a replayed one is dropped like any other duplicate.
The stream also keeps a running derivation of the log (web/src/hooks/ runDerived.ts): phases seen, the active phase and agent, task ids, files written, token and cost totals, the newest composition β folded once per event as it arrives rather than re-scanned by each panel on every flush. The ticker's clock keys on the floor model's working set (an agent between its agent_start and agent_end), so it stops when nobody is working.
Links between pages¶
Identifiers are chips that go somewhere (web/src/components/shared/ EntityLink.tsx):
| Kind | Goes to |
|---|---|
| task | /?task=ID β the ticket's dossier on the floor, and the Tasks rail |
| agent | /?agent=ID β the person's dossier |
| team | /teams?team=ID |
| run | /runs?run=ID |
| file | /files?file=path |
The Board keeps its own filters in the URL as well: ?team=ID (or the unassigned lane) and ?column=ID.
The review workflow¶
Set permission: review and agent writes stop being writes β they become proposals.
Each proposal is a .slmcode/pending/<nano>_<kind>_<mangled-path>.patch.json holding {path, kind, content} plus, when the workspace knew them at record time, from (the source of a ws_mv), task_id, agent and query_id. Studio's Review page lists them with both sides of the diff and per-file apply/reject; the same queue is available from the terminal with slmcode apply and slmcode reject.
Apply honors the kind. write/edit/patch write the content; delete removes the file; mv moves the source to the destination (falling back to writing the recorded content if the source is gone). Stale shell entries from older builds are never files: they are hidden from the listing, skipped by {all: true} and refused by id.
| Endpoint | Purpose |
|---|---|
GET /api/review/pending?hunks=1&context=3 | list pending changes, optionally with hunks. Items carry kind, from, task_id, agent, query_id (empty when unknown) |
GET /api/review/pending/{id} | one change with its diff (a delete diffs to empty; an mv diffs source β content at the destination path) |
POST /api/review/apply | {ids} / {id} / {all: true} β emits review_pending |
POST /api/review/reject | same shape β emits review_pending |
The queue id is a bare file name and is validated as one β a traversal attempt is rejected rather than resolved.
Board, tasks and history¶
| Endpoint | Purpose |
|---|---|
GET /api/tasks | the live board; each task includes attempt_log, gate_retries and criteria when set |
POST /api/tasks/{id}/retry | move a task back to ready_to_dev, reset retries, clear error, append "retried from Studio" to attempt_log, persist, emit task_update. Returns the task. 409 when no board is loaded, 404 for an unknown id |
GET /api/queries | run history; every item also carries duration_ms, tokens, cost_usd, tasks_total, tasks_done, failed_tasks and teams (string list), computed from the stored turn, board and event log (usage totals are cached per log size/mtime) |
GET /api/shell/pending | {pending, ask, asks: [...], count} β ask is the oldest open shell ask (the shape the UI was built on); asks lists every open one, since a parallel wave can raise several |
POST /api/shell/approve | {ask_id, decision: "approve" \| "deny"} β ask_id picks which pending ask; it may be omitted only when exactly one is pending |
POST /api/runs/stop | asks the run to unwind. running stays true (with stopping: true in /api/runs/latest and /api/status) until the run goroutine has exited, so a new run cannot start on top of the old one's teardown |
Live events (SSE)¶
GET /api/events is a long-lived Server-Sent Events stream.
- Every event carries a monotonic id. A reconnecting
EventSourcesendsLast-Event-IDautomatically (or you can pass?last_event_id=), and only receives what it missed. - The replay ring buffers 1500 events. Token-delta events are evicted first, so a long streaming response cannot push the structural timeline out of the buffer.
- When events genuinely could not be replayed, an explicit
event: gapframe is emitted with{from, to}, so the UI can say "events NβM were dropped" instead of quietly showing an incomplete run. A slow consumer is flagged rather than silently dropped. - Kind
run_end(phasedoneorerror, message = the summary) is what the run-end toast, title flash and notification key on. Kindreview_pending({pending: N}) tells the Review page to refresh while a run is still writing; without it the page polls every 15 s during a run. - The ring is not cleared when a run starts.
GET /api/runs/latestscopes its snapshot to the current run by sequence number, while a reconnecting stream can still replay across a stop β start.
Two kinds are synthesized by the server itself rather than emitted by the engine, and they sit in the ring and replay like every other event:
| Kind | When | Shape |
|---|---|---|
task_update | after any board task change (worker moves, a PATCH /api/tasks/{id}, a retry) | phase = the run's current phase, or "board" when no run is active; task_id; message = "<id> -> <column>" ("<id> -> removed" for a deletion); data = {"task": {β¦task exactly as GET /api/tasks renders itβ¦}} |
review_pending | after a proposal is recorded by a running agent, and after every apply/reject | phase = "review"; data = {"pending": N} β the number of applicable entries in the queue |
GET /api/queries/{id}/events replays a recorded run's log, and GET /api/queries/{id}/trace groups it into contiguous phase segments with totals β the numbers that matter when tuning a small local model.
Security model¶
Studio is a local agent with file read, config write, API-key write and run-start capability. Three independent layers protect it, and none of them is optional-by-accident.
Loopback only¶
A request whose Host is not 127.0.0.1, ::1 or localhost is rejected with 403. This is what blocks DNS rebinding, where a hostile page resolves its own domain to 127.0.0.1 and then talks to your agent. AllowNonLoopback exists only for deliberate exposure behind an external authenticating proxy.
Same-origin only¶
No Access-Control-Allow-Origin header is emitted at all for ordinary use β the previous Access-Control-Allow-Origin: * let any page you happened to visit read Studio's responses.
- A cross-origin
Origin, or aSec-Fetch-Site: cross-siterequest, is refused. - When an origin is allowed, only that exact origin is echoed β never
*. --dev-corsallows exactly the Vite dev origins (http://127.0.0.1:5173,http://localhost:5173,http://[::1]:5173) and nothing else. Studio warns on startup when it is on.
Every response also carries X-Frame-Options: DENY, X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer β the last because the URL can carry a token.
Session token¶
slmcode studio mints a random 256-bit hex session token per launch and prints it in the URL:
β Studio listening
url http://127.0.0.1:7420/?t=8f3cβ¦
auth session token required (the URL above carries it)
Open that URL. Everything is behind the token β including the HTML shell. A bare http://127.0.0.1:7420/ does not load Studio; it gets 401 and a static page that says to open the URL the CLI printed. /api/* without a token gets a 401 JSON-less body and WWW-Authenticate: Bearer realm="slmcode-studio".
How a browser gets authenticated¶
- You open the CLI's
?t=<token>URL. - The server validates the parameter and replies with a session cookie:
- From then on the cookie authenticates every request β page loads,
fetch(credentials: 'same-origin') andEventSourcealike. The SPA strips?t=from the address bar on first read, so the token stops appearing in history, screenshots and shoulder-surfing range.
The cookie is deliberately shaped:
| Attribute | Why |
|---|---|
HttpOnly | keeps it out of document.cookie, so an XSS in a rendered diff cannot exfiltrate it |
SameSite=Strict | it is never attached to a request originated by another site |
Path=/ | one cookie covers the SPA and /api/ alike |
no Secure | Studio is plain HTTP on loopback; a Secure cookie would simply be dropped |
session cookie (no Max-Age) | closing the browser drops it; re-open the CLI's URL to re-issue |
A non-browser client (curl, a script, another agent) can present the token directly instead:
| Transport | Form |
|---|---|
| Header | X-SLMCode-Token: <tok> |
| Header | Authorization: Bearer <tok> |
| Query | ?t=<tok> β for EventSource, which cannot set headers |
Any of the three also mints the cookie, so a browser only ever needs it once.
The <meta name="slmcode-token"> tag is gone
Studio used to serve GET / unauthenticated and inject the token into the HTML for the SPA to read. That made the shell an unauthenticated token dispenser: any other process on the machine could curl http://127.0.0.1:7420/, scrape the token out of the page and then drive the agent. There is no meta tag and no meta fallback any more, by design. If you built a client against it, read the token from the CLI output or set SLMCODE_STUDIO_TOKEN yourself.
Turning it off¶
--no-auth (or SLMCODE_STUDIO_NO_AUTH=1) drops the token requirement entirely: every request is treated as already authenticated and no cookie is minted. Loopback and same-origin enforcement stay on. The CLI prints β auth disabled β any local process can drive this agent, which is exactly what it means. Use it for a throwaway container, not on a machine you share.
--dev-cors (or SLMCODE_STUDIO_DEV_CORS=1) allows the Vite dev origins so npm run dev on :5173 can talk to the API. It does not weaken the token: the dev server still has to present one, which it does by proxying /api through the same origin.
Environment overrides, for embedders and tests:
| Variable | Effect |
|---|---|
SLMCODE_STUDIO_TOKEN | use this token instead of a random one (handy for scripts) |
SLMCODE_STUDIO_NO_AUTH | disable the token requirement β same as --no-auth |
SLMCODE_STUDIO_DEV_CORS | allow the Vite dev origins β same as --dev-cors |
What the token actually buys you β and what it does not¶
Be precise about this, because the previous version of this page overstated it.
It does bound:
- any other origin β a page you visit cannot read Studio's responses, and
SameSite=Strictmeans the cookie is never attached to its requests; - an unprivileged process that can reach the port but cannot read your terminal or your process's memory β for example something in another container, another user's account on a shared box, or a service that only got a socket;
- accidental exposure through a proxy or a port-forward, since the URL alone is not enough without the
?t=parameter.
It does not bound another process running as you. The token is printed to your terminal's stdout and lives in the server process's memory. Anything with your uid can read your scrollback, your shell history if you pasted the URL, /proc/<pid> on Linux, or simply the terminal multiplexer buffer. On a single-user laptop the token is a good hygiene measure and a genuine anti-CSRF/anti-rebinding control β it is not a sandbox against malware already running as you.
Loopback and same-origin are what stop a remote page. The token is what stops a local listener that is not you. Neither stops you, or anything running with your privileges.
Transport hardening¶
Plain http.ListenAndServe has no timeouts (gosec G114 / Slowloris). Studio sets ReadHeaderTimeout: 10s, IdleTimeout: 120s and MaxHeaderBytes: 1MB. Read and write timeouts stay zero deliberately: /api/events is a long-lived SSE stream, and any WriteTimeout would cut it off mid-run. ReadHeaderTimeout is what actually bounds a header dribble. Shutdown is graceful β in-flight requests drain instead of being severed.
Path safety¶
GET /api/workspace/file and /api/workspace/tree resolve every path against the real workspace root with symlinks evaluated, so neither .. nor a symlink inside the tree escapes it.
Frontend development¶
The dev server is a different origin from the API, so start the backend with slmcode studio --dev-cors (or SLMCODE_STUDIO_DEV_CORS=1). Studio ships no CORS headers otherwise.
| Script | Does |
|---|---|
npm run build | tsc -b + production build into dist/ |
npm run typecheck | tsc --noEmit |
npm run lint | typecheck and ESLint |
npm test | Vitest + Testing Library |
npm run test:coverage | Vitest with v8 coverage |
react-hooks/exhaustive-deps is an error, not a warning: a stale closure in the SSE handler once reduced the live event log to a single row, and that rule is what catches it.
Cross-component contracts¶
A few interactions cross component boundaries without a shared parent. They go through CustomEvents on window, all named in web/src/components/ui/events.ts. Every event is dispatched cancelable; a listener that handles it calls preventDefault(), and the dispatcher falls back to doing the work itself when nobody does.
| Event | Fired by | Listened to by |
|---|---|---|
slmcode:focus-prompt | / | Live view β focuses the prompt |
slmcode:run-prompt (detail.query?) | ββ΅ in the prompt; the result panel's Run again | Live view β starts the prompt (or detail.query) with the chosen teams/specialist; the result panel calls POST /api/runs directly when unclaimed |
slmcode:stop-run | β. in the prompt | Live view β POST /api/runs/stop |
slmcode:steer-task (detail.taskId) | a task's Steer | LiveFeedback β prefills @task:ID; parked in sessionStorage when no composer is mounted |
slmcode:command-palette | anything | Layout β toggles the palette |
The prompt input is recognised by its accessible name Run prompt (or a data-run-prompt attribute). URL parameters that pages read: /board?column=blocked (Board), /files?path=a/b.go (Files), /runs?run=ID (Runs), /?task=ID (Live β task panel focusTaskId), /?run=ID&replay=1 (Runs β Replay on the floor; harmless when Live ignores it).
Errors on every editor page go through useToast().reportError; list pages render ui/ErrorState with a Retry in place of the list when their load fails. Tab strips use ui/useRovingTabs (one Tab stop, arrows between tabs, aria-controls to the panel). Dialogs share useFocusTrap from ui/Modal.tsx.
make ui-react builds and syncs web/dist/ into cmd/slmcode/ui/, which is embedded with go:embed all:ui. make bootstrap does the same but only when the assets are missing.
Studio downloads no webfonts. Typography uses the platform UI stack; drop inter-variable.woff2 / jetbrains-mono-variable.woff2 into web/public/fonts/ to opt into Inter and JetBrains Mono locally.
API surface¶
Roughly 60 endpoints under /api/, grouped: health Β· readiness Β· config (+ config/schema) Β· docs Β· tasks Β· board Β· columns Β· skills Β· runs (start / stop / resume / latest / interrupted) Β· clarify Β· plan Β· continue Β· escalate Β· shell (the five HITL gates, each GET β¦/pending + POST β¦/answer|approve) Β· rewind Β· compact Β· events Β· status Β· models Β· auth Β· mcp Β· stacks Β· agents Β· pipeline Β· composition Β· blocks Β· packs Β· squads (the current run's org chart) Β· teams (the library β CRUD, plus teams/preselect, teams/activate, teams/activity and teams/{id}/manager) Β· archives Β· queries (+ /events, /trace) Β· review Β· workspace/file Β· workspace/tree.
slmcode config schema and GET /api/config/schema both emit the machine-readable config schema the Settings page renders from β that is how Settings stays in sync with config.Config.