Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 118 additions & 17 deletions studio/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,123 @@
# CLAUDE.md — studio/
# CLAUDE.md — Mecatl Studio

Studio is mecatl's web client (the Atrium workspace): a Next.js **Node module,
never a Go module** — not in `go.work`, the layering DAG, depguard, or the
api-compat gate. It consumes only the daemon's public HTTP/SSE API. See ADR
0287 (`docs/adr/0288-studio-atrium-module.md`).
The web client for the mecatl harness: a Next.js app (App Router) serving the
Atrium workspace — Chats · Scheduled · Skills · Memory · Settings — against a
`mecated` daemon. See ADR 0288 (module + posture) and ADR 0289 (server-backed
chats).

> Studio is landing as a stacked PR series; this file grows with the module.
> Until the series completes, the rules below cover what is in the tree.
## Commands

Run through the root Taskfile, not bare npm:

```sh
task build # repo root first — studio's managed mode spawns ../bin/mecated
task studio:dev # start Studio + its mecated supervisor (background; logs in studio/dev.log)
task studio:stop # stop web server + controller + the mecated it supervises
task studio:test # vitest run + the hermetic server-tier suite (builds first)
task studio:lint # biome
task studio:typecheck
```

`npm run dev` = managed mode via `scripts/dev-local.mjs` (controller on :8788,
web on :3000). `npm run dev:web` = bare `next dev` (external mode or against an
already-running controller). Setting `MECATL_BASE_URL` selects external mode.
The hermetic suite (`npm run test:server`) builds Next first — a stale build is
the usual reason it fails mysteriously.

## Module shape

- `src/lib/protocol/` — the ONLY reader of raw daemon JSON: event decode +
StreamEvent translation, session inventory/transcript decoders, schedule
decode/encode. Generated TS proto bindings are deferred; this seam plus its
vitest suite is the stopgap.
- `src/lib/harness/client.ts` — browser transport: fetch + SSE buffering +
stream robustness (120s idle timeout, saw-result guard).
- `src/lib/server-proxy.ts` + `src/app/api/mecatl{,-control}/[...path]` — the
server tier: origin trust, header allowlists, bearer + workspace injection,
external-mode 409 policy.
- `scripts/local-controller.mjs` — managed-mode sidecar (supervises `mecated`,
owns model-router/MCP-gateway config + OAuth); policy helpers in
`src/lib/controller-security.mjs`.
- `src/features/agent/` — runtime-status provider + the daemon-backed hooks;
`src/app/workspace/**` — the five surfaces.

## Rules that have teeth

- **Daemon-only.** No mock layer, no demo fallbacks: an unreachable daemon is a
rendered offline state. Never add fixture content behind a probe failure.
- **npm, pinned.** Node from `.nvmrc`; the lockfile is regenerated only with
the pinned npm (`npx -y npm@10.9.4 install`) — npm 11 rewrites it into a
shape CI's npm 10 rejects. Installs run `--ignore-scripts`.
- **License headers.** Everything under `studio/` is `Apache-2.0`; CI greps
away any stray Proprietary SPDX header regression.
- **Gates.** `npm run lint` (Biome), `npm run typecheck`, `npm run knip`
(dead code/exports/deps), `npx vitest run`, `npm run build` — all green
before commit. Root docs gates still apply to any Markdown change.
Each rule is backed by a test; break the rule and its test names you.

1. **Daemon-only: an unreachable daemon renders offline, never demo data.**
There are no fixtures to fall back to — do not add any. The one sanctioned
exception is the explicit, default-off, clearly-labeled Labs mock content
(Settings → Labs → "Show mock features", `src/features/agent/mock-tour.ts`)
— an opt-in demo the user turns on, never a fallback for an unreachable
daemon.
(`tests/rendered-html.test.mjs`: unreachable daemon → friendly 503.)
2. **The workspace is resolved, never hardcoded and never browser-supplied.**
Managed: controller `/status`; external: `MECATL_WORKSPACE`; injected
server-side into session/team/schedule creation. SERVER-ASSIGNED remote
deployments (ADR 0237 — any network-facing listener) refuse a client
workspace outright: leave `MECATL_WORKSPACE` unset there, and the proxy
deliberately injects nothing (the correct empty-workspace create); a
refused injection is rewritten with the unset-the-variable fix.
(hermetic: session creation carries the deployment workspace.)
3. **Credentials never cross the browser/controller boundary.** No key-paste
UI anywhere; `mecated` reads `~/.config/mecatl/auth.yaml`. The proxy's
header allowlist excludes `authorization` from the browser.
(hermetic: bearer injected server-side.)
4. **Controller mutations are server-only.** They require the server-set
`x-mecatl-studio-request` header, a loopback Host, and an allowlisted
Origin. (hermetic: CSRF/DNS-rebinding truth table.)
5. **External mode owns nothing locally.** Every control write answers 409.
(hermetic: control writes 409.)
6. **A failed turn renders as failed.** `result.stop === "error"` with no text
must never become a quiet success — the run_result event always reaches the
UI. (`src/lib/protocol/events.test.ts`.)
7. **Unknown event kinds are surfaced, never dropped.** A new daemon
capability shows up as "not rendered yet". (`events.test.ts`.)
8. **The memory panel is read-only.** A value typed into the UI would land in
turn-0 context bypassing injection scanning; the daemon has no write API by
design. Do not add an editor.
9. **Session rows obey the store.** Eligibility comes from row capabilities
(omitted = denied); a row is removed only by a complete inventory walk or a
404; renames adopt the daemon's clamped echo.
(`src/lib/protocol/sessions.test.ts` + `use-agent-sessions`.)
10. **Schedule PUT replaces the whole spec.** Fields the form cannot edit ride
the row's `carried` spec and are re-encoded, or they are silently deleted.
(`src/lib/protocol/schedules.test.ts`: carried round-trip.)
11. **Requests are protojson; responses are stdlib JSON.** Never echo a decoded
response back as a request body. (`schedules.test.ts`: the asymmetry test.)
12. **Skills are project-scoped only.** The controller pins `--skills-dir` and
never passes `--skills-conventional`.

## Gotchas

- `npm test` runs vitest in watch mode; CI and `task studio:test` use
`npx vitest run` + `npm run test:server`.
- The controller restarts `mecated` on every config write; in-flight runs and
session ids die with it. Surfaces warn before writes that restart. Startup
rides the daemon's ready file (`--ready-file` + an ephemeral `--http-addr`
+ a mkfifo lifetime pipe — Node's stdio "pipe" is a socketpair mecated
rejects); `/status` reports the ready doc's `apiMajor`/`features`/
`deployment`.
- FireNow (`POST /v1/schedules/{name}/fire`) is synchronous — the request lasts
the whole agent run.
- Live re-attach to a running session rides the durable watch
(`GET /v1/sessions/{id}/watch`, ADR 0250; gate on the
`watch_session_events` feature): SSE `{event, cursor, phase}` envelopes —
replay from the cursor (empty = the beginning), one event-less
`phase: "live"` boundary frame, then live follow. The client is
`src/lib/harness/watch.ts`; `use-agent-chat` attaches it when the
inventory reads running/awaiting and Studio is not itself driving the run.
Residual: `POST /prompt` still cancels its run on client disconnect, so a
reload of the DRIVING tab still ends the run — the watch covers runs
driven elsewhere (schedules, other tabs/clients) and parked approvals.

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
157 changes: 49 additions & 108 deletions user-docs/building/what-you-get/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ surfaces on one rail — **Chats**, **Scheduled**, **Skills**, **Memory**, and
**Settings**. It is a client like `mecatui`: the daemon owns every record, and
Studio reads and writes the daemon's state rather than keeping its own.

:::note Landing in progress
Studio is landing as a stacked series of pull requests; the full workspace,
including the mobile round, is now in the tree, with a closing documentation
pass to follow.
:::

## Starting it

Managed mode (the default) supervises a `mecated` from your checkout:
Expand All @@ -38,105 +32,52 @@ MECATL_WORKSPACE=/srv/workspace \
npm run start
```

In external mode there is no local controller: every local control surface
answers 409 as owned by the deployment.

Studio is **daemon-only** by design: when the daemon is unreachable it renders
an offline state that names the fix — never simulated content. The browser
never holds a daemon address or credential; Studio's own server tier pins
Host/Origin, injects the bearer and the session workspace server-side, and
allowlists headers in both directions. The decision record is ADR 0288
(`docs/adr/0288-studio-atrium-module.md` in the repo).

## Chats

Chats are the daemon's sessions — there is no separate Studio store. The
sidebar lists the daemon's session inventory bucketed by recency, with
capability-gated rename and delete. A new chat is a draft until you send:
the session is minted on first send, so abandoned drafts never litter the
daemon. The transcript streams live (text, reasoning, tool activity, and
delegation badges), failed turns surface as alerts instead of vanishing, and
permission asks render as three-way approvals (allow once / always / deny)
that withdraw if the daemon retracts them. @-mentions offer the daemon's
agent roster and slash commands its command list.

## Scheduled

Scheduled tasks live on the daemon; Studio's Scheduled surface lists them
with their fire history. Authoring supports the two real shapes — a cron
schedule (with timezone and an optional max-fires cap) or a one-shot (with
retry) — and mutating runs are an explicit opt-in in the form, so the
invalid mutating/plan pairing cannot be constructed. Editing round-trips the
schedule's carried spec (the daemon's update replaces the whole spec), and
each fire links its real transcript.

## Skills

The Skills surface lists the daemon's skills with provenance, browses folder
skills (including the SKILL.md inline), and — in managed mode — authors them
through the local controller: create a skill in two steps, upload a zip or a
folder, enable/disable (a disabled skill moves to a holding area rather than
being deleted), or delete. Skill names pass one shared validator on both the
browser and the controller. In external mode the list is read-only: skill
management belongs to the deployment.

## Memory and Settings

Memory is read-only by design: the daemon has no memory write API (a
hand-typed value would enter turn-0 context without injection scanning), so
Studio shows the memory table with honest disabled/empty states, per-entry
detail, the store footprint, and the consolidate action. Settings carries
Personalize (text size, interface scale, session-list side, notifications),
the agent identity card (name and avatar are browser-local cosmetics — the
agent learns your name in conversation), and the learning review page.

## Providers, model router, MCP gateway

In managed mode Studio administers the daemon's runtime configuration
through the local controller: add a provider (built-in kinds take a base-URL
override; custom gateways are validated), test a key, switch the active
provider, remove one, and edit the model router over the daemon's whole
model inventory. Provider credentials live in the daemon's auth file, owned
server-side — Studio shows status booleans and key-shape hints, and key
material never crosses to the browser. The MCP gateway URL is user-entered
but always validated, and egress is HTTPS-only (loopback HTTP sits behind an
operator env opt-in). In external mode all of this reads as owned by the
deployment. The About-this-daemon card reports the safe identity probe.

## External-mode sign-in (OIDC)

When an external daemon sits behind an OIDC-aware gateway, the settings
provider page offers sign-in: Studio's server tier runs the PKCE flow itself
and holds tokens in process memory only — nothing token-shaped reaches the
browser or disk. With OIDC unconfigured, the static `MECATL_AUTH_TOKEN` path
is untouched. Managed mode never uses either.

## Live re-attach

Open a driving session in a second tab (or refresh mid-run) and Studio
re-attaches to the live run through the daemon's durable session watch. One
limit to know: the tab that *started* the run still cancels it if that tab's
prompt connection drops — re-attach covers every other tab.

## Modes, model switching, debug sessions

The composer's mode selector switches the session's permission mode live
(with rollback if the daemon refuses), and the model selector forks the
conversation onto another model mid-chat, keeping the transcript. Where the
daemon advertises session debugging, a chat's menu offers Debug with AI
behind an explicit consent dialog; debug sessions carry a badge in the
sidebar.

## Labs

Settings → Labs holds the clearly-labeled mock extras, default-off: a mock
Projects section in the chat sidebar and a canned feature tour. These are
browser-local demo content only — a mock id is hard-gated from ever reaching
the daemon — and with the toggle off none of it exists at runtime.

## On a phone

Studio installs as a PWA and adapts below tablet width: bottom tab
navigation, sheet-based side panels, a docked composer that plays well with
the on-screen keyboard, long-press row actions, and swipe-to-dismiss.
Desktop rendering is unchanged.
In external mode there is no local controller: provider, model-router, and
MCP-gateway settings show as owned by the deployment.

Studio is **daemon-only**. If the daemon is unreachable you get an offline
banner naming the fix — never simulated content.

## What each surface does

- **Chats** — the daemon's session store, live. The sidebar is the session
inventory (a chat renamed or deleted here is renamed or deleted for every
client); opening a chat reads its authoritative transcript; a new chat
creates its daemon session on the first message. Streaming shows tool calls,
reasoning, delegation badges when the run hands work to subagents or teams,
and permission asks with three-way verdicts (allow once / always / deny). A
failed run renders as failed, with a retry.
- **Scheduled** — the schedule registry: create and edit schedules (cron with
timezone, or one-shot), pause/resume/fire, and audit each schedule's fire
history down to the per-fire session transcript. Write-capable schedules
require an explicit opt-in; the default posture is read-only plan mode.
- **Skills** — the daemon's resolved skill inventory (name, summary,
provenance). Read-only today; authoring is a follow-up.
- **Memory** — the user model: durable facts the agent has stored about you.
Read-only by design — the agent curates memory through injection-scanned
tool calls, so Studio never offers an editor.
- **Settings** — appearance and notifications, plus (managed mode) the
provider status, the semantic model router, and the MCP gateway connection
(bearer token or OAuth). Credentials are never typed into Studio: `mecated`
reads them from `~/.config/mecatl/auth.yaml`.

## Environment variables

| Variable | Meaning |
| --- | --- |
| `MECATL_BASE_URL` | External daemon base URL; presence selects external mode |
| `MECATL_AUTH_TOKEN` | Bearer for the external daemon (server-side only) |
| `MECATL_WORKSPACE` | Workspace path in external mode (server-side only) |
| `MECATL_STUDIO_PUBLIC_ORIGIN` | Comma-separated origins Studio is served from (CSRF gate) |
| `MECATL_STUDIO_ORIGINS` | Controller's Origin allowlist (managed mode) |
| `MECATL_STUDIO_PROVIDER` | Managed provider: `mock`, `openrouter`, or `toolhive` |
| `MECATL_ALLOW_INSECURE_LOOPBACK_MCP` | `1` permits a loopback-HTTP MCP gateway |

## Limits worth knowing

- Studio re-attaches live to running sessions through the daemon's durable
session watch (any tab, including a scheduled fire in progress). One
residual: the tab that *started* a run still cancels it if that tab's
prompt connection drops.
- Config writes in managed mode restart the daemon, which ends in-flight runs.
- There is no cost display: the daemon accounts tokens, not currency.
Loading