studio

package
v0.16.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 50 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. A proxy that rewrites Host (Vite's changeOrigin: true) must list its front origin in AllowOrigins, since the Origin's host:port is compared with the Host. 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.

The loopback Host stops a rebound page, not a cross-site one: any site's page can POST to localhost without a preflight. So setup A also refuses a cross-origin write — an unsafe method (anything but GET, HEAD, OPTIONS) whose Origin is neither one whose host:port is the request's Host nor an AllowOrigins entry, or that sends no Origin but Sec-Fetch-Site: cross-site — with a 403 "cross-origin write refused (setup A)" naming the Origin. Same-origin pages, clients that send neither header (curl, the runtime link) and AllowOrigins origins are let through; reads need no rule, since setup A sends no CORS headers and the browser hides a foreign page's answer.

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), and weft mcp — the same read routes as a stdio MCP server for a coding agent, GET only (tools runs, search, run, step, request, events, tools, diff, export, sessions, session, meta, and explain_failure, a composition of run + step + the transcript; outputs the route's JSON, cut at max_bytes with a note; with no --url it reads the discovery file). claude mcp add weft -- weft mcp; Cursor's .cursor/mcp.json: {"mcpServers":{"weft":{"command":"weft","args":["mcp"]}}} — the discovery file finds the running Studio; --url http://127.0.0.1:7331 names another. The recipe, with worked examples and troubleshooting: docs/mcp-recipe.md.

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. "Open in Studio" from the panel (⤢, g s) under a playground-scoped panel token, when /api/meta lists handoff, asks GET /api/panel-handoff?to= — the deep link's path under the base — with the bearer and Accept: application/json and opens the answer's location, a path under the base resolved against the endpoint (never the host page) — its fragment carries a 5-minute read token for the same public id, so the hosted Studio opens on the run without a token pasted. That handed-over token (any panel token in a #token=) serves its tab alone: it lives in that tab's sessionStorage (kept across a reload; a duplicated tab inherits it) and never replaces the dev token a browser keeps in localStorage, which every other tab goes on using. Settings shows it as "this tab only" and its clear drops it alone; when Studio refuses it (its 5 minutes are over) the tab falls back to the stored token, or the token prompt says the hand-off token expired. The tab opens after the hand-off answers; a browser that blocks a popup after a slow answer leaves the plain link on the note line (put there once the answer takes over a second). Without the capability, under a read-scoped panel token (its Studio page is already a read view), the dev token or none, the plain link opens; a refusal opens the plain link too (a 403 or 404 silently, anything else with the note line). The host API's studioLink() stays the plain URL.

Retention. Studio keeps everything until told otherwise. studio.Retain(720*time.Hour) — --retain 720h or WEFT_RETAIN=720h on weft studio and weft dev — starts a sweeper that deletes, as DELETE /api/runs/{id} does, every run that is not running (succeeded, failed, interrupted) last seen longer ago than the retention, with its children that are not running: once at start, then every min(retention/10, 1h), at least a minute apart. Interrupted counts as not running and is swept: a run silent for 30 s reads interrupted, which is why the retention must be at least 1h (New panics below it; --retain / WEFT_RETAIN under 1h is a usage error, exit 2, on weft studio, weft dev and weft vacuum) — a shorter one would sweep a live run paused at a breakpoint. A running child of a swept parent stays and is listed only under all=1 / parent=* until it is swept itself. Each sweep logs one Debug line with its count; a failed one logs a Warn line and the server keeps serving. The sweeper runs until Server.Close, so a Studio with Retain is built with studio.New and closed; under studio.Handler(...) nothing can close it, and it stops (one Debug line) only once the DB is closed. The sweep runs in batches of 500 root runs (obsdb.SweepBatch), one writer transaction each on SQLite, so ingest proceeds between them. /api/meta's retention reports it. weft vacuum [--db sqlite://path|clickhouse://…] [--retain d] is the one-shot form — one sweep when a retention is given, then VACUUM (SQLite: the file shrinks; it waits on the busy timeout while another process writes) or OPTIMIZE TABLE … FINAL on the tables deletions mutate (ClickHouse: every part merged — heavy on a large database) — and make vacuum runs it on $WEFT_DB, else ./.weft/weft.db. All of it touches Studio's tables only. On ClickHouse deletion is a lightweight DELETE FROM … WHERE (ClickHouse 23.3 or later) and the tables' TTLs (clickhouse.TTL) stay as they are: retention is additive.

Cost (plan A5). Studio prices usage at read time — never stored, never sent, nothing fetched. The table is a plain JSON file, USD per 1M tokens:

{"dated": "2026-10-10", "note": "…", "models": [
  {"provider": "anthropic", "model_glob": "claude-opus-5-5",
   "input": 4, "output": 20, "cached_input": 0.2, "cache_write": 5}]}

A run's model is looked up as provider/model against each row's provider/model_glob with path.Match semantics; the first match wins, so the most specific row comes first. A row with no provider (or "provider": "*") matches on its model_glob alone, whatever the transport — {"model_glob": "kimi-k2.7-code", …} prices that model reached through the openai adapter at an OpenAI-compatible base URL, or under any provider name (a * in the glob never crosses a /). A scripted model no row matches — every wefttest model and the playground's scripted engine (weft/runtime + scripted), nothing else (a provider that wraps other models ships its own row) — is an exact $0 by a rule in Studio, not a row (so an override file cannot lose it): cost_known true, cache exact, cost_badge scripted. input and output are required; a missing cached_input, cache_write or reasoning prices those tokens at the input (or output) rate. The default is pricing.json (studio.DefaultPricing), generated by go generate ./studio/... from internal/pricingdata with the day its rates were read; models whose price has a long-context tier (Claude Haiku 5.5, Gemini 2.5 Pro) and any the author was unsure of are left out — they read unpriced, never a guess. studio.Pricing(table) replaces it, as does WEFT_PRICING=<path> (read by New when no Pricing is given) or --pricing <path> on weft studio and weft dev (the flag wins); studio.LoadPricing reads such a file and the override replaces the default whole — start from pricing.json, the template. A file that does not load (does not parse, an unknown field, a missing model_glob or input, a negative rate, a bad glob, a dated that is not YYYY-MM-DD) stops Studio with an error naming the path and the field; it never falls back.

The formula, exact where the adapter reports core.Usage's splits (subsets of the totals): input × (InputTokens − CachedInputTokens − CacheWriteTokens) + cached_input × CachedInputTokens + cache_write × CacheWriteTokens + output × (OutputTokens − ReasoningTokens) + reasoning × ReasoningTokens, ÷ 1,000,000, rounded to 6 decimals on the wire. With no cache split reported every input token prices at the input rate and the row says cache: "not_reported". Every priced object carries cost_usd (null when unknown, never 0), cost_known, cache (exact | not_reported) and, when unknown, cost_badge (unpriced: no row matches — the reason is one line naming provider/model alone, "openai/x has no price"; unknown: the figure could not be computed) with cost_reason; scripted marks a known $0 (above). A figure that sums runs — a delegated figure, a session's, an experiment's — says why it is unknown in one line counting them ("2 of 9 turns unpriced: no price for openai/x, acme/y", "1 of 3 subagent runs' cost not known yet (…)"), never a clause per run, and unpriced_models (a run row's delegated_unpriced_models for its subagents) lists the models without a price behind a figure, deduped, so a page can say it once — five at most, a sixth and more one last element "(+n)" (the reason's list is cut the same way). A session's or an experiment's reason names no run id (its turns are the rows beside it); a delegated reason names the child. Run rows — lists, documents and their children[], search hits, live run frames, experiment runs, the export's run block — split the bill in two, never folded: a run's usage includes its subagent children's (the core rolls them up), so cost_usd is its own model calls (its usage minus its direct children's, at its own model's rates) and delegated_cost_usd (delegated_cost_known, delegated_cost_reason) is its descendants' own costs summed — null when any of them is unpriced, 0 with no children. A session row's cost_usd is its turns' own + delegated, where a turn is every run of the session that is not a Subagent child of another of its runs; a thread/pool child session's run is a turn of the child session, not part of the parent session's row (obsdb groups sessions over top-level runs only, so a pool child's session does not appear in /api/sessions today). The run's own figure is priced at the run's model, so a step a Fallback or UseModel answered with another model is priced at the primary's rate there; the step route (GET /api/runs/{id}/steps/{n}) prices its own model call (step_finish's usage) at the model that answered it, else the requested one, and its children[] carry own and delegated. Only core Subagent children (run ids <parent>/<step>/<call>) are subtracted from a parent's own cost; a pool child's run and another agent's Generate inside a tool handler are linked to the parent but never rolled up, so they keep their own figures. cache is exact or not_reported (no cache split reported — every input token priced at the input rate, the figure possibly overstated); a running run's own cost, and one whose end never arrived, is unknown ("running: usage arrives at run end"). gcp.gemini, the span's provider name, prices as google. The table's cache_write is the 5-minute rate (1.25× input), so a 1-hour cache write (2×) is understated; the Gemini rows have no cached_input, so cached Gemini prompts are priced at the input rate; Claude Opus 5.5 fast mode and long-context tiers are not modelled, and models with a long-context tier (Claude Haiku 5.5, Claude Sonnet 4.5, Gemini 2.5 Pro) are left out. --pricing / WEFT_PRICING paths are shown absolute in /api/meta and /api/settings/pricing. An experiment's runs are priced one by one (each cell is a run row); obsdb records no variant key on a run, so a per-variant total is the client's sum. GET /api/settings/pricing (capability pricing, read-only, every valid identity) answers the effective table in the file's shape plus source (default | file | option) and the file's path under db.path's rule — the body saves as a WEFT_PRICING file and loads back. /api/meta's pricing is {source, path?, dated, models}. The Studio UI shows cost, under capability pricing only, in the runs table (a Cost column, sortable through sort=cost when search is there too, else over the loaded rows), the run header (own · delegated), the step cards, the compare page's cost column, the playground's result cards and matrix (a per-variant total) and the sessions list — an unknown figure as "—" with cost_reason as its tooltip, never $0.00; an unpriced one with a small "unpriced" chip (the sessions list's cell is a number or "—" alone); a scripted one as "$0 · scripted". What is unpriced is said once per page, not per row (U7, src/lib/cost.ts's unpricedLine): above the runs list, above the sessions list and in the run header, "N runs unpriced: no row for openai/x, acme/y (+2)" — the models deduped, every one in its title — beside "price this model…", a link to Settings' pricing table (/settings?price=<provider/model>) that leads the table with a row for it, its rates to fill (an edit kept in the browser like any other: download and restart with --pricing to apply). With the server cost sort on, a live frame updates its run's row in place without re-sorting until the next refetch; load more re-reads up to 2,000 runs; and offset paging over a set that is changing can repeat or skip a row.

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 — or decided from the panel on the app's own turns, below), 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 each loaded row's prompt_preview (every run row carries one since 0.14.0, absent for a content-off run) — a status (running, succeeded, failed, interrupted, parked — open or not yet linked, never a decided one — parked → (decided), cancelled) 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. Cost (plan A5.3). Under capability pricing only — off, the panel draws no figure and no empty cell — each turn row carries its cost beside its tokens in the run page's words (src/lib/cost.ts, shared): $0.0029, and its subagents' as a second figure, $0.0029 + $0.0011 delegated, never folded, by the runs table's rule (shown when unknown or > 0; the run header also draws a known $0.00 delegated when the run has children — a run row carries no child count, so the turn row shows its own figure alone there, the same total); Studio's visible "cache not reported" mark follows a known figure priced without cache splits. An unknown figure is "—" with the API's cost_reason as its title and in its accessible text (" not known: …"): an unpriced model ("openai/x has no price"), a running turn ("running: usage arrives at run end"), a derived turn whose spans reported no usage. The header's total beside "N turns · T tok" sums the turns' own + delegated (atLeastCost): "≥ $x · k of n turns priced" when a turn's is unknown, the reasons in its title only (a turn whose delegated part alone is unknown still adds its own figure: "delegated: …"; never "<" under "≥"), "—" when the known part is $0 (none priced, or only scripted turns — never "≥ $0.00"); a scripted turn reads "$0 · scripted"; beside it the header's one unpriced line (U7: "1 turn unpriced: no row for openai/x", a link to Studio's pricing table prefilled for the first model), counting the loaded turns the picker counts; and "≥" with "the N loaded turns only — load older turns for more" while older turns load on demand; a partial sum's accessible text says "partial: …", never "not known". The experiment result pane shows the replayed run's whole bill under the playground card's title ("the run's whole bill: its own model calls + its subagents'") and "N replays of this turn: $x" — every playground or forked run off this turn on the loaded page, not Studio's saved experiment ("the experiment's cost"), summed by atLeastCost so an unpriced (scripted) replay leaves the priced ones "≥" — and, while older turns load on demand, "(loaded only)" with the header's words. A derived run's unknown figure reads "not known", never "not known yet", keyed on the row's derived mark. parity.test.ts holds a turn's figure, reason and cache mark to the run header's, and the result pane's figure and title to the playground's result card. 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. A run derived from stock OTel GenAI spans (plan I1.3, derived: true, docs/record-contract.md §9) reads derived in the stock_spans cause's words ("derived from spans · no events", its fix naming the record contract) everywhere it appears, never "0 events" or an empty pane: a chip on its row in the runs table, search results, a session's turns and the live page (from the live frame's derived), and on the panel's turn (turnChips); the run header's hole in full (no event count); the story a banner with the fix and one card per step from runs/{id}/steps/{n} — the chat span's model, usage and cost as served (priced, or "—" with the API's cost_reason when the spans reported none; the panel header's token total leaves such a turn out, "≥" or "—"), its execute_tool calls badged derived, its request and messages blocks the badge (the panel's Story hands off: "2 steps · open in Studio ⤢", the run page's story at step 0); the raw view and the panel's Timeline and Raw tabs the badge above what they hold, with no "no events yet" line and no "replay here" or space-replay hint; the Request tab request: derived; the waterfall and the Timeline the real spans on the time axis (no event playhead; until the spans are read, the derived note in the waterfall's place). A derived source has no transcript, so the replay verbs, the editor, the drawer and save as test are not offered: they are drawn disabled with the badge's words (derivedWords), which is what the server's 400 would say. The compare page reads every column against a derived side "not comparable", cost too (it follows usage). parity.test.ts holds both surfaces to the same holes, chips and refusals on the server's own bodies (src/test/derived-fixture.ts). 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. Studio-only, by the panel's byte budget (not a parity gap): the story's end card ("■ max steps reached at step 9", endCard), a loop's "repeats step K" chip (loopRepeats), the tool failure pill (toolFailure / retryPill, "retry 3/2 — budget spent"), the header's concurrency chip and the "last step stopped on" relabel of a stop reason that contradicts stopped_by — the panel's turn chip names what ended the turn, the run page draws the rest.

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 never drawn here: a step whose request row carries raw gets one wire ↗ link — lib/links.ts's runLink(id, {step, view: "story", pane: "wire"}) off the endpoint, in a new tab like ⤢ — to the run page's Wire tab for that step; a step whose rows have raw: null gets no link.

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&pane=request|wire&sel=…&axis=time&t=…, sessions/<id>, traces/<id>?sel=<row key>&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. A turn's save as test (capability export, not under a read-scoped token) is one too: runs/<id>?save=test (saveTestLink), where the run page downloads the wefttest zip with Studio's own bearer — under a panel token the chip still opens the run page, whose bearer must be able to export (the dev token): a read-scoped token there is refused with the notice — an in-panel fetch-and-save measured ~0.8 KiB of panel.js. The result pane's keep as prompt ⤴ copies the drawer's prompt and, under proposals with the dev token (or none), also proposes it (PUT /api/agents/{name}/proposal), the outcome on the panel's note line in Studio's toast words (lib/saves.ts); a panel token copies only and is told "a proposal needs the dev token". 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, 255,791 B raw / 82,608 B gzip (80.7 KiB, the bundle of the ledger's row "D7"; 15,696 B — 15.3 KiB — of headroom under the 98,304 B, 96 KiB, cap — figures updated with every ledger row and the release bump, a test holding them to the ledger's last row; A5.4 raised it from 80 KiB at 78,098 B, the row "phase 6 review fixes (I)"). It ships fully minified — whitespace, identifiers and syntax (plan A5.3.0; before it the es-format library build kept its indentation and newlines: 281,470 B raw / 81,358 B gzip, 562 B under the cap) — and with no sourcemap, so it is unreadable in a host page's devtools: that is the accepted cost of the headroom. To read the panel, read its source, studio/web/src/panel; a sourcemap is deliberately not shipped (it would be a second file, against the one-file artifact, and the npm package carries the same bytes). 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 was held in reserve (since pulled), 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 remained at the time, 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 cap with the headroom left. The cap is 96 KiB (98,304 B) since phase 7, by the UX audit's decision 1 (§10, 2026-10-12; item A5.4, the ledger's row "A5.4 cap raised to 96 KiB", the bundle unchanged); it was 80 KiB (81,920 B) before. The ledger's cap is the one source: the build's failure (vite.panel.config.ts), the printed table and scripts/panel-asset.ts all read it, and budget.test.ts pins it. 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 row "minified" (73,706 B) is the minify lever, not an item: from it on, every row is measured on the minified bundle and is not comparable byte for byte with the rows before it, which stay as history (the table folds its −7.5 KiB into the unbudgeted line, −0.1 KiB net with the unbudgeted rows after it, as budget.test.ts pins). 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 plain word (running, succeeded, failed, parked — succeeded with pending calls, a later resume does not unsay it — interrupted; the turn list itself reads parked → tN and cancelled, U1), 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 decides its own turns through its Session, s.Decide(ctx, thread.Approve(ackId)) — or, when it set runtime.Sessions(resolver) at runtime.Install and that runtime of the run's service is connected, the panel and the run page decide them through POST /api/runs/{runId}/approvals (plan F4.3, below); otherwise the route answers 403 "app-owned turn" with the fix 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 app's own turns (plan F4.3). A parked turn the app started — no weft.playground, a turn of a thread session with a public id — takes the panel's continue / skip / resolve (the field is skip's reason or resolve's result) while a connected runtime of the run's own service registered runtime.Sessions(resolver) (GET /api/runtimes' sessions — the rule the route itself applies; meta.debug_scope is global, so breakpoints_own alone, or another service's Sessions runtime, leaves the turn read-only) and the turn is its session's latest. The verbs post to the playground's own route, POST /api/runs/{id}/approvals; the 202's command is polled (GET /api/playground/commands/{id}) until it ends — Studio settles a silent runtime's command as lost within max(10 min, 3 beats), so the poll is bounded — then the resumed turn shows as the session's next turn and the parked one reads decided — resumed as <session>-tN (a link; a reload says the same, with no verb), or the runtime's rejection word for word (rejected: the app is writing this session; fork instead). A running app turn takes steer the same way, its own run id posted — a delivered one reads steered; a steer that missed its turn reads failed: no running turn to steer for run … (…). Cancel is never offered on an app turn. Otherwise the block reads awaiting a decision (read-only): the verbs drawn disabled (badges.ts's refused verb, data-weft-refused="app-owned") and one line in the server's words — app-owned turn · read-only: no runtime of service <svc> is connected — fix: open the runtime link in the app: WEFT_ENV=dev (or runtime.Enabled(true)) at runtime.Install, with runtime.Sessions(resolver) when the app's link is closed or it is not running, … run <id> is the app's own, and no connected runtime of its service <svc> acts on the app's sessions — fix: set runtime.Sessions(resolver) … when one is connected without it, auth.go's this token is read-only: mint it with "playground": true to act for a read-scoped token, and the 403's own once one has answered (lib/honesty.ts holds them; a test pins them to studio/appturn.go and studio/auth.go) — until a later runtimes read says the verbs' gate holds again (the runtime reconnected, the fix applied), which gives them back; the panel reads GET /api/runtimes at most every 10 s while such a turn is drawn, on a timer too, so a quiet parked turn follows the link opening without a click. A plain Generate's park (no session) reads parked · app-owned run · the app resumes it (weft.Approve) with the thread.PublicID(...) fix; a turn of a session created without a public id reads appturn.go's … run <id> is not a turn of a thread session with a public id with the same fix. A child run is one of three, told apart in this order (parkedKind's table, in lib/honesty.ts): a weft.Subagent child (<parent>/<step>/<call>) reads parked · a subagent's call — decide on the parent turn <parent> (a link on the run page), no verb — only the parent's SUBAGENT_PENDING call is pending on the session; a thread/pool child, recognised by the weft.session.parent thread stamps on every pool child's run (in the run row's meta) when the run has no parent run id or is its own session's turn (<session>-tN), reads parked · a delegated session's turn — decide on the parent session <session> — and, when it ran under a delegating tool call, · delegated by turn <parent> — no verb and no fix: its requests are mirrored onto the parent session's Pending() and decided there (p.Decide, ADR 0022 §7). A child p.Submit started from app code outside a run carries no parent run id at all (core stamps weft.parent.run.id only from a running call's context) and reads the same, the session alone. On the run page the session is a link to /sessions/<session> and the turn a link to its run; the block reads neither the child's session nor the runtimes for it. Another agent's Generate inside a tool handler (a parent run id, an id of its own, whatever session id, public id and weft.session.parent it inherited — core merges a run's context metadata into every run started under it, so one nested under a pool child's turn carries that key too) never parks onto the parent and reads the plain Generate's line; a session's own Send inside a tool handler (<session>-tN, no weft.session.parent) is a turn of that session, gated as any other. A known caveat: a session created without thread.PublicID whose Send runs inside another session's tool handler inherits that session's public id through core's context metadata and reads decidable; the runtime refuses the decision in its own words (the session resolver answered session … for public id …, not the run's session …, shown verbatim) — give such a session its own thread.PublicID. The panel's turn lists are top-level runs, so it draws none of these children; reached another way, it reads the same words through the same parkedKind/refusalWords, without the links. On a Studio without the playground the line is … the playground is off on this Studio with meta.capabilities_off's fix (both surfaces hand their meta to refusalWords), and a failed read — meta, the session, the runtimes — is said as such (could not read the session: …), never a verdict and never an endless check; under a failed meta the run page still draws the run's events and the parked block (could not read the Studio's capabilities: …). A 403 refusal holds, on both surfaces, until a runtimes read started after it has applied and the gate says decide — then the verbs are back, even when the runtimes read the same as before (runtime ids are stable per service, cwd and agents, so a restarted app registers under the same id); a read that fails, or one still in flight, keeps it. While the turn is parked or running the parked block reads the session again every 5 s — not once the next turn is known (resumed is final) or the session read was refused (a 4xx): a turn another surface resumed reads decided. The runtimes need no poll of the block's own: while the turn is parked or running and not yet resumed the block holds the shell's poll of the same query at 5 s (use-notices.ts's useRuntimesNeeded). A failed meta read is held until meta answers: a block mounted again, or a refetch with its retry, reads the failure, never checking. The run page draws the same block under the same gate, in the same words, the 403's lifetime included (parity.test.ts). Studio's other capability-gated pages (traces, live, experiments, playground, compare) say a failed meta read as could not read the Studio's capabilities: …, never that the capability is off. The demo is thread/examples/approvals -serve: WEFT_ENV=dev go run ./thread/examples/approvals -serve 127.0.0.1:7331, ask "deploy to prod", decide it in the panel docked on the page; a restart over the same -dir resumes the session stored under its public id (one public id, one session), and a serve whose cached Session another serve's write left stale (thread.ErrStale) closes it and opens the session again, in its ask and its resolver. Without WEFT_ENV=dev the turn is read-only because the link is closed: the options are set, but no runtime of the service is connected, and the line says to open the link.

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. scope.Header also learns the page the request came from — its Referer, else its Origin + "/" — sanitised (http/https with a host only; userinfo, query and fragment dropped; a URL over 1024 bytes dropped whole, never cut) and stamps it as weft.page_url on every run the handler starts: it puts the pairs on the request's context with weft.WithMetadata, so Generate and thread's Send under r.Context() carry it with no option passed (a run's own weft.Metadata wins). scope.PageURLFromContext(ctx) reads it in the handler; scope.PageURL(r) applies the rule to any request. Studio's run row serves it in meta; the run page's "open in app" (plan G3, forthcoming with its web half) returns to that page with #weft_scope=.

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. POST /api/runs/{id}/cancel (capability cancel, plan H6) asks the runtime holding a runtime-started run to stop it — the link's cancel frame for the command whose acks named the run; the runtime cancels that command's own context, never kills, and the run ends failed with context canceled and its partial transcript, its command finished failed; the live stream carries the failed run row (a cancelled run has no run_finish). 202 {run_id, command_id, runtime_id, state: "cancelling"}; 404 an unknown run; 403 "app-owned turn" the app's own run with no Sessions runtime (plan F4.1, the paragraph below), 409 one already finished (a parked run answers 409 "decide its pending calls instead"); 503 its runtime is disconnected; best effort — a cancel lost to a stream reconnect is not retried, cancel again. Auth is steer's: the dev token or setup A's loopback, a playground-scoped panel token inside its public id; a read panel token is 403.

The app's own turns (plan F4.1). A run no runtime started is the app's: its approvals, steer and cancel go through a connected runtime of the run's service (the run row's service, the registration's service) installed with runtime.Sessions(resolver) — /api/runtimes reports it as "sessions": true, and meta.debug_scope reads "own+runtime" while one is connected. The route then forwards the verb as an event: session command ({command_id, verb: decide|steer|cancel, public_id, session_id, run_id, call_id?, decision?, reason?, content?, message?, actor?}, addressed by the run row's weft.public_id and weft.session.id) and answers 202 {command_id, state: "queued", run_id, runtime_id} at once; the client polls GET /api/playground/commands/{id}. The runtime claims the app's Session writer lease before it acks, so its refusals arrive on the row as state: "rejected" with error: the app is writing this session; fork instead (another writer holds the session — nothing written), no session for this public id, the session resolver answered session <id> for public id <pub>, not the run's session <id> (the resolver handed back another session under that public id — one public id naming two sessions, e.g. an app that creates a new session per process start; nothing written), this session requires signed decisions: … (a thread.RequireSigned session — the app decides it with DecideSigned), cancel is not supported on an app turn (thread has no per-turn cancel), or the resolver's own error. Two answers arrive after the ack, as state: "finished", status: "failed", nothing written: a steer whose run is not the turn running now (no running turn to steer for run <id> (…), naming the run running instead when there is one — thread.Session.Steer decides it under the session's lock with the run's id, so the call never starts a turn and never lands in a newer one; a delivered steer that misses its drain point — StopWhen, a park, the run ending first — still becomes a follow-up turn under ADR 0019's rule: the finished ack then reads succeeded for the named run, and the follow-up turn shows up later as the session's next run), and a lease lost between the claim and the write (the locked sentence). With several replicas of one service, Studio picks the Sessions runtime seen most recently, which may not be the process holding the session: it answers the app is writing this session; fork instead (a shared store) or no session for this public id (per-process stores) — misleading, but safe: nothing is written. A decision's finished ack names the resumed turn (<session>-tN, the app's run — never weft.playground). Without such a runtime the three verbs answer alike, 403 {"error":{"code":"forbidden","message":"app-owned turn · read-only: …"},"fix":"set runtime.Sessions(resolver) at runtime.Install in the app …"}; a run that is not a turn of a thread session with a public id is 403 "app-owned turn" with the fix thread.PublicID(...). Identity rules are every playground verb's: the dev token or setup A loopback; a playground panel token inside its own public id (another's 403); a read token 403.

Derived runs. A stock OTel app (no weft) that exports GenAI spans with gen_ai.conversation.id gets run rows (docs/record-contract.md §9): one per root invoke_agent span, id otel-<trace id>-<root span id>, derived: true on every run row (list, page, search, session), requests_badge: "derived". Its page's holes is [{"hole":"derived","reason":"derived from spans · no events","fix":"the producer exports stock OTel GenAI spans only: emit weft's records (docs/record-contract.md) for events, the transcript and requests"}]. What each route answers for it: runs/{id}/events — events: [], done: true and that badge; runs/{id}/transcript — batches: [] and the badge (?step=N: 409 with it); runs/{id}/spans — the real spans (the waterfall), weft.step.index / weft.tool.seq filled by the reader; runs/{id}/requests and /tools — empty, badge derived; runs/{id}/steps/{n} — the step assembled from its chat span (its holes include derived; steps.go is not yet derived-aware, see I1.2's open items); runs/{id}/logs — the app logs under its spans; runs/{id}/export — json/jsonl/otlp carry the row and spans with every other block badged, wefttest is 400 with the badge; diff — every column unknown against a derived side; the playground's fixtures and preview refuse a derived source 400 with the badge (there is no transcript to replay). A derived run has no public id, so a panel token never reads it (403).

The Live page derives a running row from the event frames it already receives: step = the last step_finish's index, tokens = the sum of step_finish usage, TTFT = its ttft_ms; cost stays unknown while running. 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.

Every matrix is saved as an experiment (plan F5). Experiments in the sidebar (/experiments) lists them with their agent, size, run count and cost. /experiments/{id} draws the variants × inputs grid, each cell its latest run with status and cost. From there you can re-run one cell or all of them under the same experiment id, compare two cells on the compare page, or "clone into the form", which reopens the playground on a draft holding the matrix's form — exactly, in any browser: the experiment saves the whole command (engine, side effects and thread per variant, from_step, the source run, transcript edits (none from a matrix)), and only a definition saved before that is read back from its runs. The matrix's name is free text; its id follows command_id's rule ([A-Za-z0-9._:-]{1,128}): a name that fits is its own id, any other is slugged with a per-page nonce, an empty one is exp_<time>, and a reopened experiment keeps its id until you rename it. Because of that nonce, the same out-of-rule name in another tab or after a reload makes a separate experiment; only a name that fits the rule re-runs the same one. "Its runs" opens /runs?experiment_id=<id>, which /api/runs filters. The playground keeps your form as a draft in this browser once you change it. ?exp=<draft id> reopens it, and the drafts list opens or deletes the others. Drafts never leave the browser. "Copy share link" carries the whole form in the URL fragment (#state=, no token, at most 8,000 characters encoded). It opens against the same Studio, restores the form, and runs nothing. A matrix runs every cell from its own from_step, the source run's step ordinal; past step 0 the rows' text is not sent. When its last cell settles, one toast says how many succeeded and failed, with the bill when pricing is on (an experiment page's "re-run all" re-reads the experiment at most three times for a run not yet listed, then says "cost not read yet"). A share link's value is refused past 8,000 characters, or when it inflates past 256 KiB.

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).

Status words (U1) are one table, src/lib/status.ts, on every surface — the runs list, the run header, a session's turns and its header, the live table, the panel's turn list, picker and filter: running, succeeded, failed, interrupted, and two read-time words over the stored four — cancelled (failed with error_type cancelled, the cancel cause in its title; the [cancelled] kind chip is not drawn beside it) and parked (succeeded with pending calls) while nothing is known to have resumed it; once a later turn of its thread session is among the rows read (or, from U13.1, the row's resumed_by) it reads parked → t2, a link to that turn (in the panel, a click on the word selects it). The certainty is in the detail, never the word: "1 call awaiting a decision" only where the resume was looked for and could not be missed — a session turn read against its session's whole turn list (the session page, the run header once its session loaded, the panel's session-scoped list); elsewhere (the runs list under a filter or sort, the live table, an experiment's grid) "1 call pending · no resume recorded here", and for a plain Generate "1 call pending · a resume, if any, is not linked yet". A decided park's detail says "1 call decided · resumed in t2" only on the stored resumed_by; a park found by the next turn alone says "1 call · next turn t2 (decided or dropped)" — after a Branch, a Rollback or an undecided Send that turn is no resume. The sessions list's status is the newest turn's stored word (no parked / cancelled until U16): its row carries only status. The runs list's status filter has parked too — the API is asked for succeeded and the loaded rows are kept where the word reads parked (its chip says so, and that a resume which did not succeed is not looked for; the header then reads "N listed of M loaded", never "matching"); the live table's filter matches a status word by its key, not its text — parked never matches a decided park, and failed no longer matches a cancelled run (whose key is cancelled); any other word matches the agent or the shown word as text. Each word has one colour role (src/lib/palette.ts's ROLE), the same hex on both surfaces: running --status-run, succeeded --status-ok, failed --status-bad, interrupted and cancelled --status-int (the words differ), parked --status-parked, a decided park muted; the live dot is the succeeded colour. A role's word is drawn in its ink (INK): the running word in --status-run-ink, 4.5:1 on both themes, the dot, pulse and pill border in --status-run.

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). These chips are what the record attributes to user code today: only effects are recorded — a PrepareStep, a WrapModel or WrapTools middleware, a Tap is never named as the hook that acted (docs/record-contract.md §6, "What the record attributes to user code today"; the hook on the request record is U25's, the middleware onion U23's). The open pane has two tabs, request and Wire (plan A6.2, components/studio/step-wire.tsx): per attempt, the provider request (method and URL, headers, the body — pretty-printed when its media type is JSON and it parses, the bar then saying "pretty-printed" while Copy copies the wire bytes; "binary, N bytes" with a copy when base64) and response (status, media type, headers, body or raw SSE text), from the requests row's raw; the withheld credentials are listed ("2 headers withheld: …", "1 query parameter withheld: …"), the 256 KiB cap's cut is a truncated badge naming the bytes, a body not read to its end (complete: false) a truncated badge "incomplete: the body was not read to the end"; a row without raw shows its raw_badge, raw_reason and raw_fix (not_recorded, cause raw_off), a raw record that did not parse the derived badge, a read-scoped token the hidden badge once, in the pane's header; each pair downloads as .json. The tab is remembered per step and URL-addressable: ?step=N&pane=wire (pane=request for the request tab) opens that step's pane on it, in the story view. A long run scrolls (plan H2): past 60 steps the story is virtualised, past 200 rows the waterfall and the raw table are, mounting only the rows in view plus an overscan (and the row the URL names); a jump to step scrubber heads the story (range and number box, n; one ?step= history entry per scrub, written in place while the drag goes on); and the page keeps the stream's last 50,000 events in a ring (lib/ring.ts's EVENT_RING_CAPACITY) — a run past it says which positions it shows, the earlier ones read from api/runs/{id}/events?after=, and turns replay off. Live deltas are folded into the story's words but never kept in the ring: they have no position on the API, so the window's positions, its base and the eviction note count positioned events only:

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 (plan H6 — one /api/live?all=1&kinds=event,run stream opened with a grant {"all":true} when /api/meta's live_all is true, the dev token or setup A's loopback: run frames add and refresh the rows, event frames keep each row's step — the newest step index, "…" while a step runs — tokens so far (input + output from step_finish usage, one figure per step index so a re-delivered frame replaces rather than adds, "≥" when the page joined mid-run) and TTFT; cost is the row's own, "—" with "running: usage arrives at run end" until the final run frame prices it; the running list seeds the rows since run frames are never backfilled; the header shows the stream's state — connecting, live, reconnecting, overflow, stopped with a retry; a reconnect resumes with Last-Event-ID and the footer states the bound, runs running or seen in the last hour, newest 500; a run the resume could not read gets a "could not read" gap badge; finished runs stay 30 s then drop unless peeked; a filter box narrows by agent or status — a status word (running, failed, cancelled, parked, …) matches by its key, so typing failed no longer matches a cancelled run and parked no decided park, any other word the agent or the shown word as text; without live_all — any panel token — the page polls the running list every 5 s and says "live: polling every 5 s — the all stream needs the dev token or loopback"; under capability cancel a runtime-started run's row gets a cancel button — the first click arms it for 3 s, the second posts POST /api/runs/{id}/cancel and the row reads "cancelling…" until the run's row ends — looked for among every row, the filter aside — or 30 s pass without an end, when the button is back titled "no end seen — cancel again" (a cancel is best effort); the end is toasted as "run X cancelled · ended failed", or "run X ended " when it ended otherwise, and a refusal in the server's own words; an app-owned run's button is disabled, "cancel is not supported on an app turn" (the runtime refuses it: thread has no per-turn cancel), a parked run's says "parked — decide instead" and links to the run page; clicking a row or its peek button opens that run's story beside the list in a resizable pane remembered per device as split live-peek, its run id in ?peek=, Escape or the close button closing it); /traces lists every trace the database holds (GET /api/traces, capability traces: root span, service, start, duration, span count, error, the runs it holds linked; since/until/service chips, paged by the exact cursor and deduped by trace id, a rootless trace carrying its gap badge); /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; /settings says what this Studio is and serves.

The trace page (plan H7): every selection is a URL, ?sel= the row's step-qualified key (src/lib/trace-sel.ts, G1's scheme) — r:<run> an invoke_agent span, s<step> a chat span, c:<step>:<call id> a tool execution (c:resume:<call id> a resumed call, its span the one carrying weft.tool.approved — the run page's key), run-qualified (s:<run>:<step>, c:<run>:<step>:<call id>) when the trace holds several runs, t:<span id> for any other span and for a second span wanting a taken key; each row's label links to it (a plain click selects in place, replacing the URL; y copies it). An old bare c:<call id> lands on the first tool span of that id, saying "an old link: this call id repeats across steps; showing the first" when it repeats; a pre-H7 ?span=<span id> still selects. A model call's chat section draws its messages role by role from either key family — gen_ai.input.messages / gen_ai.output.messages (JSON {role, parts} arrays, tool_call parts folded as name + arguments) with gen_ai.system_instructions, else the legacy gen_ai.prompt / gen_ai.completion (plain, gen_ai.content.*, or indexed .N.role|content, .N.tool_calls.M.* on either side and a tool result's gen_ai.prompt.N.tool_call_id) — and a model call carrying neither says not_recorded (weft's own chat spans carry no content; nothing is reconstructed). The raw attributes stay listed beneath. An errored chat span of a playground run (RunRow.playground) offers "replay from this step": the run page's own drawer, pre-filled with the run and step ordinal, shown under the playground capability and a token that may act (canReplay); on the app's own run it is disabled, titled "the app's own run: replay runs through the playground". The header lists the trace's root, service, start and error, and its runs (the spans' weft.run.id, sorted) linked; "all traces" under the traces capability.

Settings (plan H4): /settings (a sidebar entry, the palette's Settings item) is one card per section — Studio (endpoint, versions, pid, interrupted_after_ms, ingest), Token, Database, Retention, Pricing, Capabilities, Runtimes, Content policy, Appearance, Keyboard. Served (read from /api/meta, /api/settings/pricing under pricing and /api/runtimes under runtimes): the versions, the database (its path, size and the pid badged hidden to a caller db.path's rule refuses), retention, the pricing table, each capability off with its capabilities_off reason, the runtimes with their breakpoints (the playground's own editor, under breakpoints), the latest run's content mark with its note and fix. Per browser: the token (studio.token in localStorage — masked, shown on request, copied, cleared, or switched by pasting another, which re-reads everything with it; its identity is read from its shape: a weft_pt. panel token names its scope and public id in its claims, any other bearer a walled Studio accepts is the server token, confirmed when meta served db.path's fields), theme (auto/light/dark), density and the pricing edits. Studio never writes a file, so the pricing table's edits stay in localStorage (keyed by the served table's source and dated; "reset to the server's table" drops them) and are never sent; "download pricing.json" writes them as the override file — {dated, note, models: [{provider, model_glob, input, output, cached_input?, cache_write?, reasoning?}]}, refused while a field LoadPricing would reject is wrong — to run Studio with through --pricing <file> or WEFT_PRICING. Applying an edit is that download and a restart: a live PUT /api/settings/pricing is not offered.

Search and the palette (plan H1): the Runs page has a free-text box (?q=, debounced; every term must match the prompt, answer, error or a tool name, a "quoted phrase" one term) and filters by agent, status, tool, model, error, date range (a day is UTC, until inclusive; a day that does not exist or a time without its zone — RFC 3339 as the server parses it — stays a "not applied" chip with a note, never sent), duration, tokens, cost (with pricing) and any number of tags, plus column sorting on started, duration, tokens and cost — each filter a removable chip in the URL (src/lib/run-filters.ts). A text or search-only filter, or a sort, reads /api/search (offset-paged); otherwise /api/runs. Duration is always applied to the loaded rows (the API has no duration filter); without search the box says why (capabilities_off.search), model, error, the range and the minimums filter the loaded rows and say so, and text and tool chips are drawn struck through as not applied. The sessions list's text box (?q=) is /api/sessions?q= (obsdb.SessionQuery.Q): every term in the session's title, its first turn's prompt, its id, public id or agent. Titles, not ids (U6): a run row, a session's turn and the run page are titled by prompt_preview (else the agent, else "(content not captured)" for a content-off run, else the id), a session by its title — thread's weft.session.title, metadata that survives content-off — else its first turn's prompt (first_prompt), else "(content not captured)" when that first turn stored no content (the row's content_off), else the id; the id rides under the title as a copyable mono line (not repeated when it is the title), and the browser tab reads director · How many units… · succeeded. One helper, src/lib/title.ts (runTitle, sessionTitle), words both Studio and the panel's turn list and its narrow dropdown (● parked t3 How many units… · 2 steps · 811ms; an experiment ↳ replay of t3 · scripted · ● failed · 2 steps · 2.5s); a thread resume reads resume of t3 · 1 decided call (turnTitle) on the session page and in the panel alike. ⌘K (or Ctrl+K) opens the palette anywhere: runs through /api/search?q= (by id and prompt over the recent runs without search), sessions (by title or first prompt too), traces (the newest page's ids, root names, services) under traces, experiments by name under playground (never under a panel token, which the experiments routes refuse), the pages — Playground under playground, Settings (plan H4's page) — and the Studio commands; ↑↓ move, Enter opens, Escape closes.

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 q, agent, status, session, public_id, tool, model, error, since, until, tag (repeatable k=v), min_duration, min_tokens, min_cost, experiments=on|off, experiment_id, subagents=all, parent the filters (a change drops before) push
runs list sort=duration|tokens|cost the order (search's; newest first without it) push
runs list before the first page's cursor — (a link's landing)
live peek the run whose story the side pane shows push
sessions agent, public_id, q the filters push
traces since, until, service the filters (a refused since/until kept, ignored and said) 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 pane=request|wire the highlighted step's pane (its tab); dropped when the step or the view changes — (a link's landing; not written back)
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
run save=test download the run's wefttest zip on landing (the panel's save as test), then dropped — (a link's landing; replace)
experiments /experiments the saved experiments: agent, size, run count, cost — the newest 50, "newest N of total" past them with a load-more up to 200 (the API's cut); never under a panel token (the hidden badge, nothing fetched) —
experiment /experiments/{id} one experiment's variants × inputs grid: re-run a cell or all, clone into the form, compare two cells —
playground ?exp=<draft id> a draft of the form in this browser's IndexedDB (never leaves the browser) the drafts list
playground #state=<compact> a shared form — deflated or base64url JSON, ≤ 8,000 characters, no token — restored on arrival, never run "copy share link"
trace view=chat the GenAI chat view (the tree is the default) push
trace sel the selected row: its step-qualified key (H7; a pre-H7 span=<span id> is still read) 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

Two names that read like URL keys are not: offset is /api/search's paging parameter (the runs list pages by before; search pages are asked by offset, never written to the URL), and the panel's tab is stored in localStorage["weft.devtools"] with the rest of its layout (D4), never in a URL.

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); on a run page it carries the pane the URL landed with, not a later switch of the step card's tab (the tab is remembered on the card, never written to the URL); 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.

Open in app in the run header (plan G3): a run whose request came through scope.Header carries meta["weft.page_url"] (the sanitised Referer/Origin); the verb opens that page in a new tab with #weft_scope=<encodeURIComponent(serializeScope({publicId, session, run}))> — lib/links.ts's appLink, the serialised scope encoded once — so the devtools panel there scopes itself (its URL rung). The panel's URL rung needs a public id: a run without one opens the bare page. Without a page URL the verb stays, disabled, titled "the run carries no weft.page_url: wrap the app's handler in scope.Header". A read-scoped panel token sees it too.

Saves (plan F6): Studio writes nothing to your files — the download is the hand-off. Save as test in the run header (capability export; hidden to a read-scoped panel token, which the format refuses) fetches GET /api/runs/{id}/export?format=wefttest with the bearer in the Authorization header — never a token in a URL — and saves the zip under its Content-Disposition name (<run>.zip: a generated <agent>_replay_test.go beside testdata/TestReplay_<run>/); the toast says "saved — unzip into a package directory and go test", or a refusal in the server's words. Keep as prompt beside a step's system prompt in the Request pane copies and, under proposals and an identity that may propose (the dev token, or setup A's loopback with no token), proposes the run's agent's instructions: when the manifest verifies the agent (weft.manifest.hash) and its offered tools' PromptSnippets end the recorded prompt, they are stripped (adopting a composed prompt as Instructions would compose them twice); otherwise the text goes as recorded and the toast says what it may carry ("may include N tool snippets or a PrepareStep rewrite"). The proposal body is the text alone, so the Agents page cannot tell: a proposal kept from a step whose prompt was not verified may carry snippets. The toast says the outcome, a 404 ("no agent … in the manifest file or any registered manifest") or 409 ("the served manifest file does not name agent …") in the server's words, and a panel token copies only ("a proposal needs the dev token"). The playground card's keep as prompt copies the variant's instructions and proposes them the same way. The Agents page shows each agent's proposal beside its registered prompt: a registered → proposed line with the proposal's since and hash (12 characters, the full one on hover), the line diff from registered to proposed (the Request pane's prompt-diff renderer), copy proposed (toasting "copied"), and withdraw (DELETE, the same gating — a panel token sees it disabled with the hint); once on the page, the pointer "the durable path is WEFT-PROMPT.md P3: a propose command over the runtime link". A read-scoped panel token never reads the manifest (403, hidden), so it never sees a proposal.

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 (playground capability; never under a read-scoped panel token) every 5 s while a surface needs it now — the playground, the replay drawer, an experiment's run controls, a parked or running app turn's block — and otherwise on a quiet poll that doubles per read that changed nothing, 5 s up to 60 s (a connected runtime's last_seen, moved by its stream's keep-alive, is no change), back to 5 s on a change, a key or pointer press, or a surface letting go of it; never while the tab is hidden. A quiet page reads it 4 times in two minutes, a runtime connected or not. It 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 poll interval is not noticed; on a quiet page the toast comes up to a minute late. 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.

After a Studio restart

The runtime link's bookkeeping is written through to Studio's database when the backend implements obsdb.CommandStore (both first-party backends do: SQLite migration 0007, ClickHouse 0008); studio.New passes its DB itself. A backend without it keeps everything in memory, as before — a restart then forgets it. One Studio per command store: two Studios over one ClickHouse database never see each other's acks and beats, so a restarted one would load the other's queued and accepted rows and decide them lost (nothing runs twice; the other's live commands read lost).

What survives. Every command (its wire body, state, run id, the accepted and last-heartbeat times, the last ack), every run → runtime route (cancel, steer and the debugger's verbs keep reaching the runtime that started a run), each runtime's breakpoint set and each runtime's latest registration. A restarted Studio lists the runtimes it stored as connected: false, restored: true — with their breakpoints and agents — until each registers again, for at least 10 min (FinishDeadline) after the restart whatever their last-seen, then by the usual prune (a disconnected runtime with nothing pending and no breakpoint set goes); it answers GET /api/playground/commands/{id} for a command issued before the restart with its stored state, and the experiments/runs joins as before.

What is decided lost, never re-run. At load, with now the restart:

  • a queued command whose runtime has no stored row (or when the stored runtimes could not be read) is lost — "Studio restarted; the runtime's identity is not stored";
  • a queued command older than its ack window (30 s since it was created) is lost — "Studio restarted; no ack since "; one still inside the window stays queued with its ack timer re-armed for what is left, and is re-sent only to the process it was queued for when that process reconnects (that process's at-most-once set refuses a frame it already admitted). Another process registering under the id meanwhile (the app restarted) is given <id>-<pid> and never sees the row, which ends lost by its ack window.
  • an accepted command is watched from its stored last word, max(last heartbeat, accepted ack): lost max(10 min, 3 beats) after it for a runtime that heartbeats (10 min otherwise), as if Studio had never restarted. Already past: lost at load — "Studio restarted; no heartbeat since ". An accepted command is never re-sent: the runtime, not Studio, decides to run on an accepted ack Studio took (an accepted ack Studio did not take runs nothing).
  • finished, rejected and lost commands are history.

When the store lags or fails. The writes leave the request path: the in-memory transition and the HTTP answer never wait for the store. One writer goroutine applies them in order (the latest write per key; the prune is a queue item, never beside a write), each call bounded at 2 s; a failure or timeout trips a breaker that holds the queue for 30 s — it keeps coalescing per key, so it is bounded by the live keys (the memory prune forgets the writes of the rows it removes) and a cap of 20 000 keys: a Studio whose store stays down for hours drops route writes past it first (a route is rebuilt from its command at load), then the oldest finished commands' writes — so under the cap a finished command may read lost or unknown after a restart; a WARN carries the running total on the first drop of an outage and each time it doubles — and then re-applies all of it, the failed write included; a write that fails again trips it again, so a dead store holds instead of spinning. One WARN, then Debug. A store failure changes no command's fate in this Studio. What it can do is leave the store older than the memory when Studio stops, and an older row is always the safe one: an accepted command stored with an older beat is decided lost sooner, never re-run; one stored still queued reaches no process but the one it was queued for, which refuses a frame it already admitted. studio.Server.Close ends the pause and drains the queue once (bounded at 5 s) before the database closes — a store that has recovered is written at once; a store still failing when the Studio stops loses the writes still held (one WARN names how many). Those are the two ways a row goes absent rather than older: a Close over a still-failing store, and the queue's cap. A third, minor edge: the memory prune also discards the pending write of a row it removes, so that row's older stored state stands until PruneCommands deletes it. The writer is stopped by studio.Server.Close alone: a server built with studio.Handler is never closed and keeps that one goroutine for the process's life (harmless); a write it takes after its DB was closed fails and logs the one WARN.

Retention. The command rows' only retention is obsdb.CommandStore.PruneCommands, run from the server's own prune (24 h for terminal commands, 10 000 kept at most; queued and accepted never), at most once a minute; run deletion, Retain and weft vacuum never touch these rows. A runtime's LastSeen from the stream's keep-alive pings is written at most once a minute (registration, stream open and stream end always write).

Stable runtime ids. A runtime's id is rt_ + the first 16 hex digits of sha256(service NUL cwd NUL sorted agent names joined by NUL) — service is OTEL_SERVICE_NAME or the binary's name, cwd the process's working directory — so an app restarted in the same place registers under the same id and a link to /runtimes/<id> survives the restart. When an id is held by another process's live stream (two replicas of one service in one cwd, or two links in one process), Studio answers the registration <id>-<pid> (then <id>-<pid>-2, …) in runtime_id, and the link adopts it; a reconnect of the same process keeps its id. A disconnected id whose process still holds unfinished work — a queued or accepted command — stays that process's: another process registering under it is given <id>-<pid>, so the old process coming back finds its id and those commands. A finished command's run route does not hold the id: a disconnected entry holding no unfinished work is reclaimed by whoever registers under its id next, Studio's breakpoint set kept — an app restarted minutes after a playground run (weft dev restarts on every save) keeps its id and its /runtimes/<id> link. So an approval for a run parked on a runtime that went away may reach the process that took the id since, which refuses it ("no parked run …").

The cwd field. /api/runtimes rows carry cwd, the app's working directory — a filesystem path, so under db.path's rule: the server token, or setup A's loopback without a Token; never a panel token (read or playground scope). /api/meta's runtimes still counts connected runtimes only.

Wire (for the TS/Python runtime clients). The registration gains cwd (string, optional) and instance (string: a random value per link, kept across its reconnects — with host and pid it is what makes a re-registration "the same process"). A client must send instance to keep at-most-once across its own restarts: without it Studio tells processes apart by host and pid alone, and a restarted container reusing pid 1 would read as the process that held the old commands (Studio WARNs once per server on a registration without one). A client must adopt RegisterResponse.runtime_id when it differs from the id it sent, and open commands_url (which names it).

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, live_all (true when this caller may open /api/live?all=1 — setup A's loopback Host and the server token, db.path's rule; false for a panel token and an AllowOrigins host), ingest_open, interrupted_after_ms, capabilities, capabilities_off {capability: why}, manifest_sources, retention — studio.Retain's duration as a Go duration string, "720h0m0s", null when off, the default — pricing {source, path?, dated, models}, the cost table; see Cost above — and ingest {dropped: [{service, records, last_heard}]}, the ingest health: the largest weft.records.dropped each exporting service's heartbeats and run_start/run_finish records reported on the OTLP path, in memory, per service.name — lost on a Studio restart, until each app's next carrier reports it again (UX-E-38: not persisted, accepted); manifest_check's rule — the dev token's or setup A's loopback, omitted for a panel token, whose public id is no reason to read every service's name and losses), settings/pricing (the effective pricing table, capability pricing), runs (agent, status, session, public id, playground, parent, experiment_id, stopped_by (what ended the run — exact, or a prefix when the value ends in :: has_tool_call: is every tool-call stop; /api/search takes it too) and tag.<k>=<v> filters, cursor-paged; every run row carries stopped_by and a canceled run's cancel_cause, omitted when none was recorded (docs/record-contract.md §6), and settling: true while its root span is stored and its run_finish is not, heard from within 30 s — its records may still arrive, so the UI follows it as it follows a running run (omitted otherwise; §6); top-level only by default — parent=<run id> lists one run's subagent children, all=1 (or parent=*) every run, children included; every run row carries prompt_preview — the turn's own prompt: the input record's last user message with text (on a resume that carries a new prompt, that prompt: the next record's user text before its first assistant message), else the first user message with text, cut to 200 runes — absent when the records carried no content, the run's stripped/not_recorded holes saying why), search (plan A7, under the search capability: ?q= terms — whitespace-separated, a "quoted phrase" one term, each a case-insensitive substring of the run's prompt (the turn's own, as above), answer (its last assistant text), error or a tool name it called, every term required, each in any field; a term under three runes is matched with LIKE on SQLite, case-folded for ASCII only (lower() on both sides; a short non-ASCII term matches its own case alone there — ClickHouse folds UTF-8: the one difference between the backends), % and _ literal; never the system prompt, a tool result or the catalog — with agent, tool (exact name), status, model (exact), error (substring), since/until (RFC 3339, started at-or-after / before), min_tokens (input + output), sort=started|duration|tokens (newest, longest, most first), limit, offset and /api/runs' own session, public_id, parent, all, playground, experiment_id, tag.<k>=<v>; min_cost (USD at least, the run's whole bill — own + delegated; an unpriced run never matches) and sort=cost (most first, unpriced runs last, newest first) are applied in Studio, one rule for both backends: it reads the match's first 2,000 runs in the order asked (newest first for sort=cost), prices them, filters, orders and pages them, and says cost_sort_cap: 2000 when the match ran past it (total then counts the capped set). Answers {total, runs, sort, next_offset, cost_sort_cap?} — run rows only, no snippet — scoped like runs: a panel token searches its own public id, a foreign public_id is 403. SQLite searches an FTS5 trigram index the writer keeps (migration 0004: run_search, run_tools, runs_fts, runs stored before it indexed when it applies); ClickHouse derives the fields at read — once per search, for the runs the other filters leave, joined to the run rows — and tests every term with positionCaseInsensitiveUTF8, no migration), 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 — each row carries raw: the attempt's wire exchange (request/response with method, url, status, headers, media_type, body — base64 when encoding is base64 — bytes and truncated_bytes, plus redacted_headers/redacted_query), recorded only under otel.RawBodies(true) with WEFT_ENV=dev; null with raw_badge: "not_recorded", raw_reason and raw_fix when the run holds none; the route is refused to read-scoped panel tokens, so a wire body is never shown to one; the response side's complete is false when its body was not read to the end — closed unread by a transport retry, a timeout, a stopped reader — and truncated_bytes then counts the unread rest when Content-Length was known; a raw record is up to ≈ 650 KiB and Studio's ingest refuses bodies over 16 MiB, so under RawBodies the pipeline exports to Studio in batches of 16 records — 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 as <agent>_replay_test.go (a generated test, plan F6) beside testdata/TestReplay_<run>/ — unzip into a package directory and go test replays the run, wefttest.Replay(t, "testdata") answering; the skeleton's header repeats each step that replays from a compacted view; results keyed by (step, call id); steered steps replayed through a generated wefttest.NewSteers source; a step the recorded run rewrote replays its recorded messages (replayed, not re-derived); a step whose request carried a compaction view is 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 (plan A7, under the traces capability: the trace list — every trace the database holds, weft or not, one row each, newest first — {total, traces, next_before, next_before_id}, each row {trace_id, root: {span_id, name} | null, service, start, end, duration_ms, span_count, error, run_ids}: the root is the earliest span with no parent, null with badge: "gap" (and its reason and fix) when only child spans arrived; service is the root's, else the earliest span's; error is any span ended in error; run_ids the weft runs the trace holds, a subagent child beside its parent — the join key to runs/{id}, [] for non-weft spans only. since/until (RFC 3339, on the trace's start: its earliest span), service (exact; traces holding a span of it), public_id, before/before_id/limit paged as runs (default 50, max 500). Scoped like runs: a panel token lists the traces every span of which carries its public id — the rule traces/{trace_id} checks, so the list offers no trace the detail refuses and no other public id's run id — and a foreign public_id is 403 — a trace holding any span without its weft.public_id (an instrumented HTTP root, a client span under a tool) is not listed to a panel token: its detail would be refused. A row carries no span attribute, so the read scope has nothing to redact; a trace whose earlier span arrives between page reads can be listed again on a later page (never skipped); clients dedupe by trace_id. before_id without before is ignored), 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 (each agent entry carries proposed: {instructions, instructions_hash, since} when a proposal is held — plan F6, the PUT/DELETE /api/agents/{name}/proposal {instructions} routes under the proposals capability: an agent's proposed instructions held in Studio's memory, at most 64 agents with the oldest forgotten first, lost on restart, never sent to the app or written to files; 200 {agent, proposed, note}, 404 for an agent no manifest names or, on DELETE, one with no proposal, 409 when a manifest file is served and does not name the agent; the dev token's and setup A's loopback alone, every panel token 403; it carries the agents' system prompts: 403 with badge: "hidden" to a read-scoped panel token), manifests/{hash} (U27, the manifests capability, present when the database is an obsdb.ManifestStore — both first-party backends: the agent configuration a run's manifest_hash names, after it stopped being the current one — {manifest_hash, agent, source, first_seen, current_manifest_hash, manifest}, source file or runtime (where Studio first saw it), current_manifest_hash the hash the current manifest gives the same agent ("" when it names none; equal to manifest_hash when the run's configuration is current), manifest the one-agent core.Manifest document; Studio stores each agent of the weft.json it serves once, at New (a weft.json rewritten while Studio runs is stored after the next restart — or by the next registration of an app running that configuration: under weft dev an app with runtime.Install re-registers its agents on every save-and-restart, so the dev loop is covered) and every runtime registration's agents, content-addressed (the hash is the document's sha256, the first write kept) and kept forever — deletion and Retain never touch them; 404 for a hash never stored; /api/manifest's rule: 403 with badge: "hidden" to a read-scoped panel token; the run document carries manifest_available: true when its hash resolves), DELETE /api/runs/{id} and DELETE /api/sessions/{id} (plan A7, the delete capability: a run with its subagent children, or every run of a session with theirs, removed from Studio's database — run rows, records, the search index, the spans, the non-weft spans below the runs' own and the app log lines under them; 200 with {deleted: "run" | "session", run_id | session_id, note}, 404 for an unknown id. Studio's tables only: the session verb removes the session's observability rows and says so — the app's conversation store is untouched, and a SQLite file that also holds thread's sessions keeps them byte-identical. The dev token's, or a loopback Host's in setup A — never an AllowOrigins host's, never a panel token's: every panel token, read or playground, is 403 before anything is looked up. A live stream following a deleted run emits nothing more unless the app keeps writing; a later write starts a new run row; a later read is 404), 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/all), 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. all=1 (plan A7, the Live page) streams every run in the database as its frames are published — subagent child runs included, each frame naming its own run_id (a record that names no run is not forwarded); a resume (Last-Event-ID, however old) backfills a bounded set — the runs running or last seen within the last hour, newest first, at most 500 — never the whole database, never an error; a resume names a run it could not read with event: backfill_error {run_id, kind} (the cause in the log). A grant names one stream: a run grant's sig never opens all, nor an all grant's a run. It is setup A's on a loopback Host and the server token's alone: every panel token, read or playground, is 403 forbidden on the stream and on its grant (the body form is {"all":true}), as is a Host let in only by AllowOrigins; /api/meta's live_all tells the UI whether to offer 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 (the 50 most recently updated by default, ?limit= up to 200, with total; id: [A-Za-z0-9._:-]{1,128}, as command_id; each row with created, run_count, cost_usd/cost_known; a POST creates or replaces the whole definition — engine/side_effects/thread/from_step/source_run_id/transcript_edits kept as sent, per variant its overrides + modes), GET /api/experiments/{id} (the definition with its whole command and its runs), 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.

GET /api/panel-handoff?to=<path> (capability handoff; registered only with a Token, else capabilities_off.handoff says why): setup C's "open in Studio". A playground-scoped panel token gets a 303 to <base><to>#token=<t>, where t is a read-scoped panel token for the same public id, signed like /api/panel-tokens's, expiring in 5 minutes or at the asking token's own expiry if sooner, logged as a hand-off without the token; Accept: application/json gets the same as {"location","exp"} for a fetch. The server token gets the 303 with no fragment. A read-scoped panel token is 403: it cannot widen, and its page links to Studio directly. to must be a path under the base — a single leading /, no scheme, host, fragment, backslash, white space or ./.. segment (raw or %2e) — else 400: never an open redirect.

Capabilities are computed from the registered route groups (routes.go) — never hard-coded: requests, steps, export, logs, diff, traces and pricing (always present), live (the stream and its grant), ingest, auth (with a token), search (when the database implements obsdb.Searcher — both first-party backends do; capabilities_off.search says so otherwise), and runtimes, breakpoints, steer, cancel + 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. Nothing can Close the server it builds, so a Studio with Retain is built with New and closed: under Handler the sweeper stops only when the DB is closed. A playground's command store writer (plan I3) is stopped by Server.Close alone — closing the DB does not stop it: under Handler it stays, one goroutine blocked on its queue for the life of the process (harmless), and a write it takes after the DB closed fails, logs one WARN and holds the queue. A Studio with Playground(true) that is ever shut down is built with New and closed, so Close drains the writer and stops it.

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 ModelPrice added in v0.14.0

type ModelPrice struct {
	// Provider is the run's provider the row matches ("anthropic");
	// "*" — what a file's missing provider loads as — matches any, the
	// row then looked up on ModelGlob against the model alone.
	Provider    string   `json:"provider"`
	ModelGlob   string   `json:"model_glob"`
	Input       float64  `json:"input"`
	Output      float64  `json:"output"`
	CachedInput *float64 `json:"cached_input,omitempty"`
	CacheWrite  *float64 `json:"cache_write,omitempty"`
	Reasoning   *float64 `json:"reasoning,omitempty"`
}

ModelPrice is one row of a PricingTable: a model glob and its rates in USD per 1M tokens. A nil optional rate prices that token kind at the base rate (CachedInput and CacheWrite at Input, Reasoning at Output).

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 Pricing added in v0.14.0

func Pricing(t PricingTable) Option

Pricing sets the table Studio prices usage with (plan A5). Without it, New reads the file WEFT_PRICING names (LoadPricing; a file that does not load panics at New, naming the path and the field), else uses DefaultPricing. The table replaces the default whole.

func Retain added in v0.14.0

func Retain(d time.Duration) Option

Retain keeps runs for d after they were last seen: a sweeper deletes (obsdb.DB.Sweep) every run that is not running — succeeded, failed or interrupted (a run silent past obsdb.InterruptedAfter counts as interrupted, hence not running, and is swept) — whose last_seen is older than d, with its children, from obsdb's tables only. It sweeps once when New returns, then every min(d/10, 1h), never more often than once a minute; each sweep logs one Debug line (slog's default logger) with its count, and a failed sweep one Warn line — it never stops the server. Once the DB is closed (obsdb.ErrClosed) the sweeper stops on its own with one Debug line. 0 (the default) is off: the local sink keeps everything until told otherwise. api/meta reports it as retention.

d must be at least an hour: New panics on 0 < d < 1h. The sweeper's not-running rule counts a run silent for 30 s as interrupted, so a short retention would sweep a live run paused at a breakpoint.

The sweeper runs until Close: a Studio with Retain is built with New and closed, never through Handler(...), whose server nothing can close (its sweeper ends only when the DB does). The weft binary reads it from --retain / WEFT_RETAIN.

Example

ExampleRetain keeps a month of runs: the sweeper deletes the older ones that are not running, from Studio's tables only, until Close. api/meta reports the retention.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"time"

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

func main() {
	dir, err := os.MkdirTemp("", "weft-studio-retain-")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = os.RemoveAll(dir) }()
	srv := studio.New(studio.Open(dir+"/weft.db"), studio.Retain(30*24*time.Hour))
	defer func() { _ = srv.Close() }()
	ts := httptest.NewServer(srv.Handler())
	defer ts.Close()

	resp, err := http.Get(ts.URL + "/api/meta")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = resp.Body.Close() }()
	var meta struct {
		Retention *string `json:"retention"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(*meta.Retention)
}
Output:
720h0m0s

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 PricingTable added in v0.14.0

type PricingTable struct {
	// Dated is the day the rates were read, ISO 8601 (2026-10-10).
	Dated string `json:"dated"`
	// Note is the file's comment: what the rates are and where from.
	Note string `json:"note,omitempty"`
	// Models are the rows, first match wins.
	Models []ModelPrice `json:"models"`
	// contains filtered or unexported fields
}

PricingTable is a pricing table: pricing.json's shape (and a WEFT_PRICING override file's). Build one in Go, or read one with LoadPricing; DefaultPricing is the shipped table.

func DefaultPricing added in v0.14.0

func DefaultPricing() PricingTable

DefaultPricing is the table Studio ships: pricing.json, generated from the vendors' public list prices on its Dated day. Nothing is fetched — a newer price arrives with a newer weft, or with an override file.

func LoadPricing added in v0.14.0

func LoadPricing(file string) (PricingTable, error)

LoadPricing reads a pricing table file (pricing.json's shape). A file that does not parse, names an unknown field, misses input or output, carries a negative rate, a bad glob or a dated that is not YYYY-MM-DD is an error naming the path and the field — never a silent fallback to the default. A row's missing provider loads as "*": the row matches on its model glob alone, any transport. The table it returns reports source "file" and its path.

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: Retain's sweeper, and 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 —
internal
pricingdata
Package pricingdata is the hand-maintained source of Studio's default pricing table (plan A5): studio/pricing.json is generated from it by studio/internal/pricinggen (go generate ./studio/...), committed, and held equal to JSON's output by studio's TestPricingJSONIsGenerated.
Package pricingdata is the hand-maintained source of Studio's default pricing table (plan A5): studio/pricing.json is generated from it by studio/internal/pricinggen (go generate ./studio/...), committed, and held equal to JSON's output by studio's TestPricingJSONIsGenerated.
pricinggen command
Command pricinggen writes Studio's default pricing table, studio/pricing.json, from studio/internal/pricingdata (plan A5).
Command pricinggen writes Studio's default pricing table, studio/pricing.json, from studio/internal/pricingdata (plan A5).
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