diff --git a/playground/README.md b/playground/README.md
index 78512f4..b45652e 100644
--- a/playground/README.md
+++ b/playground/README.md
@@ -44,3 +44,24 @@ go run artifact_probe.go --root /home/enmanuel/fn_registry --prompt "tu prompt a
`--root` debe ser un repo cuyos MCP de claude ya estén aprobados, para que la TUI
no muestre el diálogo de arranque.
+
+## Chat en el navegador (`web/`)
+
+Un chat web que prueba todo el stack end to end: backend Go con SSE que lanza
+`claude_pipe --stream` por cada mensaje y reenvía los `text_delta` al navegador,
+frontend chat vanilla (sin frameworks, sin node_modules).
+
+```bash
+cd apps/claude_pipe
+CGO_ENABLED=1 go build -tags fts5 -o claude_pipe . # binario con el fix del spinner
+
+cd playground
+go run ./web # http://localhost:8099
+# o con flags:
+go run ./web --port 8099 --root /home/enmanuel/fn_registry --warmup 4s --idle 4s --max 120s
+```
+
+Abre `http://localhost:8099` y escribe. Cada mensaje es una sesión `claude` nueva
+(sin memoria entre turnos: `claude_pipe` es one-shot). Hay ~8s de `warmup`+`idle`
+antes de la primera respuesta. La respuesta se reconstruye desde la TUI parseada,
+ya sin el spinner de carga.
diff --git a/playground/registry.db b/playground/registry.db
new file mode 100644
index 0000000..e69de29
diff --git a/playground/web/index.html b/playground/web/index.html
new file mode 100644
index 0000000..e61ad55
--- /dev/null
+++ b/playground/web/index.html
@@ -0,0 +1,173 @@
+
+
+
+
+
+claude_pipe · chat (TUI parseada)
+
+
+
+
+
+ claude_pipe · chat
+ respuesta parseada de la TUI de claude, vía SSE
+
+
+
+
+
sistema
Escribe un mensaje. Cada turno lanza una sesión nueva de claude (sin memoria entre mensajes) y muestra su respuesta en streaming, parseada desde la TUI.
+
+
+
+
+ Cada mensaje = una captura PTY → vt_render → parse_claude_tui. Sin memoria entre turnos. Hay ~8s de warmup+idle antes de la primera respuesta.
+
+
+
+
diff --git a/playground/web/server.go b/playground/web/server.go
new file mode 100644
index 0000000..a6f8705
--- /dev/null
+++ b/playground/web/server.go
@@ -0,0 +1,140 @@
+// Command server is a browser chat playground for claude_pipe. It serves a small
+// single-page chat UI and, for each message, launches `claude_pipe --stream` as a
+// subprocess and relays its NDJSON events (text_delta + result) to the browser as
+// Server-Sent Events. The browser renders the answer token-chunk by token-chunk.
+//
+// This proves the whole stack end to end through a real surface: the PTY capture,
+// the VT render, the TUI parser (with the spinner fix), and the streaming delta —
+// all driving a live chat in the browser.
+//
+// Each message is an independent one-shot claude session: claude_pipe does not keep
+// conversation context between turns, so the chat has no memory across messages.
+//
+// Run:
+//
+// cd apps/claude_pipe && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe .
+// cd playground && go run ./web # then open http://localhost:8099
+package main
+
+import (
+ "bufio"
+ _ "embed"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+)
+
+//go:embed index.html
+var indexHTML []byte
+
+var (
+ binPath string
+ root string
+ warmup string
+ idle string
+ maxDur string
+)
+
+func main() {
+ port := flag.String("port", "8099", "port to listen on")
+ bin := flag.String("bin", "/home/enmanuel/fn_registry/apps/claude_pipe/claude_pipe", "path to the claude_pipe binary")
+ rootDir := flag.String("root", "/home/enmanuel/fn_registry", "cwd for claude (a repo whose MCP servers are approved)")
+ wu := flag.String("warmup", "4s", "claude_pipe --warmup")
+ id := flag.String("idle", "4s", "claude_pipe --idle")
+ md := flag.String("max", "120s", "claude_pipe --max")
+ flag.Parse()
+
+ abs, err := filepath.Abs(*bin)
+ if err != nil {
+ log.Fatalf("resolve --bin: %v", err)
+ }
+ if _, err := os.Stat(abs); err != nil {
+ log.Fatalf("claude_pipe binary not found at %s — build it first:\n (cd .. && CGO_ENABLED=1 go build -tags fts5 -o claude_pipe .)", abs)
+ }
+ binPath, root, warmup, idle, maxDur = abs, *rootDir, *wu, *id, *md
+
+ http.HandleFunc("/", handleIndex)
+ http.HandleFunc("/chat", handleChat)
+
+ addr := ":" + *port
+ log.Printf("claude_pipe web chat → http://localhost:%s (bin=%s root=%s)", *port, binPath, root)
+ log.Fatal(http.ListenAndServe(addr, nil))
+}
+
+func handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(indexHTML)
+}
+
+// handleChat runs claude_pipe --stream for the given prompt and relays each NDJSON
+// line as a Server-Sent Event. The browser opens this via EventSource.
+func handleChat(w http.ResponseWriter, r *http.Request) {
+ prompt := r.URL.Query().Get("prompt")
+ if prompt == "" {
+ http.Error(w, "missing prompt", http.StatusBadRequest)
+ return
+ }
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ // The request context is cancelled when the browser closes the EventSource;
+ // CommandContext then kills the claude_pipe subprocess (and its PTY child).
+ cmd := exec.CommandContext(r.Context(), binPath,
+ "--stream",
+ "--cwd", root,
+ "--warmup", warmup,
+ "--idle", idle,
+ "--max", maxDur,
+ prompt,
+ )
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ sse(w, flusher, "error", fmt.Sprintf(`{"message":%q}`, err.Error()))
+ return
+ }
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Start(); err != nil {
+ sse(w, flusher, "error", fmt.Sprintf(`{"message":%q}`, err.Error()))
+ return
+ }
+
+ sc := bufio.NewScanner(stdout)
+ sc.Buffer(make([]byte, 1024*1024), 1024*1024)
+ for sc.Scan() {
+ // Each line is already a JSON object ({"type":"text_delta",...} or result).
+ // Relay it verbatim as the SSE data payload.
+ sse(w, flusher, "", sc.Text())
+ }
+ _ = cmd.Wait()
+
+ // Signal completion so the browser can close the stream.
+ sse(w, flusher, "done", "{}")
+}
+
+// sse writes one Server-Sent Event. If event is empty, a default "message" event
+// is emitted (what EventSource.onmessage receives).
+func sse(w http.ResponseWriter, f http.Flusher, event, data string) {
+ if event != "" {
+ fmt.Fprintf(w, "event: %s\n", event)
+ }
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ f.Flush()
+}