Help us shape the future of Go! Take the 2026 Go Developer Survey

studio

package
v0.13.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: MIT Imports: 47 Imported by: 0

README

weft studio — the Inspector

The UI, the JSON API, the live stream and the OTLP receiver over one obsdb database: the runs list, the run page (steps, tool calls and results, subagents lazy, truncation badged, replay over the event index, a time-axis waterfall when the run has spans), sessions (threads), any trace — weft or not — a live page, and agent and tool cards from the manifest. One http.Handler, no build step for users, fully offline.

The three setups (S4.6)

A · embedded — the five lines beside your app (examples/studio-local is the whole thing, with a thread session whose turns stream in live):

defer otel.Install()() // local sink ./.weft/weft.db, content on, no network
mux.Handle("/studio/", http.StripPrefix("/studio",
    studio.Handler(studio.DB(otel.LocalDB())))) // same DB, same live hub

Passing the pipeline's handle is what makes it live: writes publish to the handle's hub and /api/live follows, sub-100 ms, no network. studio.Open(path) opens an obsdb sqlite file; with neither, New opens $WEFT_DB or ./.weft/weft.db (history only — a second handle on the file, no live lane).

Without a Token, setup A's API (the whole /api tree: reads, the live stream, the playground and debugger verbs, the runtime link) answers only a loopback Host — localhost, *.localhost, 127.0.0.0/8, [::1], any port — so a DNS-rebinding page (http://evil.example:7331 resolving to 127.0.0.1, same-origin to the browser) gets a 403 instead of your transcripts and your playground. An app served on a real hostname (http://myapp.internal:8080/studio/) either lists that origin — studio.AllowOrigins("http://myapp.internal:8080"), whose host:port must equal the request's Host — or sets studio.Token. X-Forwarded-Host/Forwarded are never trusted; behind a proxy, the Host the proxy forwards is the one checked. The UI shell and /panel.js carry no data and are not checked; with a Token the bearer is the defence and the Host does not matter.

B · local binary — any language's app, several services:

go install github.com/weftgo/weft/cmd/weft@latest
weft studio                            # UI + OTLP + SQLite + playground + dev token on 127.0.0.1:7331
./my-go-app                            # otel.Install() finds it through the discovery file
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:7331 python app.py

The dev token is stable per database — <db>.token beside the SQLite file, 0600, never printed (--rotate-token renews it; WEFT_STUDIO_TOKEN or --token overrides it); a Go app with otel.Install() / runtime.Install() finds the running Studio through the discovery file studio.json (./.weft, $XDG_RUNTIME_DIR/weft, the user cache directory; WEFT_STUDIO_URL always wins, WEFT_DISCOVERY=off ignores it); ingest is open on loopback; 127.0.0.1:7331 is the one default port: started again on the same database, the binary finds the running Studio through /api/meta (db.path, pid, read with the stable token — sent only to an address a trusted discovery file names, so from another directory with --db on the same file the probe goes out bare and a second Studio starts) and reuses it (studio already running at … (pid n), reusing, exit 0); anything else on the port moves it to the next free one in 7331–7340 with one line saying so. --addr (or WEFT_STUDIO_ADDR) pins: a busy pinned address is an error, never a fallback; --db sqlite://path picks the file, --db clickhouse://user:pass@host:9000/db the hosted backend (obsdb/clickhouse, the one place the driver is imported). The playground is on (studio.Playground(true)), inert until an app's weft/runtime connects; --no-playground turns it off. The manifest is --manifest (WEFT_MANIFEST), else the nearest weft.json from the working directory upward — one line says which file, or that none was found. --open (default on when stdout is a terminal) opens the UI with the token in the URL fragment.

The same binary is the API over a terminal, each subcommand one route, --url (WEFT_STUDIO_URL) and --token (WEFT_STUDIO_TOKEN) picking the Studio: weft runs [--agent] [--since] [--failed] [--limit] [--json] (GET /api/runs; a limit that hid runs says so on stderr), weft open <run id> [--open] [--with-token] (checks GET /api/runs/{id}, prints the bare <url>/runs/<id>; the token goes only to the browser, or to stdout on --with-token), weft export <run id> [--format json|jsonl|otlp] and weft export <run id> --wefttest ./testdata [--test TestName] [--force] (GET /api/runs/{id}/export, the fixtures unzipped where wefttest.Replay(t, "testdata") reads them; --force replaces a non-empty target's fixtures), weft doctor (GET /api/meta).

C · hosted — the same handler behind studio.Token: the panel's scoped tokens are HMAC-signed {public_id, scope, exp} minted by your backend through POST /api/panel-tokens; every data route refuses anything outside the token's public id. A token is read-only unless minted with "playground": true — a read-scoped one neither acts nor reads the agents' system prompts (/api/manifest, the runtimes' instructions) — and experiments (experiment_id included) are the server token's alone.

The devtools panel (WEFT-DEVTOOLS.md)

One script tag puts the run loop in the corner of your own page — /panel.js is served by the same handler:

<script type="module" src="/studio/panel.js" data-weft data-scope="pub_…"></script>

Rung 1 is a viewer scoped to that conversation: the turns (parked shown), the step story with tool calls, usage splits, each step's attempts and timing ("attempt 4 of 4 · fallback to glm-b", "1.2 s · ttft 180 ms"), approvals read-only, truncation/gap/stripped honesty, the raw JSON, a live tail, ⤢ deep links into Studio, lazy subagents (one level inline, with the child's request line and an "open in Studio" hand-off; a grandchild is the hand-off only) and a spans waterfall.

The views (plan D4). The open turn has four tabs, a role="tablist" (←/→, Home/End move and select; one tab stop): Story (the step story above, and the playground's drawer and result), Request (the step's request, below), Timeline (the spans waterfall at the column's full width over a time axis — ms from the run's first span — or, on a run without spans, its steps and tool calls placed by the event sequence, seq, the position in the run's event stream) and Raw. Raw is a JSON tree (src/panel/tree.ts, hand-written) of {doc, events, transcript, spans}, plus requests when the panel already read them: ▸/▾ per node (aria-expanded; → opens, ← closes); only open nodes are built — a small document opens whole, a large one its top rows (150 by default, breadth-first), a container shows 200 children then "… +N more", a string over 2,048 characters (UTF-16 units, so a CJK or emoji string is cut by characters, never inside a surrogate pair) its head and "… +N bytes" (the rest's UTF-8 bytes, counted once per string) — so a 10 MB transcript costs only what is open, and a redraw that changes no record walks nothing again. The filter box (/) matches keys and values, case-insensitively, 100 ms after the last keystroke; it opens the path to each match (the first 200), marks it and says "n matches" (every match counted; past 200, "n matches · first 200 opened"; a match past a container's 200 is drawn anyway). ⧉ copies that node's JSON (the whole node, past any cap), copy all the document, through navigator.clipboard; "copied" goes after 2 s or with the next action; where the clipboard is missing or refuses, the text is shown selected to copy by hand, and that box goes once you leave it (or on Esc). The Story tab's node is kept across a tab switch (hidden, not rebuilt), so what you opened in it stays open; the Timeline's axis is the waterfall's own window, and spans it cannot place (unreadable times, an end before the start) are counted in a note. download saves the document as <run id>.json (a / in a child's id becomes _). The turn list has its own filter above it — text over the run id, the error and the prompts of the turns the panel has opened — it cannot search the prompts of turns not opened, because /api/runs rows carry no prompt preview — a status (running, succeeded, failed, parked) and has error — over the loaded rows, never stored, "n of m turns" while it narrows ("n of m loaded · the filter applies to the loaded turns" while older pages remain, and then nothing loads by itself: the button does; the narrow dropdown keeps the selected turn listed). The list pages: when it scrolls to its end, a sentinel row (one passive IntersectionObserver inside the panel's own list, disconnected with the element) reads the next older page through the runs route's cursor (before=/before_id=, 50 a page) — no cap: it reads "loading older turns…" while a page is read and "all n turns loaded" at the end. The sentinel is keyed by the cursor, so a page that adds no row (all experiments, another session's) still arms the next read. Where there is no observer, and in the narrow layout, the sentinel is a button (older turns ↓): the default 520 px float and the 460 px right dock are narrow (the turn column is a dropdown there), so in them older turns load by the button — three clicks for 200 turns. A refresh (the live lane reconnecting, a stale read) keeps the pages already read and adds new rows at the top. The dev list (no public id) stays the newest 10 runs. Honesty (plan D5). Every badge the panel draws is one of the A3 table's ten (src/lib/honesty.ts, obsdb.HoleNote's words, pinned by studio/testdata/holes.golden.json), drawn by one module, src/panel/badges.ts: a <span class="weft-badge" data-hole="<hole>"> whose text is the table's label and whose title is the reason and "— fix: …" (the response's reason/fix when the route sent them, a known cause's — result_cap, no_public_id, … — else the table's; fix: "" says none applies). The same words ride in the badge's accessible text, visually hidden, so a keyboard or screen-reader user reads them too. The run page's HoleBadge draws the same span, the loop's cuts included (its TruncationBadge is the table's truncated, cause result_cap, or max_tokens for an unrun call). They sit on the turn's header (its holes said in full: interrupted, gap, max_tokens, not_recorded, derived, stripped), on each step card's header, on the tool call whose result was cut (a recorder cap's cut; a result cap's, truncated with cause result_cap; or a call a max_tokens step never ran, max_tokens with no fix, since the loop already retried), on compaction markers and on each request line (not_recorded, hidden, stripped, and gap for a finished step no request row names, "this step ran, but no request record names it" on both surfaces). E1.2's Request tab calls the same badge(hole, note). No other panel source spells a hole's words or builds a weft-badge outside a named list of non-hole badges (parked, the attempt line, a tool error, …), and the lib/ modules the panel imports name no other hole: badges.test.ts greps for both. weft.content: "redacted" is reserved in Go and never emitted by weft's pipeline, so redacted comes only from a non-weft writer's records. Both surfaces still badge it. The footer's content line replaces the old suffix and is said only on evidence: "content on · 2 events shortened (24.6 KiB cut)" (a recorder cap cut the open turn's events, so content was there; on a capped walk, "· first 5000 events"), "content on" for a recorded full mark (the latest run's, from /api/meta's content, when that run is the open turn or no turn is open), "content off · otel.NoContent()" for a destination's stripped, and "content off · captured none (weft.Content(false), or no destination takes content)" for the core's none. The core marks none when no destination takes content too, not only for the option (/api/meta words it the same). For a mark read from meta, its note and fix win. When nothing marks the content (a stored event carries no mark when full, an unmarked run, another run's mark), the line says nothing. The cap itself is the app's (otel.Content(otel.ContentConfig{MaxBytes: …})) and no record carries it, so the line does not name it. Turn rows carry chips read from the run row, each titled with its source: "scripted (0 tokens)" (the run's model is weft/runtime/scripted, the scripted engine's own, pinned by runtime's TestScriptedModelInfo, because no weft.* attribute names the engine; plain "scripted" when the row's usage is not zero, as when a Subagent child ran a real model), "fork of s_…#e_…" (weft.session.forked_from, from the row's meta) and "experiment of t3" (weft.forked_from, the source turn named by its turn number when the list holds it; "experiment" alone with only weft.experiment.id). src/panel/parity.test.ts serves one run per hole of the table, and per loop cut, to both surfaces from one fake Studio. It asserts each shows data-hole="<hole>" where both mark its source (run header, step card, tool call, request section) with the table's words. It also asserts that every badge the run page shows is one the panel shows, and that a hole-free baseline (timed steps, a request row) shows none on either. And a pre-A1 run's request section says not_recorded on the run page and in the panel's Request tab.

The Request tab (plan E1.2) is the run page's Request pane in the panel (src/panel/request.ts, the same readings: lib/request-pane.ts, lib/requests.ts, lib/diff.ts's bounded diff). It shows one step — the one J/K move, a click on a step card or the tab's own step buttons picks, and ⤢ carries — with its header (the model, the attempt line, the chips changed by PrepareStep, the neutral prompt changed at this step, overridden by experiment, catalog changed at this step, decided by the same hash rules as the pane), an attempt picker when the step retried, the system prompt (12 lines, then "show all") with its diff — against the previous step when the hash moved, at the first step against the registered instructions when the panel can read /api/manifest (setup A, a dev token; captioned "not verified for this run" from a name-only weft.json, and when no manifest is readable the tab says so instead of diffing), never over a cut record, "too large to diff" past 2,000 differing lines a side — the messages sent (count · bytes from the transcript the panel holds, the last three inline, the rest in D4's tree), the tool catalog (each name expands to its description, its policy chips and its schema in D4's tree), every param ("adapter default" when nil), tool choice, thinking, a subagent call's child request (its step 0, read by the child's id on demand — "read its turn"; a child whose history cannot be read says so and links to its Studio page) and the step's compaction marker. The step shown is the one ⤢ carries and J/K move from (over the steps the record names too). The manifest is read per endpoint and token, asked again 30 s after a failure and once more when it does not list the run's manifest hash (a redeploy under weft dev). A derived prompt or catalog record (its body did not parse) is badged derived and never diffed over, here and on the run page. A Studio without the requests capability is not_recorded, cause not_served (obsdb's table; /api/meta's capabilities_off.requests words first). / on the tab focuses an open tree's filter, else the turn list's. Every absent block is a badge from badges.ts with its reason; a run-wide hole is that badge and nothing else — a read-scoped token sees hidden and never asks for the record or the manifest, so no prompt byte reaches the page. The provider wire pair (plan A6) is recorded by no route yet, so the tab draws nothing for it.

Replay from here (plan F1). The Story view's step cards, tool calls, steers, the subagent call's child row and the child's own steps carry the run page's verbs, shown on hover and on keyboard focus (aria-labels; Enter/Space stop at the verb, Escape goes on to the panel; Escape on a drawer control closes the drawer and returns focus to its verb): replay from this step, edit this result and replay (from_step N+1 with the call's recorded result pre-filled — drawn only up to the highest from_step the server accepts for the transcript, lib/experiment-body.ts's replayBounds: on its last step only when that step's calls are all answered there; no transcript, no edit or steer verb; an empty result seeds no edit), edit the prompt and replay (the step's system prompt from its request record; unreadable or cut, the registered prompt and a line saying so, with the hole's badge), re-run (the actions row's ↻ too: it opens the drawer, it never posts at once) and continue here with a new message (a fork, on a session's top-level turn only; Run waits for a message that is not blank). A child replayed from its row is read first, and Run waits while a step's catalog is being read. from_step and an edit's step are the step ordinal. Each opens the experiment drawer pre-filled from lib/replay.ts's draft — the same draft Studio's replay drawer opens on, posting byte for byte the same command — with focus on the verb's field, and back on the verb when it closes. Above Run, the ack preview: per tool of the step's catalog (the request record's replay classes) "runs", "substituted" or "parked" and why, in lib/replay.ts's words ("replay never (unannotated) · substituted from the recorded result when the call repeats, else parked"), redrawn as the tools, mode and breakpoints change; a catalog it cannot read is its badge, the registered tools judged instead; and the prefix line — "steps 0–2 kept", "· the compacted prefix (what the model saw at step 3)" when a run-scope compaction view sits at or before the step. Run is held while side effects allow would be refused (the registered tools left on) or a pre-filled edit has no field (shown, badged). A child's verbs replay the child as its own run of the child row's agent; an agent no runtime registers is said and nothing is posted. The result pane links the new run to its source step (selected in the panel, ⤢ to Studio's run page), and an experiment's turn row has a "↖ t3 step 2" button beside its "experiment of" chip (a ⤢ link when the source turn is not listed). The verbs and the drawer are drawn only under the playground capability and a token that may act — lib/replay.ts's canReplay, the run page's gate: a read-scoped panel token sees none. scripts/panel-gate.ts opens the tab on examples/studio-local's trimmed turn and checks the chip and the deleted guidance line. Deep links (plan G1) follow one scheme, src/lib/links.ts, shared by the panel and Studio's app (an ESLint rule refuses a Studio URL built anywhere else): runs/<id>?step=<n>&view=story|raw&sel=…&axis=time&t=…, sessions/<id>, traces/<id>?span=<span id>&view=chat, playground#run=… (the hand-off in the fragment), playground?run=…&step=…&agent=…&engine=… (the page's own state, G2) and playground?experiment=<id>; the page state each one carries is the table under "Every view is a URL" below. step is the step's ordinal — its index as the loop counts it, the n of runs/{id}/steps/{n} — never an event position or a transcript batch index; a call is named with its step (sel=c:<step>:<call id>, c:resume:<call id>). ⤢ carries the step being read (else the running one); each tool call, subagent badge, session and trace in the panel is a link. No link carries a token, and no request does in its URL: the token travels in the Authorization header, and each live stream is opened with a grant (POST /api/live-grant, the bearer in the header; the stream URL carries only the sig). The browser's own reconnect is left to resume with Last-Event-ID while the grant lasts; one after its 60 s, or one Studio refused (a restarted Studio's new grant key), gets one new grant and a fresh connection (the panel refetches what it lists). A panel token's stream that ends with event: expired is not asked again: the live dot goes out, history stays, nothing reaches the console — a new token is a new data-token, and the panel restarts its connection on it. Alt+W toggles (Q4); the other keys, the dock's layout and what it remembers are under "Layout" below. Setups B/C add data-endpoint and data-token (a dev token, or a panel token your backend mints per page via POST /api/panel-tokens). A single-page app that switches conversations sets window.__WEFT__ = { scope } (or { publicId }) instead of data-scope; the tag then carries no other knob, so keep data-weft on it — it is how the panel finds its own tag (and with it the endpoint) whatever the file is called.

The panel follows a scope, pub_…;session=s_…;flow=f_…;run=r_… (src/lib/scope.ts; only the public id is required). The public id selects the conversation; run pins the selected turn, and its live tail, to that run once (your clicks own the selection afterwards), or says run r_… not in this conversation when it is not among the conversation's runs; session narrows the turn list to that session's runs (a run without a session id is not judged, and a list whose runs carry none says it is not narrowed); flow shows as a chip in the header and filters nothing yet (it waits for weft/flow). data-public-id="pub_…" is still read, as the scope pub_…, but it is deprecated: data-scope is the form.

Where the scope comes from (plan C3, scope detection). Each rung after the first is passive, and data-detect="off" turns rungs 2 and 3 off; rungs 1 and 4 always work — no data-detect value and no token turns them off. The order is explicit (rung 1) > the URL (rung 4) > a marker (rung 3) > a header (rung 2): the numbers are the order the rungs were added, the precedence is how deliberate each one is — the host's own word, then a hand-off a link made (Studio's dev links), then what the page shows passively. The footer says which is in effect in one word: the rungs installed — detect: headers (with (chained) when the fetch it wrapped was not the browser's own), detect: markers or detect: headers+markers — led by url (detect: url, detect: url+markers, …) while the scope followed is the URL's, else detect: off, detect: explicit (rung 1 names the scope, nothing detects) or detect: none (nothing names one, and rungs 2 and 3 are off by default here).

# Rung When it is on The explicit alternative
1 explicit: data-scope (element, weft:scope meta, script tag), window.__WEFT__ = { scope } or { publicId }, scope() / mount({scope}) from @weftgo/devtools, the deprecated data-public-id always; it wins over every detected scope —
2 response headers: the Weft-Scope header of the page's own same-origin fetch responses (the scope header below) by default only with the page and the endpoint on loopback (localhost, *.localhost, 127.0.0.0/8, [::1]) and no token or a dev/server token, and only once Studio has answered; anywhere with data-detect="headers" (or mount({detect: "headers"})); never under a panel token (weft_pt.) unless asked data-scope
3 the DOM marker: data-weft-scope="pub_…;session=…;flow=…;run=…" on any element of the page — the framework helpers (useWeftDevtools for React and Vue, the Svelte weftDevtools action) set it on the element they are given by default everywhere except a page off loopback under a read-scoped panel token (a playground-scoped panel token, the dev token, no token, or any token on loopback keep it on); anywhere with data-detect="markers" (or "headers,markers", mount({detect: "markers"})) data-scope
4 the page URL: ?weft_scope=… (read first), else #weft_scope=… — the same string form, URL-decoded (#weft_scope=pub_x%3Bflow%3Df_1); a value without a public id is ignored always, every setup and token, data-detect="off" included; below rung 1, above rungs 2 and 3; re-read on hashchange and popstate data-scope
5 the fallback: no rung names a scope — the header reads "no conversation detected on this page · how to scope" and lists the newest runs when 1–4 name nothing any line under "how to scope": data-scope="pub_…" on the panel's <script> tag (or <weft-devtools>), scope("pub_…") from @weftgo/devtools, data-weft-scope="pub_…" on the chat's element, scope.Header(h, …) on the app's handler (Go, package weft/scope)

Rung 3 trusts the page's DOM: a data-weft-scope attribute anywhere in it names a conversation, and an HTML sanitizer (DOMPurify among them) keeps data-* attributes by default — so markers must not appear in untrusted HTML. A page that renders user or model HTML marks the container data-weft-untrusted: a marker on or inside such an element is ignored (focus inside it names the chat around it, if that one is marked), and setting or lifting the attribute later rescans. A host that cannot mark it sets data-detect="off" (markers and headers off — a data-detect naming only headers leaves markers at their default, on) and names the scope explicitly.

Rung 2 patches a global of your page, window.fetch (shadow DOM scopes DOM and CSS, not JavaScript), so it is held to these rules (src/panel/detect.ts): it reads the response URL and the Weft-Scope header only, never a body, never a clone, and never sends anything; it reads a same-origin response, or a cross-origin one only when the page and the response are both on loopback (a dev server on :5173 calling the app on :8080) — any other cross-origin response is ignored even when it exposes the header, so a cross-origin production app uses data-scope or the DOM marker; the panel's own Studio requests are never read; it is installed only once Studio has answered, so a panel whose Studio never answers never touches fetch; it chains to the fetch it found (the browser's own or another library's patch) and returns the same response or rejection; it is put back exactly as found when the panel disconnects or the rung is turned off, unless another patcher wrapped it after the panel (then that stack is left alone, the wrapper goes inert and the footer says fetch not restored) — so with two panels on one page, the first to disconnect leaves its inert wrapper in the chain, a pass-through; and nothing it does reaches the console. It is fetch-only: a page cannot read an EventSource's response headers, so an SSE chat app names its scope with rung 1 or rung 3. WebSocket is never wrapped. A detected scope of the conversation the panel follows (the same public id, session and flow — the next turn's header) is a narrowing, never a restart: its run is pinned unless you have clicked a turn since the last pin. Another conversation is followed only from the request path that set the current one, so two widgets polling for different conversations do not thrash; the panel keeps the newest scope per path and one entry per conversation seen (with its newest run), for the switcher below. A run the scope names that is not listed yet (a streaming handler's header lands before its run row) is looked for once more a second later before the panel says it is not in the conversation.

Rung 3, the DOM marker, is the production rung: it touches no global of your page (window.fetch stays as you left it) — it reads attributes only, through one MutationObserver on document.documentElement (only changes that add, remove or re-mark a marked element count; rescans debounced ~100 ms, trailing, but at most 500 ms apart while such changes keep coming, and none while the page is hidden — one when it is shown), one passive, capturing focusin listener and one visibilitychange listener on document (event listeners, not patches), all removed when the panel disconnects or the rung is turned off (src/panel/markers.ts). It never writes to your DOM, never reads the panel's own tree (a <weft-devtools> and anything inside it are skipped, and focus inside the panel — even a panel mounted inside a marked chat — is never a chat's), ignores an empty value or one without a public id, and prints nothing; a scan that changes nothing draws nothing. The marker must sit in the light DOM: one inside a widget's own shadow root is invisible to the scan (and to focus's closest()) — put it on the host element at or above the widget. With several markers on one page the one nearest focus wins: the closest marker around the focused element, else the one focus was last in, else the one followed now, else the first in document order. A marker's scope is a conversation (public id, session, flow) whose run narrows it, so following it is C3.2's rescope — the same conversation narrows, another restarts, a turn you clicked stays. A followed marker that leaves the page stays followed — only focus or the switcher moves the panel — except on a route change: when every old marker is gone and exactly one new one appeared in the same scan, the new one is followed. An explicit data-scope (or window.__WEFT__) wins over a marker, unless a marker carries the same conversation — the helpers call scope() and set the marker, so focus moves between the helpers' chats; from then until the explicit scope changes it counts as a marker's, even after that marker leaves, and its run (a helper's next scope()) pins as an explicit run does.

The scope switcher: when more than one conversation is known (or one, while the panel follows a conversation no longer on the page) — the explicit scope, then the markers in document order, then the header-detected scopes in first-seen order, each conversation once under the first source that names it, at most 20 — the panel header shows a <select aria-label="conversation"> listing each by its public id (with its session and flow when set) and its source (explicit, marker, header); ● (live) or ○ marks the one followed. Choosing one follows it and pins its run (a forced rescope) until focus next goes into a marker or the explicit scope changes. A followed conversation that is no longer known is shown as "not on the page", not offered. One known conversation shows no switcher.

data-detect rung 2 (headers) rung 3 (markers)
unset page and endpoint on loopback, any token but a panel token (weft_pt.) on, except a read-scoped panel token off loopback
headers on anywhere as unset
markers as unset on anywhere
headers,markers on anywhere on anywhere
off (named anywhere: off,headers is off) off off (rungs 1 and 4 stay on)

Empty words are skipped (markers, is markers); a value naming anything else (bogus, headers,bogus) is unset: the defaults apply.

Rung 4, the URL, reads location and listens to hashchange and popstate — two passive listeners on window, removed when the panel disconnects (event listeners, not patches); it never writes the URL (no pushState, replaceState or hash change, whatever the switcher does). A new URL scope drops a switcher choice and pins its run, as a new explicit scope does; an explicit scope set later wins over it, and removing that hands the panel back to the URL. Focus in a marked chat does not move the panel off the URL's scope; the switcher (which lists it as url, after explicit) does. The parameter is read from the query and from the fragment, a hash router's own query included (#/chat?weft_scope=pub_…); a value ends at the next & or ?, is decoded once (a value encoded twice, whose public id still holds ; or =, is ignored), and one without a public id is ignored. Because the rung listens instead of patching, a navigation the page makes with pushState or replaceState is not seen until the next hashchange or popstate, or an attribute change on the panel.

Rung 5, the fallback: with no scope from any rung, the header says no conversation detected on this page · how to scope, and "how to scope" opens the four one-line fixes above. The newest runs it lists follow /api/live?agent=<agent of the newest listed run> (run frames only, opened through a grant like every stream) instead of a poll: the live API has no selector for everything (exactly one of run, session, public_id, agent), and an app's dev page usually runs one agent. While that stream is up the list says live: agent X · the other agents' runs every 30 s and is still read every 30 s (dock open, page visible), so another agent's runs that start later appear. The 10 s poll (dock open, page visible) takes over while no stream is up — no run listed yet (no agent to name), the stream gone (it is reopened on the bounded backoff — 5 s, doubling, capped at a minute, five times at most; the poll does not reopen it in between), or a grant refused (streaming needs the server token · polling; a panel token is never granted an agent's stream, so under one the panel never asks and says the same). In the fallback only the streamed agent's runs light the pill below.

The collapsed pill is an activity signal: while the stream the panel holds anyway (its scope's public_id stream, or the fallback's agent stream — no second one is opened) lists a run as running, the pill pulses (a CSS animation, none under prefers-reduced-motion) and shows the run's live step count, ● 3, with aria-label and title weft devtools · running, step 3. The count is the open tail's steps when that run is the turn the panel follows, else its row's (unknown while it runs: ● alone, weft devtools · running). When the run ends the next draw is the plain pill.

Layout (plan D1). The dock floats — dragged by its header, resized from its corner, between 360×280 and the viewport minus 16 px, clamped again on every window resize (a passive listener, removed on disconnect) — or docks to the left, right, bottom or top edge, resized along that edge (pointer events with pointer capture, no library; touch works through them). Collapsed it is the pill: the live dot and step count above, and the current turn's cost — its tokens in→out once the run has finished, — while it runs (Studio prices no usage, so tokens are the cost). Hidden draws nothing: the element, its API and its events stay, and Alt+W or open() bring the dock back. Under 640 px of panel width the turn column is a dropdown and the header's actions wrap — the default 520 px float is that narrow, so the dropdown is what it shows until you widen it; under a 480 px viewport the panel is a full-width bottom sheet, 70 vh at most. The resize handles are pointer-only (aria-hidden; a keyboard resize is D3's). The header's ⇆ button and Alt+Shift+W go to the next layout: float, dock right, bottom, left, top.

Attribute What it sets Default
data-position the initial place: bottom-right or bottom-left (the float's corner), right-dock, left-dock, top-dock, bottom-dock; set later, it moves the dock bottom-right
data-open start expanded collapsed (the pill)
data-mode the initial mode: float, dock, pill or hidden from the two above
data-push="true" while docked and open, pads <html> on the docked side by the dock's size — padding-<side>: var(--weft-devtools-inset), the variable set to the size (the bottom sheet: padding-bottom by its 70vh) — so the page's flow never sits under the panel. It replaces the inline padding on that side while docked; the inline values from before (or one your page set meanwhile) are put back exactly on close, on a mode change and on disconnect. The panel's one write to your document, opt-in. Padding does not move position: fixed or sticky elements: a fixed or sticky composer keeps clear with the variable in your own CSS, bottom: var(--weft-devtools-inset, 0) (and the matching right/left/top for those docks) — push sets the variable as well as the padding off
data-z-index the dock's and the pill's z-index (an integer) --weft-z on the element, else 2147483000
data-theme light or dark names the theme (above everything); auto leaves it to the order below auto

Theme (plan D2). The panel is light or dark, resolved in one order: (1) explicit — data-theme="light|dark" on the element, weft:theme, the script tag, or mount({theme}); (2) stored — the header's ◐ button cycles auto → light → dark (disabled, and saying so, under an explicit theme), kept as theme in the key below, and auto clears it; (3) auto — your page's <html data-theme="dark|light">, else <html class="dark|light">, else <html>'s computed color-scheme when it is exactly dark or light, then the system's prefers-color-scheme, else dark. A theme toggle on your page is followed (one passive attributes-only observer on <html>'s class and data-theme, and one media-query listener, both removed on disconnect); <html> and matchMedia are only read. The result is data-theme-resolved on the <weft-devtools> element itself. The palette is the Studio app's (src/lib/palette.ts, which names the src/styles.css variable each shared token equals — a test fails on drift), and both themes clear WCAG AA (theme.test.ts: 4.5:1 text, 3:1 UI). Every colour, radius and font is a custom property on the panel's :host, so your CSS overrides any of them — an outer rule beats the shadow tree's :host, in either theme: weft-devtools { --weft-bg: #fff }. The tokens: --weft-bg (panes), --weft-bg2 (header, steps, footer), --weft-bg3 (selected row, fields), --weft-fg, --weft-dim, --weft-faint, --weft-line, --weft-accent, --weft-on-accent (text on the accent), --weft-warn, --weft-err, --weft-info, --weft-ok, --weft-parked, --weft-scrim (the ? overlay), --weft-shadow, --weft-font, --weft-radius, --weft-radius-sm (and --weft-z, above). The panel's own tokens, not Studio's: light --weft-faint #766f61 (Studio's --faint reads 2.6:1 on paper, under AA; dark --weft-faint is Studio's --code-mut), --weft-parked (Studio has no purple), --weft-scrim and --weft-shadow. Two consequences of the Studio palette: in light, --weft-bg2 equals --weft-bg (Studio's --card is its --background), so the header, steps and footer differ from the panes by their line only; in dark, --weft-bg3 equals --weft-line, so fields (.weft-input, the narrow turn dropdown) draw their border in --weft-faint (3:1 on their fill). color-scheme on :host gives the panel's native controls and scrollbars the resolved theme. The one thing drawn in your page's flow — the "Studio not reachable" line a mount you made shows — sits on your background, so it takes your page's text colour (color: inherit, the retry link underlined), not a theme's.

The panel remembers, per origin, in localStorage["weft.devtools"] — one JSON object, {v: 1, mode, side, open, hidden, x, y, w, h, d, run, theme, raw, tab, debug}: the mode (float/dock), the docked side, open, hidden, the float's box (x, y, w, h, px), the dock's size (d), the last selected turn (run, by run id), theme (the ◐ choice: light, dark, or "" for auto), the raw view, tab (D4, additive to v1: the turn view's tab — story, request, timeline or raw; raw mirrors it, and a document without tab opens Raw when raw is true, else Story), and debug (the old localStorage.weft_debug=1 switch, migrated into the key and dropped). The placement fields are written only once you place the panel (toggle, drag, resize, re-dock): until then the data-* attributes above decide on every load; after, the stored placement wins over them. Another version is ignored; every read and write is guarded, so a private window just forgets. localStorage.removeItem("weft.devtools") resets everything (and turns debug off).

The keyboard (src/panel/element.ts's SHORTCUTS, the ? list). Alt+W is the one key the panel hears on window; every other key is heard on its shadow root, so it fires only while focus is inside the panel — a key typed into your page never reaches it, and keys typed into the panel's own fields are typing. Alt+W that opens the dock puts focus in it, so the keys below work at once.

Key Does
Alt+W toggle the dock from the page — not while a text field has focus (Ctrl+Shift+W too, where the browser delivers it)
Alt+Shift+W next layout: float, dock right, bottom, left, top
Esc close (the ? list first); it is not stopped, so your page's own document Esc handlers (a modal) still run — the panel never blocks them
j / k next / previous turn
J / K next / previous step (⤢ carries it)
g s open the turn and step in Studio (lib/links.ts, the link ⤢ carries), in a new tab
r the Raw tab (again: back to the tab before it)
/ focus the filter: the raw tree's on the Raw tab, else the turn list's
← / → on the tabs: previous / next tab (Home / End: first / last)
? the key list
↓ / ↑ on a turn row: move the list's one tab stop to the next / previous row (Enter or a click selects)
Tab in an open, focused float: wraps from the last control Tab can reach to the first (Shift+Tab back) — controls inside a closed <details> or hidden by CSS (the narrow float's list) are skipped. The float is non-modal (role="complementary", no backdrop, the page stays live), so this is the one focus trap, and Esc collapses the dock to leave it; docked, the pill and the sheet never trap

The renderer (plan D3). A draw builds the dock afresh and patches it into the nodes on screen (src/panel/render.ts's patch, about 1 KiB, no framework, no innerHTML — text nodes only): a child matches by its data-key — a turn row by its run id, a step card by its step ordinal (s<n>), a tool call or a parked call's line by its call id, a turn-dropdown option by its run id, a switcher option by its conversation key, the dock and the footer by name — else by tag and class (a key repeated under one parent pairs in order); a kept node is updated in place (attributes, text, a field's value only when the build says otherwise), and handlers ride on(), so a kept node answers with the newest closure. While a turn streams only its running step's card changes, and focus, the caret, the panes' scroll and the <details> you opened stay because their nodes do — nothing is restored by hand; a focused node is never moved (a move would blur it). The 100 ms live-draw throttle stays.

Element ARIA
the dock role="complementary", aria-label="weft devtools"
the turn list .weft-rows: role="list" (aria-label="turns"); each run a role="listitem" holding its row button, the selected one aria-current="true"; roving tabindex (one row tabindex="0")
the running step's text aria-live="polite" and aria-busy="true" while it streams (a reader waits instead of re-reading each draw) — the one live text region
the step-end status a visually hidden role="status" line, always in the dock, written once when the running step ends: step N finished · n words
expanders aria-expanded on how to scope, a subagent's summary, and each raw tree node's ▸/▾; the header's raw is a toggle, aria-pressed (D4: it switches tabs)
the turn view's tabs (D4) role="tablist" (aria-label="turn views") of role="tab" buttons with aria-selected, one tabindex="0", aria-controls on the selected one; the drawn panel role="tabpanel", aria-labelledby its tab
the filters aria-label="filter turns", aria-label="status", aria-label="filter keys and values"; ⧉ labelled copy <key>
icon buttons an aria-label on each: ⤢, ⇆, ◐, –, ↺, and the pill (its visible words, weft devtools · 10→4)
the switcher, the turn dropdown aria-label="conversation", aria-label="turn"
the experiment drawer the system prompt, input and edit fields inside their <label>; the selects aria-labelled model, thinking, side effects, engine, thread; the result pane's compare with; tool and breakpoint checkboxes inside their labels; – / ↺ labelled; the option lab's fields inside their <label>, its checkboxes aria-labelled only tools: <tool> / park on: <tool> in a labelled group, each ↺ reset <knob> to the agent's default

The accessibility budget. src/panel/a11y.test.ts runs axe-core (a devDependency only — a test asserts panel.js holds no axe) with its default rules over the panel's shadow root in each mode — float open, docked right, the bottom sheet at 400 px, the pill, the Raw tab with its tree open and filtered, the Timeline tab, the experiment drawer open (and its option lab with an override, a refusal and the named tool picker), and the Raw tree under the ? shortcuts overlay — in both themes, and fails on any violation, listing them. jsdom has no layout, so the rules that need one come back incomplete, never as a pass: color-contrast (D2's theme.test.ts holds the palette to WCAG AA instead) and label-content-name-mismatch (visible text needs layout); any other incomplete rule fails the budget. What jsdom cannot see — contrast as rendered over your page, focus-ring visibility, real screen-reader announcements (whether a reader waits on the busy stream and speaks the step-end status once is a manual browser-gate check) — is the browser gate's (scripts/panel-gate.ts against a real Studio, and a manual pass).

Where the configuration comes from (plan C2). Each field — endpoint, scope (or the deprecated public-id; scope wins at the same rung), token, detect, position, open, auto, global, mode, push, z-index, theme — resolves on its own; the first source that sets it wins, so a meta tag can carry the token while the script tag carries the endpoint. The panel never reads its own file name: a bundle served as /assets/devtools.abc123.js behind a proxy configures itself the same way.

# Source The explicit form
1 mount(opts) — a programmatic mount (import { mount } from "@weftgo/devtools", the npm entry; not in the script-tag bundle) mount({endpoint, scope, publicId, token, detect, position, mode, push, zIndex, open, auto, theme, target, enabled}) — mode/push/zIndex written as data-mode/data-push/data-z-index; enabled: false mounts nothing (null)
2 the <weft-devtools> element's attributes <weft-devtools data-endpoint="…" data-token="…">
3 meta tags <meta name="weft:endpoint" content="…"> (also weft:scope, weft:public-id, weft:token, weft:detect, weft:position, weft:open, weft:auto, weft:global, weft:mode, weft:push, weft:z-index, weft:theme)
4 the panel's <script> tag: the running classic script, else the first with a data-weft attribute (any src, any value), else the module script whose src resolves to the bundle's own URL (import.meta.url) — never a tag because it carries a data-endpoint, data-token or other generic data-* word: a third-party widget's tag is not the panel's configuration. The running script and the own-URL match apply to the standalone panel.js only (/studio/panel.js, or the package's panel.js served as its own file): imported through @weftgo/devtools the panel is part of the app's chunk, whose URL and <script> are the app's, so only data-weft counts and the endpoint default stays the page's directory <script type="module" src="…" data-weft data-endpoint="…">
5 panel-config.json beside the script (/studio/panel.js → /studio/panel-config.json), asked only when rungs 1–4 named no endpoint; its endpoint is taken on the script's own origin only, and it never carries a token name the endpoint at any rung above
6 the script's own origin + directory (setup A) name the endpoint at any rung above

A tag whose src is not the bundle's own URL (a bundler that inlined or re-served the panel, a loader script) needs data-weft on it (or a higher rung); without a tag to find, the endpoint is the page's own directory.

A weft:token meta tag, like data-token, is a token in the page's source: in HTML you ship, use a per-page panel token your backend mints (POST /api/panel-tokens), never a dev or server token.

No Studio answering: the dock the script mounted by itself removes itself silently — no console, at most two requests (panel-config.json, then meta; panel-config.json gives up after 3 s). A mount you made (a <weft-devtools> element in your markup, mount(opts), or data-auto="false") shows one quiet line instead, Studio not reachable at <endpoint> · retry, where retry asks again (the line reads checking… while it does). The artifact is built by studio/web/vite.panel.config.ts (a separate library-mode build), the committed studio/dist/panel/panel.js, 216,640 B raw / 62,591 B gzip (61.1 KiB, the ledger's last row; 18.9 KiB under the 80 KiB cap). The scope-detection ladder (C3: rungs 2 to 5 and the activity pill) cost +6.0 KiB gzip against its +3 KiB estimate: nothing deferrable supplies the first scope and the deferrable remainder is under 1 KiB, so the overrun is accepted rather than split. The host API below cost about +3.1 KiB against +1 KiB (with its review fixes); the layout (D1) +3.6 KiB against +4 KiB (with its review fixes); the keyed renderer and ARIA (D3) +1.5 KiB against +2 KiB (with its review fixes); the views (D4: tabs, the JSON tree and its filter, the turn filter, paging) +5.2 KiB against +5 KiB (with its review fixes). The Request tab (E1) measured +6.1 KiB against its +4 KiB estimate (with its review fixes): the overrun is the review's honesty work (derived records, the manifest asked again, the unreachable child, not_served), nothing in it is deferrable at first paint, and minifying the bundle is held in reserve, so it is accepted rather than cut. The transcript editor and its preview (F2) measured +6.3 KiB against +4 KiB with its review fixes: the overrun is review-driven honesty and correctness (the compacted-range pre-check, the pending and held Run states, steers by step, the int64 and 2^53 refusals, the fork hold), nothing deferrable supplies the editor's first paint, and 2.2 KiB of headroom remain, so it is accepted. make studio-panel-asset stages it as panel-<version>.js + sha256 for non-Go backends.

The size ledger. Every panel build (bun run build, make studio-build, make studio-check) prints the per-item size table, measured with the version stamp normalized — the stamped version string (scripts/weft-version.ts) replaced by the fixed-length placeholder "v0.00.0" (normalizeStamp in scripts/panel-budget.ts) before gzip, in the build and in budget.test.ts alike, so a version bump moves no ledger byte — and gzipped by gzipSize there, pako's pure-JS deflate at level 6, not node:zlib, whose bytes differ across the host Node versions vite and vitest run under (the ledger's measure note says which rows were measured which way): each plan item's estimate beside its measured gzip delta, over when the delta is more than half again the estimate (over (accepted) when the plan recorded why it was not split), the unbudgeted items, the baseline, and the total against the 80 KiB cap with the headroom left. The deltas come from studio/web/panel-budget.json, a committed, append-only ledger: one row per landed item — {"label": "D1", "item": "D1", "gzip": <panel.js's gzip bytes after it>}, item the budget line it counts against ("" for none) — and earlier rows are never edited. An item appends its row in the same commit as its rebuilt studio/dist/panel/panel.js: the build says when the built file differs from the last row, and src/panel/budget.test.ts — run by make studio-check — fails until the last row is the committed file, so a rebuild that forgets its row fails studio-check. The per-item marks are evidence, not a gate; the build itself fails only over the cap (vite.panel.config.ts).

The same file is on npm as @weftgo/devtools, for apps that bundle everything and ship no <script> tag (Vite, Next, SvelteKit). This is a second delivery, not a replacement: the handler above is still the canonical install. import "@weftgo/devtools" loads the package's panel.js, which has the same sha256 as /studio/panel.js of the same version, and so does what the tag does. Beside it are a thin typed module (mount, scope, open, close, toggle, on, serializeScope/parseScope, and the host API below: select, isOpen, studioLink) and the /react, /vue and /svelte helpers, which set the data-weft-scope marker and are not components. The package has zero runtime dependencies and its version is the weft version. studio/web/npm/README.md has the API table. The root entry is import-safe on a server (the assembled index.js imports panel.js between ssr-guard.js and ssr-unguard.js, which lend its element class a placeholder HTMLElement where there is none; every export is a no-op without a DOM). mount() is idempotent (a second call reuses the element the first made, with the new options), waits for <body> when called from <head>, and with the helpers takes enabled (enabled: import.meta.env.DEV keeps production visitors from loading the panel). make devtools-npm assembles the package in studio/web/npm, runs the package suite against the assembled files and lists the tarball. make studio-check fails if the package's panel.js is not the served one, an exports/types target is missing, or package.json or the package README names another version; the same check is the package's prepublishOnly, and publishConfig.access is public. Publishing is done by hand. A Vite app that installs the packed package is in examples/devtools-vite (make devtools-vite-check).

The host API (plan C4) drives the panel from the page's own UI — a "debug this" button beside a reply, a "report this run" link — with no Studio URL in the page. It is the <weft-devtools> element's own methods, one implementation for both installs: under the script tag the bundle publishes them as window.weft.devtools, the one global the panel adds (below); from npm the package's exports call them on every panel on the page.

Method What it does
open(), close(), toggle(), isOpen expand, collapse or flip the dock; isOpen reads it (from npm, isOpen())
scope(s) the explicit scope (rung 1: it wins over the URL, markers and headers, and its run pins): a Scope or its string form ("pub_…;session=…;run=…"); sessionId/runId are read as session/run, so a run event's detail can be fed back; one naming neither a public id nor a session reads scope: no public id or session. scope(null) clears what scope() set and puts back what mount() named, so the ladder decides again (a data-scope in your markup stays yours)
select(runId, step?) selects that turn, and the step by its ordinal (G1), once a scope() just before it has settled; ⤢ then carries the step. A run the list does not show is read by id (GET /api/runs/{id}, scoped by the token) and joins the list when it is the conversation's (a run with no session id — a playground run — is not judged by a session narrowing, as in the list); else the line run r_… not in this conversation (run r_…: Studio did not answer when the read failed). A step the run does not have reads step n not in run r_… · showing step m, and the last step is carried. A call made while the panel's start hangs waits 10 s, then reads select r_…: the panel is not connected
on(event, cb) → unsubscribe follows one event (below); a cb that throws is swallowed
studioLink(runId, step?) the Studio page of that run (and step) through lib/links.ts — the link ⤢ carries, never with a token; "" before the panel knows its endpoint

A scope naming a session and no public id (scope({publicId: "", session: "s_…"}), or ";session=s_…") is resolved through GET /api/sessions/{id}/public_id, which only setup A and the dev token may ask: under a panel token the panel does not ask, and says session s_…: the session lookup needs the dev token (a 403 reads the same); a session created without thread.PublicID reads session s_… has no public id · not recorded …, an unknown one … · unknown session, a lookup Studio did not answer session s_…: Studio did not answer the lookup. The panel keeps its scope and nothing is thrown.

The events are CustomEvents named weft:run, weft:parked and weft:error, dispatched from the element (bubbles, composed) after the panel's own work (a microtask), so document.addEventListener hears them too. Only runs of the conversation followed are reported (none in the fallback), and only transitions the panel sees: a conversation's first read of its list is history — only a run reading running there, and the run the scope pins, are reported.

Event detail When
run {runId, status, publicId?, sessionId?, step?} — status the turn list's word (running, succeeded, failed, parked, interrupted), step the run's last step ordinal the panel knows a run starts, and each status change, once per transition
parked {runId, callId, ackId, name} — ackId is the id the approval names, the call id weft.Approve/Deny/Resolve and POST /api/runs/{runId}/approvals (call_id) take, so it equals callId. The app approves its own turns through its Session, s.Decide(ctx, thread.Approve(ackId)); the Studio route is for playground runs only (403 for a run no runtime started) once per call a run parked on (read from its run_finish's pending list)
error {message, runId} a run of the conversation failed: its error, once per run — run errors only (the panel's own trouble, Studio not answering, is a line in the panel)

The global: while a <weft-devtools> from the script tag is connected, window.weft.devtools is its API object (window.weft is created only when absent — an existing plain object gets the one property, anything else is left alone and the footer says global: window.weft is the page's; a devtools of your own is never replaced). It is deleted on disconnect (with window.weft when the panel created it and nothing else is in it). data-global="off" on the tag or element (or <meta name="weft:global" content="off">) keeps it off; the npm package adds none. examples/studio-local's page uses it: "debug this" per reply, "report this run" from on("run") and studioLink, and on("parked") counted into <body data-parked-count> (ask it about a refund: the turn parks on refund_order, a weft.RequireApproval tool).

The scope header (plan C3, rung 2, the development rung) is set by the app's own handler, since thread has no HTTP layer: one line, mux.Handle("POST /chat", scope.Header(chat, func(r *http.Request) scope.Scope { return scope.Scope{PublicID: pubOf(r)} })) (package github.com/weftgo/weft/scope), sets Weft-Scope: pub_… on every response, and a handler that knows the run once its turn starts calls scope.Set(w, scope.Scope{PublicID: …, RunID: turn.RunID()}) before writing (examples/studio-local's /run answers Weft-Scope: pub_demo;run=<id>). It never carries a token. The value is the data-weft-scope marker's string form, pinned for both sides by studio/testdata/scope.golden.json. Both helpers append Weft-Scope to Access-Control-Expose-Headers; a page on another origin also needs your CORS policy to allow its origin. Put scope.Header inside your CORS middleware, or list Weft-Scope in its exposed headers. The panel reads the header through rung 2 of the scope-detection ladder above (on by default with the page and the endpoint on loopback and no or a dev token, data-detect="headers" elsewhere; same-origin responses, or cross-origin ones between two loopback origins) — examples/studio-local's tag names no scope, and its first /run scopes the panel to pub_demo and pins that run.

The playground (WEFT-PLAYGROUND.md)

Your app dials Studio out and executes experiment commands as runs of the agents it registers — setup A is five lines (runtime/examples/local is the runnable demo behind the P0 curl gate):

defer runtime.Install(runtime.Local(srv), // or runtime.Studio(url, tok)
    runtime.Agents(support), runtime.Limits(runtime.Budget{MaxTokensPerExperiment: 200_000}),
    runtime.AllowSideEffects("send_email"))() // a write tool; a read is weft.Replay(weft.ReplaySafe)

Studio's side is studio.New(..., studio.Playground(true)), which serves GET /api/runtimes (the registered runtimes, each with connected: whether it holds a live command stream now — one whose stream dropped stays listed, connected: false, until its commands resolve, and a reconnect re-registers under the same id), POST /api/playground/runs (the §10.4 validation table: 400 unknown tool/model names and unsupported 8b modes, 403 raised limits or a refused side-effect tool, 404 unknown runtime/agent/source run, 409 a reused command id, 503 with no runtime connected) and GET /api/playground/commands/{id}, plus the runtime link's own routes (POST /api/runtime/register, GET /api/runtime/commands SSE, POST /api/runtime/acks). Registration carries heartbeat_ms; while a command is held the runtime re-posts its accepted ack at that cadence, and a repeated accepted ack is liveness: on an accepted row it restarts the finish watch (max(10 min, 3 beats)), on a lost one it is a 409, on a finished one a no-op. Safety: off unless WEFT_ENV=dev or runtime.Enabled(true); overrides only narrow; a side-effect tool's call is substituted with its recorded result or parked (weft.Replay(weft.ReplaySafe) vouches a read, AllowSideEffects opts a tool into allow mode); budgets cap each experiment; the app's own runs are never touched. The command's overrides carry instructions, tools_enabled, model, thinking, the numeric options (max_steps and parallelism lower only, temperature 0..2) and the option lab's typed knobs: params {top_p?, max_tokens?, stop?, seed?} (neutral; laid over the agent's own params — an absent field keeps the agent's value, and a command cannot clear the agent's stop or max_tokens; top_p 0..1, max_tokens positive, at most four non-empty stop sequences), tool_choice {mode: auto|any|none|named, name?} (a named choice must name a tool the command keeps on and does not park; with none sent, an agent default naming a tool the command turns off is refused — send tool_choice), park_on (a parked call stops there even in substitute mode) and only_tools (tools of the agent; only_tools inside tools_enabled when both are sent, 403 otherwise). model is the agent's own, a runtime.Models name, or — when the runtime registered runtime.ModelResolver(func(ctx, name) (weft.Model, error)) — any name the app's resolver accepts: it runs before the ack (bounded at 10 s: "resolver timed out"; a resolver that ignores ctx is abandoned at the bound), its error text is the rejected command's reason (model <name>: …; the app writes that text, so it carries no key or URL), and without a resolver Studio answers 400 unknown model … : register it with runtime.Models or add runtime.ModelResolver. GET /api/runtimes gives each agent resolver and defaults {max_steps, parallelism, thinking, temperature?, top_p?, max_tokens?, seed?, stop?, tool_choice} — the run defaults the form greys beside each override. Version skew: a runtime older than the option lab registers no defaults and would run a command without params, tool_choice, park_on or only_tools (it does not know them), so Studio refuses any of the four for it (400 runtime predates the option lab: upgrade weft/runtime …); an old-shape command still runs. A subagent's child run replays as its own agent, registered on the runtime by that name (runtime.Agents(parent, child)): the replay carries weft.forked_from="<child id>#<from_step>" and no parent linkage (ADR 0029). A replay from step N is fed what step N's model call carried, compaction view included; the replayed agent's own PrepareStep then runs over it, so the model's input is exact when the agent's PrepareStep is idempotent over its own output — a count- or index-keyed PrepareStep re-shapes the replay's input. from_step may equal the run's step count when its last step ended in answered tool calls (a run that failed answering them): the replay's first model call answers them, so "edit the result, replay from the next step" works.

P1–P5 ride the same command: transcript_edits (validated on both sides — a patch names a call in the kept prefix, the prefix ends at a step boundary with every call answered, and an edit to a message from_step's compaction view replaced is refused: the replay prefix is what the model saw at that step, ADR 0029), engine: scripted (the source run's recorded turns at zero tokens; scripted + an instructions/model override is refused — the §5.5 prompt trap), thread fork (a new session with lineage the panel can keep chatting in), POST /api/runs/{id}/approvals (a parked run's continue/skip/resolve — ADR 0007's own verbs), POST /api/playground/fixtures (the run's records as wefttest replay fixtures; refused to a read-scoped panel token, 403 with badge hidden; 409 with its badge when there is nothing to fixture), and GET/POST /api/experiments with GET /api/experiments/{id} (the saved groups, PQ4). The debugger's rungs 3–4 act on runtime-started runs only (D7, PQ7): PUT /api/runtimes/{id}/breakpoints (capability breakpoints) and POST /api/runs/{id}/steer (capability steer) — meta.debug_scope says so. The panel's experiment drawer (the §3 form, the live result with the inline diff, the approvals, the 2-way compare) and the Studio /playground page (variants side by side with the metrics, the E9 variants × inputs matrix, the experiment history) both render them, each control only for its reported capability. The run page's replay drawer (plan F1: its verbs on every step, call, steer and child row, the ack preview naming what each tool's calls would do) posts the same command, and the playground's "continue from step" is a picker over the source run's steps.

The option lab (plan F3) is one form on both surfaces — Studio's experiment form (the playground page and the replay drawer) and the panel's experiment drawer — with a knob for every override the command carries: max steps, parallelism and temperature (options), top_p, max tokens, stop (one sequence per line) and seed (params), tool choice (auto, any, none, named and its tool), only tools and park on, beside the prompt, tools, model and thinking. Each knob shows the agent's default from GET /api/runtimes greyed (the placeholder, or the default option's words); a value that differs from it is drawn in colour with a ↺ back to the default; an empty knob, or one set to the default, sends nothing ("absent keeps the agent's value" — the form cannot clear the agent's stop or max_tokens either). The model is a select over the runtime's models, plus free text when the agent's runtime holds a runtime.ModelResolver (the app resolves the name; its refusal is the rejected command's reason, shown as given). Widening is never silently sent: src/lib/experiment-body.ts's labOverrides mirrors the server's rules by name — caps lower only, the ranges, a tool the agent lacks, only tools inside the tools left on, a named choice the command turns off or parks, the agent's own named default turned off — says each refusal on its knob in the server's words and holds Run; a tool turned off is greyed out of only tools (a box already ticked stays clearable) and the named tool picker; park on lists every tool of the agent, unrestricted. A cap of 0 is no bound: no default shown, no refusal. The server stays the authority: its 400 is shown verbatim where the command's error goes. A registration without defaults (a runtime older than the option lab, whose defaults /api/runtimes omits, or a Studio older than the field) shows the knobs with no default and greys the seven option-lab controls the old runtime cannot carry — top_p, max tokens, seed, stop, tool choice, only tools, park on — saying why (runtime predates the option lab …). The model's free text is kept as typed (the name sent is its trim). The ack preview follows park on (those calls park) and only tools (the others are off). Both surfaces build the command's overrides through labOverrides, so they post byte-identical JSON for the same choices (src/panel/optionlab.test.ts: a table over testdata/api/runtimes.golden.json and the parity check).

The transcript editor (plan F2) is the Story view's, on both surfaces — the run page and the panel's Story tab — under the replay drawer's gate (canReplay) and on a run that is over: step 0's prompt, a steer, a call's args, a tool result and a call-free reply turn into a textarea in place (click the thing, or Enter on its pencil), with revert and a red "edited" chip, and every step boundary up to the highest from_step the server accepts takes an inserted message ("· insert a message before step N" — a steer at rest). The edits are the five kinds of transcript_edits — user (step, index), tool_args (step, call_id, args an object), tool_result, reply, insert — and accumulate into one command: the first opens the replay drawer ("Edit the transcript and replay"), which lists them by kind (each droppable), keeps from_step at what they imply (an edit of step N keeps N, so N + 1; an insert at boundary N, N) and runs them on engine live, saying why (the scripted engine replays recorded turns, which answered a different prompt). Args are JSON, checked in the editor against the tool's input schema from the step's catalog record (src/lib/edits.ts's checkArgs, obsdb.CheckToolArgs' rules by name — type and type lists, integer literals, enum, required, properties, additionalProperties, items, a null field absent): a refusal is said on the field in the loop's INVALID_INPUT: tool "x": field "f": expected …, got … words, never sent, and holds Run — the check matters most for a read-scoped preview, whose server check is the object shape only. Both surfaces build transcript_edits through lib/edits.ts's wireEdits (kind always), so they post byte-identical JSON. Above Run, "will be sent" (capability preview; nothing asked for without it, nor for a fork): POST /api/playground/preview with the very body Run would post, re-read 300 ms after the form rests, drawn through src/lib/preview.ts — the system's state (same, changed, unknown, hidden), the messages one row each with the server's op (same, changed with was struck through above will, added, removed), tools added and removed, the knobs that changed (model, params, thinking, tool choice), every *_badge from the honesty table, the warnings one line each (prepare_step always, compacted, derived, scripted, instructions) and unchecked as a line naming what only a runtime checks. Its 400 is the command's own sentence, shown verbatim, and holds Run. A replayed run whose row carries weft.edits opens its Story with "kept prefix edited": one chip per token — "args edited", "result edited", "edited" (a user message or a reply), "inserted" — at its source coordinates, a call's edited args or result read from the replay's input by call id (the kept prefix is positional); a child run inherits the mark and draws none. An editor follows its command: Escape inside a Story textarea is the field's (the drawer stays); closing the drawer, another verb's opening, a drop from the list or the drawer's own field rewriting the edit resets the textarea to what the command holds (the recorded text when nothing). A steer's user edit is the steer's own step's (its stored step, then its place among that step's user messages). The schema is the run's last catalog naming the tool (studio's server checks against it; the edited step's own catalog is a Go-side debt). Numbers the browser would change (past 2^53 exactly, out of range) are refused; without JSON.parse source access the integer-literal check is marked unchecked, never passed. A fork never carries edits — the drawer says so and holds Run. Run also waits while the preview of a changed command is pending, and an edit inside the compaction view transcript?step=<from_step> names (compacted_at) is refused in the server's words before anything is posted.

The step compare (plan E3, capability diff; hidden when off, like every capability-gated control): two or more runs side by side, one row per step ordinal, a column per compared field — system, tool calls, tool results, text, usage — each cell same, changed (with the value), not comparable (the server's unknown: a side did not record the column, never read as the same) or missing (one run has no such step), and above the table one "changed at step N" marker per changed step, linking to that step. Each side's marks (compacted, subagent, max_tokens, parked, running, interrupted, error) are chips beside it — never a change by themselves — and its holes are badges from the one table, as is the response's own truncated (response_cap). Both surfaces read GET /api/diff through one module, src/lib/stepdiff.ts: Studio's /compare?a=<run>&b=["<run>",…]&step=N page (lib/links.ts's compareLink; N-way is N−1 calls of the 2-way route against the base run a, drawn in the first column; step highlights and scrolls to that ordinal's row; a mark the side's holes already carry is drawn once, as its badge; a system prompt withheld from a read token is the hidden badge in its cell, beside the state the hashes gave) — reached from the run header ("compare with source" for a replayed run, via weft.forked_from, and "compare with…"), the replay drawer's finished result, and the playground (every finished variant against the source run, beside the final-text diff); the panel's experiment result pane draws the 2-way table (the replayed run against its source) at panel width — "reading the step compare…" while it loads, each marker a link to its step — with a ⤢ hand-off to the same compare page at the first changed step. src/panel/parity.test.ts holds both to the same rows, markers and badges from the same goldens (studio/testdata/api/diff*.golden.json).

The runs list (light): status as a dot and a word, the error under a failed run's id, session/public-id/experiment filters that mirror the URL, the session column linking each turn to its thread, and a follow toggle that streams an agent's runs as they happen:

runs list, light theme

The run page (light) — the prompt and the answer first (the answer now comes from the transcript: deltas are live-only), then the facts; the trace draws on the time axis when the run has spans (real durations, ?axis= picks; the position axis keeps the replay playhead), and subagent children sit as nested rows under the step that called them (agent, status, usage, holes, a link) and expand lazily — their events and their request record (the child's prompt, read by the child's id) are their own runs', fetched on demand. Each step card's Request pane shows what the step called the model with: the system prompt (diffed against the previous step, or at the first step against the registered instructions when they differ; never over a cut record, and "too large to diff" past 2,000 differing lines a side), the tool catalog (description, schema, policy chips), every param ("adapter default" when nil), tool choice, thinking, the attempts and the messages sent (count and bytes computed from the transcript as JSON, the last three inline, the rest as a raw tree), with the chips the hashes decide — changed by PrepareStep (system_hash moved with the tool set unchanged, or at the first step is not instructions_hash plus the offered tools' snippets as a manifest verified for the run names them), the neutral prompt changed at this step when a changed tool set may explain the move (a ToolSource tool, an unverified weft.json), overridden by experiment (the invoke_agent span carries weft.override.instructions; the story view reads the spans once the run ends) and catalog changed at this step; a read-scoped token sees the hidden badge and nothing else (examples/studio-local's PrepareStep trim is the live demo; a derived prompt or catalog record is badged and never diffed over):

run page with the trace, light theme

Sessions list a thread's turns in order with usage and the newest status; /live shows everything streaming right now; /traces/{id} renders any trace — a stock OTel GenAI app's included — as a span tree plus a chat view when semconv content was captured.

Every view is a URL (plan G2): each piece of page state lives in a search param, built and read through src/lib/links.ts. A cursor move replaces the history entry; a view change pushes one — the back button walks views, never cursor moves:

page key state history
runs list agent, status, … the filters push
run view=story|raw the view (trace is the default) push
run raw=doc the raw view's surface push
run step the step (its ordinal) — (a link's landing)
run sel, d, axis the selected span, detail mode, axis replace
run t the replay playhead (seeks and pauses) replace
run q, hide, ev the raw view's search, hidden kinds (hide=step,delta), open event's position replace
run replay, from, of the replay drawer: its verb, its from_step, the child run it replays (a fresh load reopens it on that step, from the verb's draft — edits are not in the link) replace
trace view=chat the GenAI chat view (the tree is the default) push
trace span the selected span replace
playground run, step, agent, runtime, engine the source run, its step, the target, the first variant's engine — written back as they change replace
playground experiment a saved experiment, highlighted in the history push
compare a, b, step the base, the runs compared, the step push

The panel hands the playground its prompt and other fields in the fragment (playground#run=…&instructions=…), read once and stripped. The page never writes a prompt into the query (an older link's ?instructions= / ?input= are still read). The playground writes its state from its controls; a change from outside (Back, Forward, a link followed while it is open) is adopted into the controls and never written back. Copy link — the button in the run header, y anywhere, or "Copy link to this page" in ⌘K — copies the page's canonical link: the bare URL with its state, never a fragment, a token, a panel scope or prompt text (lib/links.ts's canonical drops token, access_token, sig, weft_scope, input, instructions); the header says "link copied". Where the browser refuses the clipboard (plain http off loopback, a denied permission) it falls back to execCommand("copy"), then shows the link in a prompt, and says "copy failed". Each page titles its tab through lib/title.ts: run s_…-t3 · succeeded · weft studio, trace 0af76519…319c · weft studio, playground · weft studio.

Notifications (plan H5): one toaster, bottom-right, in the app's theme, with no hotkey (it never takes focus or a key from the page), and every toast's words in src/lib/notify.ts — raised once per instance (a command, a run, a runtime, a stream's give-up, a press), never again on a re-render or refetch. experiment … finished · status — a playground variant's or the replay drawer's command settled (useCommandTracking), with "open" to the run; a matrix raises one when its last cell settles, with "open" to the experiment. run … parked at tool — a run a page follows parked (useRunEvents: a playground card's run, or a run that parks while its page is open — a run opened already parked says so on its page only; the toast goes when this page decides it, or when its approve finds the park already decided — it reads the run's row first, then follows the command its post became and says a rejection), with "open" and, for one parked call of a runtime-started run where the playground is on and the token may act, "approve" (POST /api/runs/{id}/approvals). runtime … connected / disconnected — the live stream has no runtime kind, so the shell reads GET /api/runtimes every 5 s (playground capability; never under a read-scoped panel token) and raises one toast per transition of a runtime's connected (a runtime gone from the list counts as disconnected, once; a failed read says nothing; each flap of the same id is its own toast). A runtime that goes down and comes back inside one 5 s poll interval is not noticed. live updates stopped after 6 reconnects — lib/live.ts gave up (never on a clean close or a panel token's expired), with "retry", which starts every stream that gave up over. link copied / copy failed — copy link, beside the header's own words. A toast is a view: it names ids, tools and counts, never a token or a prompt, and acts only when its action is clicked.

Panes and density (plan H3): three splits resize — the trace page's span tree beside the selected span, a run step's story beside its open Request pane (one size for every card), and the playground's config column beside its runs column. Drag the handle, or focus it (Tab) and use the arrow keys, Home/End; a double click puts both panes back at their defaults and forgets the saved size (the split's other instances follow). A split never remounts what is in its panes — not when a step's Request pane opens or closes beside its story, not when the window crosses the phone width — so a half-typed draft (an edit in the Story, the playground's free model name) survives. Each pane keeps a minimum size, so nothing — a badge, a hole mark — is squeezed out of sight. The sizes are a per-device preference, never the URL: localStorage["studio.panes.<split>"] (trace-detail, story-request, playground) holds the panes' percentages, and a missing, unreadable or garbled value opens the split at its default. Reset pane layout in ⌘K forgets them all and puts the open splits back. Density — comfortable (the default) or compact — is the toggle beside the theme toggle in the sidebar footer and "Compact density" / "Comfortable density" in ⌘K: data-density on <html>, set before paint by the bootstrap and stored as localStorage["studio.density"] (the toggles, like the theme toggle, draw the default on the hydrating render and the stored choice right after, so the prerendered shell always hydrates cleanly); compact scales the theme's own tokens (Tailwind's --spacing step, --text-xs/--text-sm/--text-base, the body's --studio-text) under [data-density="compact"], so every page follows. At phone width (≤ 640 px) the splits stack: the trace detail under its tree, the playground's config column above its runs column, the page scrolling as one with no horizontal scroll; its header controls wrap, its result cards go one per row, and the cards and the matrix table scroll inside their own box. A step's Request pane opens beside its story from the lg width (1024 px) up, inline above it below that. The playground's controls are the shared components/ui Select (select-field.tsx) in theme-token colours: the lint rule controlRules (eslint.config.js, pinned by src/lib/controls.lint.test.ts) refuses a native <select>, a raw Tailwind palette class (text-red-500, bg-amber-100, Tailwind 4.3's mauve/mist/olive/taupe, …) and an arbitrary colour (text-[#…], bg-[rgb(…)], border-[oklch(…)]) in the playground, the experiment form and the split, density and select components.

Keyboard: ⌘K jumps to any recent run, / filters, j/k move, enter opens, e/s/r switch trace/story/raw, space replays, [/] jump by step or tool event, ,/. move one event, y copies the page's link, ? lists everything. Keys fire only on a bare press outside a text box.

The API (S4.2/S4.3)

JSON under {base}api/: meta (versions, db {kind, path, size — path and size to loopback and the server token only}, pid, ingest_open, interrupted_after_ms, capabilities, capabilities_off {capability: why}, manifest_sources), runs (agent, status, session, public id, playground, parent and tag.<k>=<v> filters, cursor-paged; top-level only by default — parent=<run id> lists one run's subagent children, all=1 (or parent=*) every run, children included), runs/{id} (the row and the subagent children, each child row with its own holes — never events; with delta_count (the streamed deltas, counted and never stored), instructions_hash, catalog_hash, request_count and, for a run written before ADR 0028, requests_badge: "not_recorded"; and compactions — ADR 0028 §8's run-scope views {scope: "run", index, step, from_seq, to_seq, hash, replaced, entries} and thread's session markers {scope: "session", hash, replaced, entries, tokens_before?, tokens_after?, reason}, counts and hashes only, [] when none), runs/{id}/events?after=&limit= (the paged durable stream), runs/{id}/transcript (the messages bodies; with ?step=N also step, messages — what step N's model call carried, the record's truth and the replay prefix for from_step N > 0 (ADR 0029; from_step 0 re-runs step 0's PrepareStep over the original input) — and compacted_at, the run-scope view that request carried {index, step, from_seq, to_seq, hash, replaced, entries} or null; badge: "derived" when no request record placed them, messages: null with badge: "hidden" for a read-scoped panel token over a view; 404 past the run's last step, 409 with the hole — gap, stripped — when the records do not rebuild it), runs/{id}/spans (for a read-scoped panel token — here, in traces/{trace_id} and in the export alike — without the tool names a run's overrides put on its invoke_agent span: weft.override.tools, park_on, park_all_except, and a named tool_choice cut to its mode), runs/{id}/requests?step=&from=&limit=&refs=1 (one row per model-call attempt, the prompt and catalog its hashes name inline unless refs=1, next_from while pages are full) and runs/{id}/tools (the run's catalogs by hash) — both under the requests capability, refused to a read-scoped panel token (403, badge: "hidden"), and badged not_recorded / stripped rather than empty when there is nothing to show for a reason (a child run id whose last segment is itself one of these sub-route names — events, transcript, spans, requests, tools, export, logs — is shadowed by the route; provider call ids never collide), runs/{id}/logs?from=&limit=&severity= (the app's own log lines — the non-weft records an slog bridge or the OTel Logs API emitted under one of the run's spans, or an app span below one — in time order, {index, time, severity, severity_number, body, attrs, span_id?}, next_from while pages are full; severity keeps a level and above without renumbering; under the logs capability; holes: [{hole, reason, fix?}] lists every hole of the page ([] when none) and badge/reason/fix repeat the first: not_recorded (a run recorded without a tracer), truncated (the run's traces hold more than 10 000 log lines in its window — the cap applies before attribution, so a flooding subagent or sibling counts, and later lines of this run may be missing), gap (lines in the run's trace and window name a span that is not stored — dropped, or still open — and may be this run's or another's); a running run's page is partial: true with a partial_reason — lines under in-flight spans appear once those spans end, and indexes may shift — a live condition, not a hole, so partial can appear with no badge (then reason carries it too); app logs may carry prompts, so a read-scoped panel token is refused them, 403 with badge: "hidden"), runs/{id}/steps/{n} (one step assembled server-side, n its ordinal, under the steps capability: the step's status, timing, the model requested and the one that answered, attempt 1's request row, every attempt with its model and outcome, messages_in, its events, its tool calls with their spans and results, the child runs they started, usage, the compaction view it saw and holes — every absent block a badge with its reason and fix; scoped like events, except that a read-scoped panel token gets the request block as {badge: "hidden", …} inside a 200; a step past the run's last, or not yet started, is 404), runs/{id}/export?format=json|jsonl|otlp|wefttest (the whole run as one download, Content-Disposition: attachment; filename="<run id>.<ext>", under the export capability: json is one document — the run as runs/{id} serves it, every event, the transcript batches, the compactions, the request records with the prompts and catalogs they name keyed by hash, the spans and holes, every absent block badged; jsonl is the same records one per line, each with a "record" kind, in a stable order; otlp is {"logs": ExportLogsServiceRequest, "traces": ExportTraceServiceRequest} in OTLP/JSON, which POST /v1/logs and /v1/traces read back into the same run; wefttest is the run's model calls as replay fixtures, zipped flat — unzip into testdata/<TestName>/ and wefttest.Replay answers, a step whose request carried a compaction view keyed on it and noted compacted_at. The run block's children carry their holes as runs/{id} serves them. A read-scoped panel token gets json and jsonl with the request block {badge: "hidden", …} and each compaction view's messages null under the same badge; otlp and wefttest are 403 with that badge), diff?a=<run id>&b=<run id> (two runs compared step by step, under the diff capability — plan E3: {a, b, steps, summary, holes}, a/b {run_id, steps, status}; one row per step ordinal from 0 to the longer run's last — the ordinal is the alignment key — {step, changed, a, b, changes, unknown}, each side the step as runs/{id}/steps/{n} assembles it, reduced to {status, system_hash, system, tool_calls: [{name, args}], tool_results: [{call_id, name, content, is_error}], text, usage, marks, holes}, or null on the side whose run has no such step (changes: ["missing"]); changes lists what differs, in the order system (by hash when both sides have one), tool_calls (name(args), the args canonicalised: keys sorted), tool_results (content and is_error, in call order — call ids are not compared), text (the step's assistant text from the transcript, exact) and usage (the numbers); status, timing, marks and holes are never compared. A column a side could not record is never compared with the other side's value: it goes to unknown — "not comparable", neither changed nor the same — and the side's holes say why: system when a side has neither a hash nor the text (a run before the request record, a dropped request record; hidden and content-off keep the hash, so they still compare); tool_calls, tool_results and usage when a side's events are not all stored (a lost step_start, missing positions inside the step); tool_calls by name alone and tool_results unknown when a side was content-off; text when a side's text is null for a hole. marks say what the side's step was — compacted (a compaction view: the model saw other messages, which the diff does not compare), subagent (a call started a child run), max_tokens, parked, running, interrupted, error — and never flip changed by themselves: a compaction or a subagent marks its side, never forced to align. Every column a side lacks is a hole in its holes, from the same table — system: null under hidden for a read-scoped panel token (the hash stays: instructions_hash is read-scoped too, so the column still compares), not_recorded for a run written before ADR 0028, text: null under gap when no messages record of a finished step was stored, or when a messages record of the run does not parse; summary is {changed_steps, first_changed} (first_changed: null when nothing differs); the top-level holes is truncated with the table's response_cap cause when a run has more than 500 steps (the first 500 are compared; each side's steps is still the run's count). Both runs must be inside a panel token's public id (403 whichever side is outside; an unknown run 404; a missing a or b 400); a == b is every row unchanged. The route is two-way: an N-way compare calls it N−1 times against one base run), traces/{trace_id} (any trace), sessions, sessions/{id} (turns in order), sessions/{id}/public_id (the reverse of public/: {session_id, public_id}, the public id thread.PublicID stamped on the session's turns — MAX(weft.public_id) over its top-level turns: one value per thread session; a session whose turns carry several answers the greatest, unbadged; public_id: "" with badge: "not_recorded" when the session was created without one, 404 for an unknown session; the dev token's and setup A's alone — every panel token, read or playground, its own session included, is 403 with badge: "hidden"; session ids carry no /, so the sub-route shadows none), public/{public_id}, manifest (it carries the agents' system prompts: 403 with badge: "hidden" to a read-scoped panel token), POST /api/panel-tokens

(mint; the panel's scoped tokens — see the devtools panel above), and GET /api/live — the SSE stream whose frame ids are the hub's Seq: exactly one selector (run/session/public_id/agent), kinds over event/delta/messages/run (default event,run; deltas are opt-in, heartbeats never), a ping every 15 s, Last-Event-ID resume with the gap backfilled from the database and deduped on (run, kind, pos), and event: overflow when a slow subscriber's queue drops it. A token travels in the Authorization header only — ?token= is refused on every /api route, in every setup (401 naming the grant) — so a browser's EventSource, which cannot set headers, opens the stream with a grant: POST /api/live-grant (authenticated like every route; the selector and kinds as a JSON body {"run":"r_1","kinds":"event,run"} or as the query, not both) answers {sig, exp}, and GET /api/live?run=r_1&kinds=event,run&sig=<sig> opens that one stream as the identity that asked. The sig is an HMAC-SHA256 over that identity (the server token, a panel token's public id and scope, or setup A's open API), the selector, the kinds set (order-free) and exp — 60 s, never past a panel token's own expiry; another selector, another kinds set, a tampered or an expired sig is 401, and a panel token is refused a stream outside its public id at grant time (403), as the stream would refuse it. Without a Token the key is random per process: one mechanism in every setup. A grant is a 60 s window, not a single-use ticket: any number of streams may open with that sig while it lasts (a browser's EventSource reconnect reuses the same URL, so a single-use sig would end the tail at the first reconnect), and each runs until it is closed. A leaked grant URL is therefore live for 60 s — the bearer is never in it, the grant answer is Cache-Control: no-store, and Studio never logs the URL. A panel token's stream (opened with a grant or with the bearer) ends at the token's expiry with one final event: expired frame (data: {}) and closes; a server token's does not (nor setup A's). After expired, reopen only with a bearer that is still valid — a new panel token, then a new grant. Both clients do exactly this (lib/live.ts's openLive for the UI, openPanelLive for the panel): a grant before every connection, the browser's own Last-Event-ID reconnect kept inside the grant's 60 s — measured on the page's clock from the answer's Date header, so a skewed client clock neither spends every grant at once nor trusts a spent one — a new grant (and a fresh connection, refetched) after it or after a refused stream, on the backoff when it keeps failing. After expired the UI asks again only with a fresh bearer (a pasted token); the panel stops. A grant refused 403 (a panel token asking an agent's stream: the live page, the runs list's follow) is asked once; the page says "streaming needs the server token · polling" and polls. A fresh connection carries no resume cursor — the header is the only way Last-Event-ID is read — so the clients refetch pages instead, the run page's tail on its first open too. The UI adopts a token from the link's #token= fragment only; a ?token= in the address is stripped, never kept. The #token= fragment weft open and weft studio --open hand the browser never reaches the server; the UI reads it. OTLP ingest is POST /v1/traces and /v1/logs (protobuf and JSON, gzip, 16 MiB after decompression, publish-then-write, 503 on a write failure so the exporter retries). Under Playground(true) the playground's routes join (GET /api/runtimes, POST /api/playground/runs, GET /api/playground/commands/{id}, POST /api/runs/{id}/approvals, POST /api/playground/fixtures, GET/POST /api/experiments, GET /api/experiments/{id}, PUT /api/runtimes/{id}/breakpoints, POST /api/runs/{id}/steer — see the playground above) beside the runtime link's own (POST /api/runtime/register, GET /api/runtime/commands SSE, POST /api/runtime/acks — server token only, never a panel token); /panel.js serves the devtools panel bundle — static and unauthenticated; /panel-config.json ({endpoint, version, capabilities}, capability panel-config) answers a loopback (or AllowOrigins) Host and a same-origin, AllowOrigins or loopback Origin only, else 404.

Capabilities are computed from the registered route groups (routes.go) — never hard-coded: live (the stream and its grant), ingest, auth (with a token), and runtimes, breakpoints, steer + playground under Playground(true), plus anything a hosting wrapper declares with studio.Capabilities(…). The panel group registers always on and names no capability; panel.go and playground.go add their groups through the package's hooks — nothing edits routes.go.

Errors are {"error": {"code", "message"}} with the codes not_found, bad_request, unauthorized, forbidden, method_not_allowed, conflict, unsupported, unavailable, internal.

Contributing to the UI

The web app lives in web/ — TanStack Start in SPA mode, React, shadcn/ui on Tailwind v4, built with Bun. Users never need it: the build output is committed at dist/ and embedded with go:embed.

make studio-build   # cd studio/web && bun install && bun run build
make studio-check   # rebuild, prove dist is fresh, check the 600 KiB gzip budget, typecheck + test

make studio-check is the freshness gate (ADR 0018 §4): it fails if dist/ does not match web/ or if the gzipped total exceeds 600 KiB (currently ~400 KiB: the app's ~363 plus the panel bundle's ~36.4). The build is deterministic — two builds from one tree are byte-identical (scripts/clean-dist.ts pins the router's prerender timestamp and keeps <base href> first in <head>).

The dev loop is two terminals: go run ./studio/examples/basic -serve (API + embedded UI on :7331) and cd studio/web && bun run dev -- --base /studio/ (Vite on :3000 proxying /studio/api).

Import path: github.com/weftgo/weft/studio, a package of the framework module (go get github.com/weftgo/weft); it imports core, obsdb and, for its tests, otel. The binary is cmd/weft (go install github.com/weftgo/weft/cmd/weft@latest, make studio-bin in this repo), the one place that imports the clickhouse driver.

Documentation

Overview

Package studio is the Inspector: the UI, the JSON API, the live stream and the OTLP receiver over one observability database (weft/obsdb), served by Go alone.

The three setups (S4.6)

Setup A embeds the handler next to the app's own pipeline — the five lines of WEFT-OTEL-DATA-ARCHITECTURE §10.1:

defer otel.Install()() // local sink ./.weft/weft.db, content on, no network
mux.Handle("/studio/", http.StripPrefix("/studio",
	studio.Handler(studio.DB(otel.LocalDB())))) // same DB, same live hub [D4]

Passing the pipeline's handle is what makes it live: writes publish to the handle's hub and the /api/live stream follows, no network (examples/studio-local is the whole thing, with a thread session). Without a Token the API answers only a loopback Host (localhost, *.localhost, 127.0.0.0/8, [::1]) or one an AllowOrigins origin names — DNS rebinding makes any site same-origin to a loopback Studio — so an app served on a real hostname lists its origin in AllowOrigins or sets a Token.

Setup B runs the binary (`weft studio`, cmd/weft): the UI, OTLP ingest, SQLite and a dev token on 127.0.0.1:7331 — any language's app points WEFT_STUDIO_URL or OTEL_EXPORTER_OTLP_ENDPOINT at it.

Setup C hosts it behind a Token: the panel's scoped tokens are HMAC-signed public-id handles minted through POST /api/panel-tokens.

Routes are groups; capabilities are computed

The serving surface is a list of route groups (routes.go): New registers the core groups (the read API, the live stream, ingest, panel-token minting), and the lanes that follow add theirs in their own files without editing anyone else's:

  • step 7 (lane C1) adds studio/panel.go, which sets panelGroupHook through a package-level var initializer — no init(), no registry — and the panel group is always on;
  • step 8 (lane C2) adds studio/playground.go the same way (playgroundGroupHook), enabled by the Playground(true) option.

Both files exist now: the panel group registers always and names no capability (the panel is a client of the API), and Playground(true) registers the playground's groups — capabilities playground, runtimes, breakpoints and steer. api/meta's capabilities list is computed from the registered groups (live, ingest, auth with a Token, and the playground's) — never hard-coded — plus anything a hosting wrapper declares with Capabilities(...). The UI gates every deployment-specific screen on those names (ADR 0018 §8).

The API is the contract

The JSON API (S4.2/S4.3) is the one contract between Go and the UI (and the panel): its types are written by hand in api.go and mirrored in web/src/lib/api.ts, pinned on the Go side by golden tests (testdata/api) and on the TS side by type-checking. Events are paged (ADR 0018 §8); the live tail is GET /api/live, an SSE stream whose frame ids are the hub's Seq — the resume cursor.

Index

Examples

Constants

View Source
const Version = version.Version

Version is the version Studio reports as api/meta's studio_version and the devtools panel is built against: the framework module's tag, version.Version — one version for every layer. It moves with the module's release, nothing else.

Variables

This section is empty.

Functions

func DevToken

func DevToken() string

DevToken generates a random dev token for setup B's binary: printed at start, fixed by WEFT_STUDIO_TOKEN. Exported because the binary lives in another package (cmd/weft).

func Handler

func Handler(opts ...Option) http.Handler

Handler is New(opts...).Handler(): the one-liner for setups A and B when the playground is not in play.

Example

ExampleHandler is setup A's shape on one screen: a Studio over an obsdb handle, mounted under a prefix. otel.Install's local sink writes the handle in a real app; here a batch stands in for it.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"

	"github.com/weftgo/weft/studio"

	"github.com/weftgo/weft/obsdb/sqlite"
)

func main() {
	dir, err := os.MkdirTemp("", "weft-studio-example-")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = os.RemoveAll(dir) }()
	db, err := sqlite.Open(dir + "/weft.db")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = db.Close() }()

	// The mount the doc comment shows; the server serves the UI, the
	// API, the live stream and OTLP ingest under /studio/.
	mux := http.NewServeMux()
	mux.Handle("/studio/", http.StripPrefix("/studio",
		studio.Handler(studio.DB(db))))
	srv := httptest.NewServer(mux)
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/studio/api/meta")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = resp.Body.Close() }()
	fmt.Println(resp.StatusCode)
}
Output:
200

Types

type Option

type Option func(*config)

Option configures New.

func AllowOrigins

func AllowOrigins(origins ...string) Option

AllowOrigins permits these origins on the API and ingest routes (CORS for a panel served from another origin). The default, when a Token is configured, is localhost and 127.0.0.1 on any port; setup A (same origin, no token) sends no CORS headers unless origins are listed. Without a Token each origin's host:port is also a Host the API answers besides the loopback names (an app served at http://myapp.internal:8080 lists exactly that); "*" admits every Host.

func Base

func Base(path string) Option

Base is the URL path Studio is mounted at, with leading and trailing slash. Default "/studio/". It is written into the shell's <base href> per request and becomes the router's basepath, so the same bundle mounts anywhere (ADR 0018 §6).

func Capabilities

func Capabilities(names ...string) Option

Capabilities declares named API capabilities the backing server provides beyond what the registered route groups already report (ADR 0018 §8). The core groups contribute their own names — live, ingest, auth — computed from what is actually registered, never hard-coded; this option is for a hosting wrapper's own verbs.

func DB

func DB(db obsdb.DB) Option

DB serves Studio over db (setup A: DB(otel.LocalDB()) — the same handle weft/otel's Local destination writes, so the UI reads what the run wrote, live, through the handle's own hub). Without DB or Open, New opens the history database at $WEFT_DB or ./.weft/weft.db (history only: a second handle on the file, no live lane — passing the pipeline's handle is what makes setup A live). A nil db panics at New time — a Studio with nothing to read is a construction error.

func IngestToken

func IngestToken(tok string) Option

IngestToken requires `Authorization: Bearer <tok>` on the OTLP ingest routes. Without one, ingest answers loopback peers only (setup B's dev mode) — a Studio bound wide without a token refuses remote exporters (S4.4).

func Live

func Live(h obsdb.Hub) Option

Live serves the live stream from h (S4.1). The default is the DB's own hub when it implements Hub() — setup A's sub-100 ms lane — else an in-process obsdb.NewHub() fed by ingest.

func Manifest

func Manifest(json []byte) Option

Manifest supplies core.Manifest bytes (weft.json) for the agent and tool cards. It wins over the manifests runtimes register with (Playground(true)), which api/manifest lists beside it in sources; without either api/manifest answers 404 and the UI hides the Agents nav. Registered manifests live in memory: a Studio restart forgets them until the runtimes register again.

func NoIngest

func NoIngest() Option

NoIngest turns the OTLP ingest routes off: a read-only Studio. The ingest capability disappears from api/meta with them.

func Open

func Open(path string) Option

Open is shorthand for DB(sqlite.Open(path)): Studio over an obsdb sqlite file, created when missing. Opening happens at New time and panics on failure — a Studio that cannot reach its database is a construction error, not a serving one.

func Playground

func Playground(on bool) Option

Playground turns the runtime link server and the playground routes on (studio/playground.go registers its group, which this option enables); without it meta.capabilities does not list the playground.

func Title

func Title(s string) Option

Title overrides the page title (default "weft studio").

func Token

func Token(tok string) Option

Token protects the JSON API and the panel-token mint with a bearer token, and keys the panel tokens' HMAC signatures (setup C's signing key). Without it the API is open to a loopback Host or one AllowOrigins names (setup A: embedded, same origin; any other Host is refused, the DNS-rebinding guard).

type RuntimeServer

type RuntimeServer = linkrt.RuntimeServer

RuntimeServer is the runtime link server's in-process side (studio/runtime): the registry of connected runtimes that weft/runtime's runtime.Local(srv) drives and Server.Runtime returns — an alias, so the S4.1 name stays in this package while the type lives where the routes do. Nil on a Server built without Playground(true); the runtime-link routes register with the playground group.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is one Studio: the embedded UI, the JSON API, the live stream and — unless disabled — OTLP ingest, over one obsdb.DB. Build it with New; serve it with Handler.

Example

ExampleServer shows the Server surface (S4.1): New when the playground is in play, Handler() to serve, Close for what New opened, Runtime() nil without Playground(true). New without DB or Open would open the default path ($WEFT_DB or ./.weft/weft.db) — pinned by TestOpenOption — so the example opens a throwaway sqlite file instead of writing into the package directory.

package main

import (
	"fmt"
	"os"

	"github.com/weftgo/weft/studio"
)

func main() {
	dir, err := os.MkdirTemp("", "weft-studio-example-")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = os.RemoveAll(dir) }()

	srv := studio.New(studio.Open(dir+"/weft.db"), studio.NoIngest()) // read-only: no OTLP receiver
	defer func() { _ = srv.Close() }()                                // New opened that DB: closed here

	_ = srv.Handler() // mount it; the weft binary (cmd/weft) does
	_ = srv.Runtime() // nil without Playground(true) (studio/runtime)
	fmt.Println("ok")
}
Output:
ok

func New

func New(opts ...Option) *Server

New builds a Studio server over one obsdb.DB (S4.1). Handler() serves the UI, the panel, the JSON API, the live stream and — unless disabled — OTLP ingest. Runtime() is the runtime link server's in-process side, which weft/runtime takes in setup A; nil unless Playground(true) built it.

func (*Server) Close

func (s *Server) Close() error

Close releases what the server owns: the database when New opened it (Open or the default path). A DB passed through DB(...) stays its owner's — closing otel's handle out from under the pipeline is not Studio's call.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler serves Studio. Mount it under a prefix with http.StripPrefix, or at the root of its own mux:

mux.Handle("/studio/", http.StripPrefix("/studio",
	studio.Handler(studio.DB(db))))

The handler does not bind a port — the caller chooses the address. Bind loopback, or configure Token, before exposing it wider.

func (*Server) Runtime

func (s *Server) Runtime() *RuntimeServer

Runtime returns the runtime link server's in-process side (what weft/runtime's runtime.Local takes): the server Playground(true) built, nil without the option — and without it the playground routes do not exist either.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP routes one request: the registered route groups (the API, the live stream, ingest) on the mux, then the UI — embedded files first, the SPA shell for every other path (deep links survive reload).

Directories

Path Synopsis
cmd module
examples
basic command
Command basic records demo runs — a tool call, a subagent, a failure — into an obsdb sqlite database and serves Studio on 127.0.0.1:7331:
Command basic records demo runs — a tool call, a subagent, a failure — into an obsdb sqlite database and serves Studio on 127.0.0.1:7331:
Package ingest is Studio's OTLP/HTTP receiver (S4.4): POST /v1/traces and /v1/logs, protobuf and JSON, optional gzip, a 16 MiB limit on the decompressed body, and the publish-then-write pipeline —
Package ingest is Studio's OTLP/HTTP receiver (S4.4): POST /v1/traces and /v1/logs, protobuf and JSON, optional gzip, a 16 MiB limit on the decompressed body, and the publish-then-write pipeline —
Package runtime is the runtime link's server side (WEFT-PLAYGROUND §10.3, S4.2): the registry of connected runtimes and the three routes a weft/runtime client speaks to — register, the SSE command stream, acks.
Package runtime is the runtime link's server side (WEFT-PLAYGROUND §10.3, S4.2): the registry of connected runtimes and the three routes a weft/runtime client speaks to — register, the SSE command stream, acks.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL