# The Booth A dead-simple standing web server for shuttling **ephemeral files** between the operator and CC sessions — A/B renders, smoke-test screenshots, audio/video samples, or anything you want to hand off. It works both directions: - **Session → operator:** a session drops a folder of files on disk; the Booth renders it as a browsable "booth". - **Operator/anyone → pickup:** upload files through the browser (or `curl -F`) and get a **human-readable pickup id** like `4-wombat` or `star-84`. Either way it **wipes 24h after the last activity**. No database — the filesystem *is* the state. - **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth* - **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth) - **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while you're touching it, self-destructs 24h after you stop) ## How a session posts A booth is **just a folder** under the data dir. Three ways, cheapest first: ```bash # 1. On nh3-dev — the helper (services/booth/scripts/booth): booth add my-run out/a.png out/b.png # creates booth + copies, prints URL booth new my-run # empty booth, then cp/mv into ~/booth-data/my-run/ booth url my-run # just print the URL booth ls # list booths booth rm my-run # wipe now (TTL would anyway) # 2. On nh3-dev — raw, no helper: mkdir -p ~/booth-data/my-run && cp out/*.png ~/booth-data/my-run/ # -> http://10.100.10.50:8090/b/my-run/ # 3. From another host — rsync into the data dir: rsync -a ./out/ nh3-dev:booth-data/my-run/ ``` Then hand the operator `http://10.100.10.50:8090/b/my-run/`. ## Kept boards — the one exception to the 24h rule A booth containing a **`.forever`** dotfile is **never swept**, and renders in its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no countdown, no one-click wipe). Everything else is unchanged: the default is still ephemeral, so nobody inherits a cleanup chore they didn't ask for. ```bash booth keep my-board # drop the sentinel — exempt from the sweep, forever booth unkeep my-board # release the pin — the board rejoins the sweep booth rm my-board # delete it NOW (works on kept boards; says so when it was kept) booth links # list the standing link board: row number, entry id, the row booth unlink 3 # remove row 3 booth unlink 8b40e0a5 # or remove by entry id (what the web UI's × posts) ``` It is just a file, so the manual forms work identically and are the honest mental model: ```bash touch ~/booth-data/my-board/.forever # keep rm ~/booth-data/my-board/.forever # unkeep rm -rf ~/booth-data/my-board # delete outright, whenever you like ``` **Why this exists:** agent sessions hand the operator URLs — a booth of renders, a PR, a dashboard — and they drown in terminal scrollback. Kept boards are where those go instead. ### The standing link board ```bash booth link [description] ``` Appends one line to the **`links`** board (`$BOOTH_LINKS_BOARD`, default `links`), creating it and marking it kept on first use. Each entry carries provenance — who posted it and when — because a bare URL is unreadable three days later. `links.md` renders as a readable page in the booth. The append is a single `printf` of a single line to an `O_APPEND` fd, which is atomic under `PIPE_BUF` on POSIX. That matters here specifically: many agents post to one board, and interleaved half-lines would be the obvious failure. Deliberately **not** a database. The board is a markdown file — editable with any editor, greppable, and trivially prunable by hand, which is the whole point of the Booth's filesystem-is-the-state model. ## Asks — let the operator pick one of N, and read the pick back The one **interactive** primitive. A session needs a human decision — which render wins, which plan, go/no-go — and wants to act on it without a chat round-trip. Drop a question in a booth; the page renders it as a radio form with a notes field; the operator's submit writes an **answer sidecar** the session reads. Filesystem is still the state: ``` /.ask.json the question (a session writes it) /.answer.json the answer (the web UI writes it, atomically) ``` ```bash # On nh3-dev — pose, then block until answered (default 1h), then act on it: booth ask r18-ab winner "Which render wins?" "A — baseline" "B — cudaMallocAsync" booth answer r18-ab winner --wait # prints the answer JSON when it lands booth answer r18-ab winner # non-blocking: exit 1 while unanswered booth asks r18-ab # list a booth's asks + state # Options can carry an id + detail line instead of a bare label — write the # JSON yourself (booth.asks.write_ask validates the same way): cat > ~/booth-data/r18-ab/plan.ask.json <<'EOF' {"prompt": "Ship which?", "options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"}, {"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}], "notes": true, "notes_label": "why / conditions"} EOF # From another host: rsync the ask in, then poll the sidecar over HTTP: curl -sf http://10.100.10.50:8090/b/r18-ab/winner.answer.json # 404 until answered ``` **Several questions, one form.** Give the ask a `questions` list instead of `prompt`+`options`; the page renders one form with a radio group per question and a single submit, every question required. Per-question `notes: true` adds a small text field under that question; the form-level `notes` stays one field for the whole ask. The answer is keyed by question: ```bash cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF' {"title": "R18 batch review", "questions": [ {"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": true}, {"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]}, {"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}], "notes": true, "notes_label": "anything else"} EOF # -> batch.answer.json: {"stem", "title", "answers": {"r1": {"prompt", "choice", # "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"} ``` The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes", "answered_at", "answered_by"}` — `choice` is the option id (the label itself for string options), `choice_index` its 0-based position, `answered_by` the client address. `POST /b//answer` is what the form submits — fields `ask` plus `choice` / `notes` (single) or `choice.` / `notes.` / `notes` (multi); a missing or bad choice is a 400, an unknown stem a 404. Rules of the primitive: - **Radio, one pick per question.** ≥ 2 options, ≤ 40 per question, ≤ 30 questions per ask. No multi-select checkboxes (not yet asked for). Many asks per booth are fine — each is its own form and its own sidecar; use `questions` when the picks belong together and should land as one answer. - **Re-answering overwrites.** The sidecar is the *current* answer, not a log. The page shows the recorded answer with a collapsed *change answer* form. - **Open asks are flagged** — an amber `? N asks` badge on the index card and in the booth header — so a waiting question is visible from the front page. - **A broken ask is shown as broken**, not hidden: if the JSON does not validate, the page says why, so a session never thinks it posted a question the operator cannot see. - Ask/answer files are not gallery items and do not count toward the booth's item count; they render as the panel above the gallery. Answering bumps the booth's mtime, so it lives another TTL — the session has 24h to read it. - Works with JavaScript off (plain form POST). No auth, same as everything here. ## Upload for pickup The reverse direction — put files in through the web, pick them up by id: - **Browser:** the index page has an *Upload files for pickup* panel (drag-drop or click). Submit → you land on a booth with a **human-readable id** (`4-wombat`, `star-84`) whose files each have a ⬇ download link. - **curl (a remote session with no ssh to nh3-dev can use this too):** ```bash curl -sS -i -F 'files=@out/a.png' -F 'files=@out/b.png' \ http://10.100.10.50:8090/upload | grep -i location # Location: /b/star-84/ <- the pickup id ``` - **Pick up** at `http://10.100.10.50:8090/b//` (download links), or on nh3-dev straight off disk at `~/booth-data//`. Uploads are stamped as pickup booths (a `⬆ pickup` badge in the UI) and expire on the same 24h TTL. Limits: `BOOTH_MAX_FILES` files (default 50) and `BOOTH_MAX_UPLOAD_MB` total per submission (default 1024); filenames are reduced to a safe basename (no path traversal). ## What a booth renders - **Has its own `index.html`?** → served **verbatim** (its relative assets — `chart.png`, `report.css` — resolve out of the same folder). Build whatever page you want. - **No `index.html`?** → **auto-gallery** of the folder's media: - images (`png jpg jpeg gif webp avif svg bmp`) → `` (click → full-screen viewer with **Fit** / **1:1** — the toggle only appears when the image is larger than the viewport — plus download and ✕/Esc back to the gallery) - video (`webm mp4 ogv m4v mov`) → `