studio

package
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Oct 7, 2026 License: MIT Imports: 32 Imported by: 0

README

weft studio — the Inspector

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

The three setups (S4.6)

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

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

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

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

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

go run ./studio/cmd                    # UI + OTLP + SQLite + dev token on 127.0.0.1:7331
WEFT_STUDIO_URL=http://127.0.0.1:7331 ./my-go-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:7331 python app.py

The dev token is printed at start (WEFT_STUDIO_TOKEN or --token fixes it); ingest is open on loopback; --db sqlite://path picks the file, --db clickhouse://user:pass@host:9000/db the hosted backend (its own module, the one place the driver is imported).

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

The devtools panel (WEFT-DEVTOOLS.md)

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

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

Rung 1 is a viewer scoped to that conversation: the turns (parked shown), the step story with tool calls, usage splits, approvals read-only, truncation/gap/stripped honesty, the raw JSON, a live tail, ⤢ deep links into Studio, lazy subagents and a spans waterfall. Alt+W toggles (Q4), ? lists keys, r flips raw. 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). No Studio answering: the panel removes itself silently. The artifact is built by studio/web/vite.panel.config.ts (a separate library-mode build), the committed studio/dist/panel/panel.js, 63,541 B raw / 17.0 KiB gzip; make studio-panel-asset stages it as panel-<version>.js + sha256 for non-Go backends.

The playground (WEFT-PLAYGROUND.md)

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

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

Studio's side is studio.New(..., studio.Playground(true)), which serves GET /api/runtimes (the connected runtimes), POST /api/playground/runs (the §10.4 validation table: 400 unknown tool/model names and unsupported 8b modes, 403 raised limits or a refused side-effect tool, 404 unknown runtime/agent/source run, 409 a reused command id, 503 with no runtime connected) and GET /api/playground/commands/{id}, plus the runtime link's own routes (POST /api/runtime/register, GET /api/runtime/commands SSE, POST /api/runtime/acks). Safety: off unless WEFT_ENV=dev or runtime.Enabled(true); overrides only narrow; a side-effect tool's call is substituted with its recorded result or parked (weft.Replay(weft.ReplaySafe) vouches a read, AllowSideEffects opts a tool into allow mode); budgets cap each experiment; the app's own runs are never touched.

P1–P5 ride the same command: transcript_edits (validated on both sides — a patch names a call in the kept prefix, the prefix ends at a step boundary with every call answered), engine: scripted (the source run's recorded turns at zero tokens; scripted + an instructions/model override is refused — the §5.5 prompt trap), thread fork (a new session with lineage the panel can keep chatting in), POST /api/runs/{id}/approvals (a parked run's continue/skip/resolve — ADR 0007's own verbs), POST /api/playground/fixtures (the run's records as wefttest replay fixtures), and GET/POST /api/experiments with GET /api/experiments/{id} (the saved groups, PQ4). The debugger's rungs 3–4 act on runtime-started runs only (D7, PQ7): PUT /api/runtimes/{id}/breakpoints (capability breakpoints) and POST /api/runs/{id}/steer (capability steer) — meta.debug_scope says so. The panel's experiment drawer (the §3 form, the live result with the inline diff, the approvals, the 2-way compare) and the Studio /playground page (variants side by side with the metrics, the E9 variants × inputs matrix, the experiment history) both render them, each control only for its reported capability.

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

runs list, light theme

The run page (light) — the prompt and the answer first (the answer now comes from the transcript: deltas are live-only), then the facts; the trace draws on the time axis when the run has spans (real durations, ?axis= picks; the position axis keeps the replay playhead), and subagent children expand lazily — their events are their own runs', fetched on demand:

run page with the trace, light theme

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

Every view is a URL: filters, view=story|raw (and raw=doc), the selected span sel and detail mode d, the axis, the selected step, and the replay position t all live in search params. Keyboard: ⌘K jumps to any recent run, / filters, j/k move, enter opens, e/s/r switch trace/story/raw, space replays, [/] jump by step or tool event, ,/. move one event, ? lists everything. Keys fire only on a bare press outside a text box.

The API (S4.2/S4.3)

JSON under {base}api/: meta (versions, the DB kind, ingest_open, interrupted_after_ms, capabilities), runs (agent, status, session, public id, playground, parent and tag.<k>=<v> filters, cursor-paged), runs/{id} (the row and the subagent children — never events), runs/{id}/events?after=&limit= (the paged durable stream), runs/{id}/transcript (the messages bodies), runs/{id}/spans, traces/{trace_id} (any trace), sessions, sessions/{id} (turns in order), public/{public_id}, manifest, POST /api/panel-tokens (mint; the panel's scoped tokens — see the devtools panel above), and GET /api/live — the SSE stream whose frame ids are the hub's Seq: exactly one selector (run/session/public_id/agent), kinds over event/delta/messages/run (default event,run; deltas are opt-in, heartbeats never), a ping every 15 s, Last-Event-ID resume with the gap backfilled from the database and deduped on (run, kind, pos), and event: overflow when a slow subscriber's queue drops it. OTLP ingest is POST /v1/traces and /v1/logs (protobuf and JSON, gzip, 16 MiB after decompression, publish-then-write, 503 on a write failure so the exporter retries). Under Playground(true) the playground's routes join (GET /api/runtimes, POST /api/playground/runs, GET /api/playground/commands/{id}, POST /api/runs/{id}/approvals, POST /api/playground/fixtures, GET/POST /api/experiments, GET /api/experiments/{id}, PUT /api/runtimes/{id}/breakpoints, POST /api/runs/{id}/steer — see the playground above) beside the runtime link's own (POST /api/runtime/register, GET /api/runtime/commands SSE, POST /api/runtime/acks — server token only, never a panel token); /panel.js serves the devtools panel bundle — static and unauthenticated.

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

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

Contributing to the UI

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

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

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

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

Go module: github.com/weftgo/weft/studio, requiring the tagged weft and weft/obsdb (and weft/otel, for its tests) — no replace, standalone-importable. The binary is its own module (cmd/), the one place that imports the clickhouse driver.

Documentation

Overview

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

The three setups (S4.6)

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

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

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

Setup B runs the binary (studio/cmd): 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 = "v0.4.1"

Version is the studio module's tag, reported by api/meta. It moves when the module is released, 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 its own module.

func Handler

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

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

Example

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

package main

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

	"github.com/weftgo/weft/studio"

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

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

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

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

Types

type Option

type Option func(*config)

Option configures New.

func AllowOrigins

func AllowOrigins(origins ...string) Option

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

func Base

func Base(path string) Option

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

func Capabilities

func Capabilities(names ...string) Option

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

func DB

func DB(db obsdb.DB) Option

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

func IngestToken

func IngestToken(tok string) Option

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

func Live

func Live(h obsdb.Hub) Option

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

func Manifest

func Manifest(json []byte) Option

Manifest supplies core.Manifest bytes for the agent and tool cards. Without it api/manifest answers 404 and the UI hides the Agents nav.

func NoIngest

func NoIngest() Option

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

func Open

func Open(path string) Option

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

func Playground

func Playground(on bool) Option

Playground turns the runtime link server and the playground routes on (step 8: studio/playground.go registers its group, which this option enables). Until that file exists the option is accepted and does nothing, and meta.capabilities does not list the playground.

func Title

func Title(s string) Option

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

func Token

func Token(tok string) Option

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

type RuntimeServer

type RuntimeServer = linkrt.RuntimeServer

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

type Server

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

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

Example

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

package main

import (
	"fmt"
	"os"

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

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

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

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

func New

func New(opts ...Option) *Server

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

func (*Server) Close

func (s *Server) Close() error

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

func (*Server) Handler

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

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

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

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

func (*Server) Runtime

func (s *Server) Runtime() *RuntimeServer

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

func (*Server) ServeHTTP

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

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

Directories

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

Jump to

Keyboard shortcuts

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