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

studio

package
v0.20.3 Latest Latest
Warning

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

Go to latest
Published: Oct 31, 2026 License: MIT Imports: 53 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, the run tree on a time scale 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. What a non-Go app gets today: its OTel traces, the runs derived from them (docs/record-contract.md §9) and the devtools panel through panel.js — not the playground, breakpoints or approvals, which need a runtime link (weft/runtime: Go only today; non-Go clients are backlog I2):

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_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:7331 OTEL_METRICS_EXPORTER=none OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true opentelemetry-instrument python app.py

The receiver is OTLP/HTTP only (POST /v1/traces and POST /v1/logs at the root, protobuf or JSON; no gRPC, no metrics): the endpoint is the bare origin, to which the exporter adds /v1/traces and /v1/logs itself, and Python's exporter, which defaults to gRPC, needs OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf (the JS one defaults to it). A stock invoke_agent span makes a run, and gen_ai.conversation.id groups runs into a session (record-contract §9); any other span is a trace (/traces). Such a session is marked "derived (stock OTel)" on the sessions list and its page (the session row's derived: true), and its page numbers its turns by start order (the session document's turn, 1..n; the run's own row keeps 0; UX-H-35).

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, an explicit otel.Local or otel.Studio never reads it — the root README's "When weft/otel reads the file"); ingest is open on loopback; 127.0.0.1:7331 is the one default port: started again on the same SQLite file, the binary is refused: one Studio per database file, held by <db>.lock beside a SQLite file — an exclusive OS lock (flock; LockFileEx on Windows) held while it serves, taken before any port is probed or bound, and released by the kernel when the process ends, a crash or kill -9 included. Two starts at the same moment, or from directories whose discovery files differ, cannot both serve it; the refusal names the holder (studio already running at http://127.0.0.1:7347 (pid n) on …/weft.db, from the lock file or the discovery file, else another Studio holds <db>), exit 1; weft dev reuses it instead (weft dev takes the lock first too). The lock stands for the file a symlinked path names; hard links and two spellings on a case-insensitive filesystem are not detected. The lock file names its holder only while it is yours (0600, your uid; Windows skips the owner check), its url a loopback IP and its pid alive — a crashed Studio's words name nobody, and weft dev hands the database's token to no other host. A ClickHouse database is a shared server and takes no lock. The port probe still asks /api/meta (db.path, pid, read with the stable token — sent only to an address a trusted discovery file names), the discovery file's Studio first; 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; without it the banner ends studio: sign in with weft open --with-token.

The same binary is the API over a terminal, each subcommand one route, one resolver picking the Studio for all of them: --url, else WEFT_STUDIO_URL, else the discovery file (the trust rules apps use; WEFT_DISCOVERY=off skips it), else 127.0.0.1:7331 — one stderr line weft: Studio <url> (discovery file …) when you named none — and the token --token, else WEFT_STUDIO_TOKEN, else the file's when it names that Studio: weft runs [--agent] [--since] [--failed] [--status] [--limit] [--json] (GET /api/runs; the error's kind and why, duration, cost and public id in the table; a limit that hid runs says so on stderr), weft open [id] [--open] [--with-token] (a run, public, session or trace id — GET /api/runs/{id}, /api/public/{id}, /api/sessions/{id}, /api/traces/{id} in turn — its bare page; no id, the Studio root; 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 ./pkg [--fixtures-only [--test TestName]] [--force] (GET /api/runs/{id}/export: the save-as-test zip's tree written into the package directory byte for byte — <agent>_replay_test.go beside testdata/TestReplay_<run>/; --fixtures-only the fixtures alone where wefttest.Replay(t, "testdata") reads them; --force replaces what is there), weft doctor (GET /api/meta; with the discovery file, the OTLP door and the newest run's weft version among its lines), 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, tags, scope, exp} minted by your backend through POST /api/panel-tokens; every data route refuses anything outside the token's public id.

A token can instead (or as well) be scoped to a batch of runs by their metadata (I4, capability panel_token_tags): POST /api/panel-tokens {"tags": {"arena.batch": "20261014-1606"}, "ttl": "1h"} — public_id, tags, or both; at most 16 pairs, each key and value non-empty and at most 256 bytes, and no weft.* key but weft.flow (the others are the record contract's attributes: a 400 says which) — signs the tags into the token like the public id, and every read it makes is held to them the way a public id token's is held to its public id: the runs list, search, stats and traces carry the tags as a filter (traces: every span carries them; stats' by=agent delegated bill sums only descendants carrying them with every run between them and the group's run carrying them too — the tree's rule, so it matches what the run document and the span tree show), a single run without them is 403 (the rule another public id's run gets; an unknown id stays 404) with its steps, spans, logs, export and tree, a session lists only its tagged turns (SessionQuery.Meta scopes turns before the grouping; its compacted filter still reads the whole session's compaction markers, which carry no metadata), and the live stream and its grant deliver only tagged runs — the panel's data-scope="tag.arena.batch=…" lane included, across every public id of the batch, and with ;agent=… the agent's runs of the batch (a tag-only token's agent= stream is held to its tags; a public id's token still gets 403 for agent= alone). A request narrows (more tags, flow=, agent=, public_id=) but never widens: another value of a token's tag key is 403. A child run is read by its own row: a Subagent child inherits its parent's metadata (core.WithMetadata) and is in the batch; a pool or thread child session's turn carries only the tags its app gave it, and is otherwise a counted hidden hole. A token with tags is read-only ("playground": true beside tags is a 400), and a tag-only token resolves no public id (/api/public/{id} is 403) and reads no command (/api/playground/commands/{id}, /api/runs/{id}/commands: 403 — commands are a public id's). Its hidden holes say "without your token's tags". Metadata is caller-written — unlike the public id, which the app hands out — so a tag scope is only as tight as the app's own tagging: minting stays the server token's, and a tag your app lets a user set is a tag that user can join. A run the token may read can have child runs that carry another public id, or none (a nested Generate or thread turn inside a tool handler, a pool child session, a child whose Metadata cleared weft.public_id): every route that reaches runs through another — the run document's children[] and the export's run block, a step's children and its calls' child_run_id, every row's children_count / children_failed (runs list, search, a session's turns, the live lane's run frames), ?tree=1 on spans and the export — keeps the token's public id's children alone (a child row must carry it, the tree's rule) and says the rest as one hidden hole with a count (the run document's and the step's holes, the tree export's subagent_holes), never by id, public id or prompt. The run's own cost still takes their usage out (the core rolled it in); its delegated figure is not known to the token (delegated_cost_known: false, the reason counting the runs it cannot read). 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) or no token, the plain link opens; under the dev token (setup B, UX-D-18) the hand-off answers with that same token in the fragment, as weft open --with-token opens it, so the tab lands signed in; 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 (25.6 or later, the schema's floor: obsdb/clickhouse/README.md) deletion is a lightweight DELETE FROM … WHERE 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 (…)"; "not known", without "yet", when every unknown part is a derived run whose spans report no usage — it never becomes known, UX-H-07), never a clause per run; such an unknown figure carries cost_partial {usd, priced, of} — the sum and count of the parts whose whole bill (own + delegated) is known, absent when none is — which Studio's sessions list and session page draw as the panel's header sums the same turns (atLeastCost, one rule: a turn whose delegated part is unknown adds nothing): "≥ $x · k of n turns priced", "≥ $0 · k of n turns priced" when the priced turns are $0, "—" when none is (parity.test.ts feeds testdata/api/cost-parity.golden.json to both); 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. A page of runs (the runs list, search, an experiment's runs, a run document's children) reads its subagent trees for the own/delegated split one obsdb.TreeReader.ChildRuns read per tree level, never one per row (U9.1); a database without a TreeReader reads each run's children one run at a time, to the same figures. 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

Quickstart — three setups, each one line of markup or code; the rest of this section is the reference.

  • The Go app serves Studio (setup A, studio.Handler mounted at /studio/): <script type="module" src="/studio/panel.js" data-weft data-scope="pub_…"></script> — same origin, no token.
  • A separate weft studio / weft dev (setup B): the tag names the Studio and carries a token — in development the dev token (weft open --with-token prints it in a link), on a page you ship a panel token your backend mints (POST /api/panel-tokens): <script type="module" src="http://127.0.0.1:7341/panel.js" data-weft data-endpoint="http://127.0.0.1:7341/" data-token="…" data-scope="pub_…"></script>.
  • A bundled app with no <script> tag: import "@weftgo/devtools" (or mount({endpoint, scope}), useWeftDevtools(…) in React), the same ladder minus the tag (web/npm/README.md).
  • Hand-off: the panel's ⤢ links open the same run, step or session in Studio.

Where the panel's scope comes from is the ladder (explicit, header, DOM marker, URL, fallback); its attributes are the table under Layout, attributes, theme and memory.

In this section:

Install: the script tag, a token, npm

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>

A backend that is not Go serves the same file from the GitHub release asset (panel-vX.Y.Z.js with its .sha256, attached to every weft release). A bundled app with no <script> tag uses the @weftgo/devtools package — not published to npm yet: build its tarball with make devtools-npm and npm pack in studio/web/npm (studio/web/npm/README.md).

Against a Studio that wants a token — setup B, weft studio, whose token is in <db>.token (weft open --with-token prints a signed-in link) — the tag carries it (on a page you ship, a panel token your backend mints instead):

<script type="module" src="http://127.0.0.1:7341/panel.js" data-weft
  data-endpoint="http://127.0.0.1:7341/" data-token="<dev token>" data-scope="pub_…"></script>
What it shows

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 · first token 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 (inline at any depth, each read only once opened, with the child's request line and an "open in Studio" hand-off; past two levels the indent stops and the row says "depth N"; a child's final words that are its call's result are drawn once, on the call — UX-D-13) and a spans waterfall.

The step read (U10.2, the audit's §6.3): the Story's selected step — else, unpicked, the run page's landing step: the one the run failed at (lib/summarize.ts's failedAtStep; a step no card draws — a cancel before it began — falls back to the last drawn, landStep), else step 0 (P8-fix-a) — opens answer first, the run page's drawer at panel width: lib/summarize.ts's verdict line ([data-verdict]: what stopped the run there, the finish, tokens, latency, first token) and its sent / replied / tools ran / then lines ([data-answer-line]), the same words parity.test.ts holds to Studio's drawer on opt.usage-limit; its Logs and Raw OTel hand off (the step head's 📄 ⤢ — U11.2's logsLink, under capability logs, for no token or the dev token only: the logs route refuses every panel token — and the answer's Raw OTel ⤢: the run page at the step's row, ?sel=s<n>&tab=logs|raw, lib/links.ts's runLink — no token in the URL; a playground panel token opens it through the hand-off, as every [data-weft-handoff] link does). Each step's request is one line by default (UX-D-12: the attempts, the changed marks, the badges) and opens on a click to the prompt, the catalog and the params — the params only when one is not the adapter's default; the "insert a message" lines show on hover or focus.

The views and tabs

The views (plan D4). The open turn has four tabs, a role="tablist" (←/→, Home/End move and select; one tab stop), labelled with src/lib/links.ts's VIEWS as Studio's run page labels its views: story (the step story above, and the playground's drawer and result), request (the step's request, below), waterfall (before U21.1 "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, a step before its calls) and raw. Its rows are src/lib/waterfall.ts's run tree (U8.1), the one row model both renderers draw: waterfallRows({runId, status, spans, fold, total, children, expanded}, scale?) gives run › step › model call (› attempt) / tool (lane) › child run on a time scale (ms from the earliest span, sub-millisecond kept) or an order scale (stream positions, a run without spans), each row with its selection key (rowKeys, the one scheme the trace page keys by too), offset and duration (took: 15µs, 9.5ms, 7.7s), a gist from the folded events and transcript, its status, its lane and whether it is on the critical path; a child run is one collapsed row until expanded, its rows from its own spans (each carrying its weft.run.id, the ?tree=1 shape) or fold, hung under the tool span its invoke_agent span names as parent (call ids repeat per step; weft.parent.* only when that span is absent) — a nested Generate's run, or a live delegation whose tool has not ended, is a child row too, and a running run's in-flight steps and calls are open rows on the time scale; dimRows dims (never hides) by errors, slow or tools. The waterfall tab draws those rows as Studio's run page does, one level deep (U8.3): the same keys, labels (with the gist as the label's title), kinds (ROW_KIND, glyphs KIND_ICON: ▣ run, ↻ step, ◆ model, ◇ attempt, ⚙ tool, ↳ child run, ○ span) and tones (rowTone, coloured through lib/palette.ts's TONE — Studio's variable for each, the same hex, palette.test.ts), indented by depth, the critical path under the bar, each row's extent with its offset as the title, a row no span covers "spans not reported yet" or badged not_recorded; a child run is one row whose ⤢ opens Studio's run page at it (?sel=r:<child>, where it expands in place), never its subtree; a click on a step's row selects that step in Story (what the header's ⤢ carries) — a step's rows are buttons on one tab stop (the selected step's, else the first; ↑/↓ move it, Enter/Space select; the run row is no control). The panel reads /spans without ?tree=1: a child row's extent is its tool call's, its label its agent from the run document (waterfallRows' agents, else its tool's name — one rule on both scales and surfaces, the label Studio reads off the child's invoke_agent span). parity.test.ts's "the run tree on both surfaces" holds the two renderers to the same rows on the director run. Studio's span list (lib/trace.ts's spansFromTimed) walks the same span tree (spanTree) and time domain. 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 waterfall tab'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 (the sum is the k priced turns', Go's cost_partial rule: a turn whose delegated part alone is unknown adds nothing, its reason "delegated: …"; never "<" under "≥"), "≥ $0 · k of n turns priced" when the priced turns are $0, "—" when none is priced; 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 waterfall tab 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 waterfall tab 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

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

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 (U17: the drawer reads in lib/drawer-spec.ts's order on both surfaces — source, target, engine, 1 what changes, 2 will be sent, 3 tools during the replay, then side effects, thread mode and the option lab folded under "advanced", and Run; runtime-wide breakpoints are not a per-replay knob, the drawer points to the /runtimes page (U20.1); the engine is chosen per command by lib/engine.ts's engineChoice — scripted when it can answer: a source run, no edit (one equal to the recording is none: "edit this result" opens on an empty field), no prompt, model, input, fork, tools, thinking or tool-choice change, a from_step the source answered, and the preview's scripted_key_match / scripted_hit not a miss when the server sends them — else live; two radios with one line each, scripted's reason when it is disabled, live's "calls · ~N tok ≈ $x" from the source step's step-route cost (its usage at the pricing table's rate), "cost unknown: " when there is none, never $0; a pick is never switched — a scripted pick that cannot answer holds Run and says why; Run reads "Run on scripted · 0 tokens" or "Run live on · ≈ $x"; U17-fix: the facts are lib/engine.ts's factsOf over the overrides the body sends — a prompt, model, thinking or tool choice equal to the agent's own is no change and is not sent, an input equal to the source's none, parallelism 1 (SequentialTools) a key change; the preview's scripted_key_match miss is said in the server's own message; the estimate sums the source's recorded steps from from_step on (≥ $x and "later steps also run live" when some are not read); the playground and a saved experiment's cell re-run use the same rule and radios, an engine picked there riding the URL and the form; the preview folds runs of unchanged messages as "N same"; Studio's Run sits in a sticky footer); 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=…&scale=time|order|graph&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 scope and the ladder that finds it

The panel follows a scope, pub_…;session=s_…;flow=f_…;run=r_…;agent=…;tag.<k>=<v> (src/lib/scope.ts, weft/scope's Scope; one golden pins both). 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 narrows every read to the runs stamped weft.flow with it (run metadata: scope.Header stamps a scope's flow on the runs its handler starts, scope.WithFlow any context's) and shows as a chip.

A page of unrelated runs — a test harness, a CI or eval batch — has no conversation: name it by its run metadata instead, data-scope="tag.arena.batch=cli-20261012-115753" (repeatable tag.<k>=<v> segments, ANDed; agent= the same way; the tags are what the app stamped with core.WithMetadata or core.Metadata). A tag, agent or flow scope stands without a public id (its string form then starts with ;, and a first segment that is one of these keys is read as one). The panel then lists that page's runs — /api/runs?tag.<k>=<v> (and agent=, flow=) with the live lane on /api/live?tag.<k>=<v> — and the header reads batch cli-20261012-115753 · 196 runs: the first tag's key by its last dotted segment, the match's whole count; the rows say "run" and name their agent. Under a public id's panel token a tag scope reads what the token already allows (its public id's runs); under a tag-scoped panel token (setup C above: "tags" in the mint) it reads the whole batch across public ids, live lane included — and without a data-scope the token's own claims seed it (rung 4b). A tag scope is only as tight as the app's own tagging. The flow is caller-written metadata the same way: scope.Header stamps weft.flow from the scope your handler returns, else from the request's own Weft-Scope header — which the client controls, as it controls any tag — so a flow narrows a view, never grants one. A tag, agent or flow scope with no public id opens its live lane wherever agent= alone would (the dev token, setup A on any allowed Host); agent= beside a public id, session or run narrows that lane to the agent's runs. Rows read "turn" only for a public id with no tag, agent or flow; otherwise every word is "run", and each tag the title does not name is a chip.

Scope Write it as Lists
one conversation data-scope="pub_…" (or ?weft_scope=pub_… on the page's URL, no code) its turns
one session of it pub_…;session=s_… that session's turns
one flow ;flow=f_checkout (or pub_…;flow=…) the runs stamped weft.flow=f_checkout
a batch of runs data-scope="tag.arena.batch=…" (core.WithMetadata) the runs whose metadata carries every pair
one agent's runs ;agent=director (with tags: that agent's runs of the batch) those runs
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). Before it, the footer names the rung that chose the scope followed (UX-D-23): scope: explicit, scope: url, scope: marker, scope: header from /run (the path of the response that carried it), scope: switcher (your pick) or scope: none (the dev list). An explicit or URL public id with no run in Studio reads "Studio has no runs for pub_… yet — check the id (scope: explicit)" — it may be a wrong id (UX-D-37).

# 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 — the zero-code rung: ?weft_scope=… (read first), else #weft_scope=… — the same string form, URL-decoded (#weft_scope=pub_x%3Bflow%3Df_1, ?weft_scope=tag.arena.batch%3Db1); a value naming neither a public id nor a tag, agent or flow 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
4b a panel token's claims (UX-D-09, I4): a weft_pt. token's public_id claim and its tags (each a tag.<k>=<v>), read as a hint (Studio still enforces the token) — the conversation or the batch it can read anyway when 1–4 name nothing and the token is a panel token 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, each with its agent, none selected and no summed cost when 1–4 name nothing any line under "how to scope": add ?weft_scope=pub_… to this page's URL (no code), 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) — and an "all rungs ⤢" link to this table; a batch of runs is named by its tags (above)

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 — its rows grouped by agent, each group where its newest run is — says, with "how to scope" open, live: newest agent only (X) · the others 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 live updates every 10 s (panel tokens poll) — but a panel token naming a public id scopes the panel to it first, rung 4b, so its page has no fallback). 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, attributes, theme and memory

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 figure (· $0.0029) when Studio priced it, else its tokens in→out once the run has finished (· 300 in · 58 out: an unpriced model, a Studio without the pricing capability), — while it runs (UX-H-31). 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-font-prose (a step's text, the prompt and the notes: system-ui, sans-serif, no webfont; ids, JSON and numbers stay in --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

The keyboard (src/lib/keys.ts's PANEL_KEYMAP, the ? list; a key Studio binds too — j/k, [/], s/w/q/r, o, /, ? — means the same there, STUDIO_KEYMAP). 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 item of the list in focus (the turns)
[ / ] previous / next step (⤢ carries it); J / K too
s / w / q / r the story · waterfall · request · raw tab (again: back to the tab before it)
o open the turn and step in Studio (lib/links.ts, the link ⤢ carries), in a new tab; g s too
/ 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
Rendering and accessibility

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", its aria-label the row in words (turn 3, parked, <title>, 2 steps, 60 in 30 out, 3h ago, U29.5); 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 ▸/▾; Raw is a tab (r toggles it; the header's raw button is gone, U29.5)
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 in · 4 out); from 480 px of dock width ⤢, ⇆, ◐ also show their word (Studio, layout, theme — a data-w attribute as :before content under a container query; each word inside its label)
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, thread; the engine radios inside their labels; the result pane's compare with; tool 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 waterfall 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

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 module script whose src resolves to the bundle's own URL (import.meta.url) when it carries data-weft (two marked tags on a page each read their own, UX-D-28), else the first with a data-weft attribute (any src, any value: a config-only tag beside a bare include), else the module's own bare tag — 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.

When Studio does not answer

No Studio answering: the dock the script mounted by itself removes itself silently unless the endpoint is one you named on loopback (production safety) — at most two requests (panel-config.json, then meta; panel-config.json gives up after 3 s), and no console line unless its <script> tag names no endpoint and carries no data-weft (a bare include, which 0.10 recognised by its file name) and the endpoint it guessed is on loopback (a dev page): then one console.warn, "weft devtools: Studio did not answer at ; add data-weft or data-endpoint on the panel's

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 DiscoveryFile added in v0.20.0

func DiscoveryFile(path string) Option

DiscoveryFile names the discovery file the command hosting this server writes for it (`weft studio` and `weft dev`, plan B3: the studio.json apps find a running Studio through). api/meta serves it as discovery_file, under db.path's rule, while the file at path names this process — a file never written, removed, or since taken over by another Studio is not served. An embedded Studio (setup A) writes no such file and leaves it unset.

Example

ExampleDiscoveryFile is what `weft studio` does for the discovery file it writes: it names the path to the server, and api/meta serves it (to the server token, or loopback with no Token) while the file there names this process — Studio's connect lines print it. Here the file is written by hand, as the command writes it: 0600, this pid.

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-discovery-")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = os.RemoveAll(dir) }()
	path := dir + "/studio.json"
	body := fmt.Sprintf(`{"url":"http://127.0.0.1:7331","pid":%d,"started":%q}`, os.Getpid(), time.Now().UTC().Format(time.RFC3339))
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		fmt.Println(err)
		return
	}
	srv := studio.New(studio.Open(dir+"/weft.db"), studio.DiscoveryFile(path))
	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 {
		DiscoveryFile string `json:"discovery_file"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(meta.DiscoveryFile == path)
}
Output:
true

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 OffReason added in v0.20.0

func OffReason(capability, why string) Option

OffReason replaces why meta.capabilities_off says capability is off — and every capability off for the same cause (the playground's eight) — with the host's own words: `weft studio --no-playground` says "weft studio was started with --no-playground", not the Go option an embedding app would set. It changes words only, never which capabilities are on. A capability that is no optional one panics at New (a construction error, as Retain's floor is).

Example

ExampleOffReason is how `weft studio --no-playground` words the playground's absence for a user who never wrote Go: meta's capabilities_off (and `weft doctor`) say the flag.

package main

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

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

func main() {
	dir, err := os.MkdirTemp("", "weft-studio-offreason-")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = os.RemoveAll(dir) }()
	srv := studio.New(studio.Open(dir+"/weft.db"), studio.Playground(false),
		studio.OffReason("playground", "weft studio was started with --no-playground"))
	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 {
		Off map[string]string `json:"capabilities_off"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(meta.Off["runtimes"])
}
Output:
weft studio was started with --no-playground

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

Example (TagScope)

ExampleToken_tagScope mints a panel token scoped to a batch of runs by their metadata (I4) — server token only — and reads with it: its own batch answers, another value of its tag key is refused. A tag scope is only as tight as the app's own tagging (metadata is caller-written).

package main

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

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

func main() {
	dir, err := os.MkdirTemp("", "weft-studio-tags-")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = os.RemoveAll(dir) }()
	srv := studio.New(studio.Open(dir+"/weft.db"), studio.Token("server-secret"))
	defer func() { _ = srv.Close() }()
	ts := httptest.NewServer(srv.Handler())
	defer ts.Close()

	do := func(method, path, bearer, body string) *http.Response {
		req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
		req.Header.Set("Authorization", "Bearer "+bearer)
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}
		return resp
	}
	resp := do(http.MethodPost, "/api/panel-tokens", "server-secret", `{"tags":{"arena.batch":"b1"},"ttl":"1h"}`)
	var minted struct {
		Token string            `json:"token"`
		Tags  map[string]string `json:"tags"`
		Scope string            `json:"scope"`
	}
	_ = json.NewDecoder(resp.Body).Decode(&minted)
	_ = resp.Body.Close()
	fmt.Println(minted.Tags["arena.batch"], minted.Scope)

	for _, path := range []string{"/api/runs?tag.arena.batch=b1", "/api/runs?tag.arena.batch=b2"} {
		resp := do(http.MethodGet, path, minted.Token, "")
		_ = resp.Body.Close()
		fmt.Println(path, resp.StatusCode)
	}
}
Output:
b1 read
/api/runs?tag.arena.batch=b1 200
/api/runs?tag.arena.batch=b2 403

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). Every request carries an X-Request-Id (the client's, else one generated) and a refused ingest batch or a 5xx is one line on slog.Default naming it (studio/internal/reqlog, UX-E-37).

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).
reqlog
Package reqlog is Studio's server log of failed requests (UX-E-37, audit row D10): every request carries a request id — the client's X-Request-Id when it sent a well-formed one, else a generated one — echoed on the response; a refused ingest batch (a 4xx on /v1/) is one WARN line and every 5xx one ERROR line on slog.Default, each naming the request id, the status and the reason the handler answered with (Refuse; Cause when the body masks the real error).
Package reqlog is Studio's server log of failed requests (UX-E-37, audit row D10): every request carries a request id — the client's X-Request-Id when it sent a well-formed one, else a generated one — echoed on the response; a refused ingest batch (a 4xx on /v1/) is one WARN line and every 5xx one ERROR line on slog.Default, each naming the request id, the status and the reason the handler answered with (Refuse; Cause when the body masks the real error).
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