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). Two things hold a booth open past that: the .forever sentinel, and an unanswered question — see Lifetime below. Opening a booth page is activity; polling it is not.

How a session posts

A booth is just a folder under the data dir. Three ways, cheapest first:

# 1. On nh3-dev — the helper (scripts/booth):
booth add my-run out/a.png out/b.png --why "pick the denoiser, v3 on the left"
booth new  my-run --why "..."                # 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/.

Say what it is — --why

--why is one line telling the operator what he is looking at and why. It lands on the index card and on the booth page next to your handle (taken from $ALTHING_HANDLE), stored as .booth.json in the booth.

It is optional and nothing breaks without it — a booth with no announcement renders as unannounced, which is also what every booth created by rsync or a bare mkdir looks like. But a booth that cannot say what it is has no way to ask for attention except by posting its URL somewhere else, and that is exactly how the link board ended up 69% dead rows. The booth is the place to say it.

booth add r18-ab out/*.png --why "which denoiser — v3 left, v4 right" --title "R18 A/B"

A second new or add on the same booth updates the why and keeps the original creation stamp: the booth appeared once.

Checking that controls can actually be clicked

<a python with playwright> scripts/layout-probe.py [URL ...]

⚠ Markup inspection structurally cannot catch occlusion, and this UI has shipped two dead controls in two days — a reveal button whose handler Jinja discarded, and a × that a sibling release form painted over completely (30x22 px overlap on a 30px button; elementFromPoint at its centre returned the other form). Both were reported by the operator. Both passed every test, because the markup, the routes and the CSS were each individually correct.

The probe walks every button and link, scrolls it into view, and asks the browser what a click at its centre would actually hit. It took four iterations to become trustworthy, and each failure is worth knowing because they are the traps in writing this kind of check at all:

  1. top.contains(el) counted an ancestor overlay as a hit — which is the exact case the probe exists to catch. It reported OK for a real overlay.
  2. elementFromPoint is viewport-relative, so everything below the fold read as occluded. Scroll first.
  3. getBoundingClientRect() on a wrapped inline element is the union of its line boxes, whose centre can sit in the gutter between them, on the parent. Use getClientRects()[0].
  4. Only after all three does the positive control (a real overlay) fire while the negative control (the clean page) stays silent. Both were run. A probe that has never been seen to fail is not evidence of anything.

Blurring an item (cosmetic censoring)

⚠⚠ Blur is NOT access control. A blurred item is still served at its own URL, still included in the zip, still on disk. It hides a thing from a glance — a shoulder, a screen-share, a scroll past something you did not want full-size — and nothing else. The Booth has no auth by design: if a thing must not be SEEN by whoever can reach port 8090, it must not be in a booth.

booth blur   <name> <file>...     # hide from a glance
booth unblur <name> <file>...

Or the ◌ blur / ◉ blurred button on every item in the booth page — in the caption row for images, video, audio and plain files, and in the doc bar beside ⤢ ⬇ ✕ for inline docs.

⚠ The toggle is emitted by ONE Jinja macro (blurtoggle) called from all three item branches. booth.html renders docs, media and everything-else through separate <figure> blocks, and this feature was twice shipped having patched only some of them — first the blur class, then the toggle itself. Add a fourth branch and you must call the macro from it; test_every_item_kind_gets_exactly_one_blur_toggle counts toggles against figures across mixed kinds and will fail if you don't.

  • State is .blurred in the booth dir — one booth-relative item path per line, the same filesystem-is-the-state idiom as .pins and .forever. An empty set deletes the file rather than leaving a zero-byte one, so ls -a tells the truth about whether anything here is blurred.

  • Reveal is per-viewer and never persisted. Click 👁 reveal; a reload re-hides. With JS off it stays blurred, which is the safe direction to fail.

    ⚠ This button shipped INERT on 2026-09-20 and stayed that way for a day. Its handler sat after the content block's closing tag, and a child template's out-of-block content is silently DISCARDED by Jinja — the button rendered, the handler never reached the browser, and two commits plus this README said it worked. The suite passed throughout because nothing asserted against the served page. test_reveal_handler_actually_reaches_the_served_page now greps the HTTP RESPONSE, and test_no_orphaned_markup_after_the_content_block guards the structure. Both were confirmed to FAIL when the defect is reintroduced, which is the only way to know a guard guards anything.

  • Covers inherit it. If a booth's cover image is blurred, the index card's thumb is blurred too — otherwise the front page undoes the censoring.

  • Inline docs are blurred too, not just images and video. That branch puts readable text straight on the page, so it needs this more than a picture does.

Keeping a booth (round trip, both directions)

Three places, all doing the same thing:

  • Index, ephemeral card — ★ promotes to the kept lane.
  • Index, kept card — release demotes, and × now WIPES DIRECTLY. The old rule was release-then-find-it-in-the-other-lane; that protected nothing and cost a hunt, because the board you just released is loose in a feed that turns over. Protection lives in the confirmation now, which names the booth and says KEPT.
  • Inside a booth — ☆ keep / ★ kept — release, beside Wipe now, so you do not have to go back to the index. These post a next field to stay on the page; next is a form field and therefore attacker-controlled, so only same-site absolute paths are honoured (//host, schemes and backslashes are refused).

Equivalent CLI: booth keep <name> / booth unkeep <name>.

⚠ Release BUMPS the directory mtime, so a released board's age resets and it survives another full TTL. Unkeep-and-wait is a 24h delay, not a delete — which is exactly why the direct × was worth adding.

⚠ Until 2026-09-19 the UI only went one way — the kept lane could release, but an ephemeral booth could only be kept from a shell. The /keep route and the CLI verb both already existed; only the button was missing.

Lifetime — derived, not declared

A booth is in exactly one of three states, and only the first is a button you press:

state what puts it there swept?
kept you pressed keep / dropped .forever never
held an unanswered pick, or a .marks.json the service cannot read not while that holds
ephemeral everything else 24h after the last activity

An open question holds its own booth. A session that runs booth ask does not also need to keep the booth — the booth cannot be swept while the operator still owes it an answer, and it is released automatically when he answers. A partially answered multi-question pick still counts as open, so a review in flight is never swept out from under him. The index card and the booth header say held until answered where the countdown would be, so a booth that has stopped counting down always tells you why.

Viewing is activity. A deliberate GET of a booth's own page — the gallery, a verbatim report, the zoom view, the marks page, a zip download — resets the clock. If the operator is still looking at it, it is still alive. Browsing the index does not count, and neither does a session polling marks.json or booth marks --wait: machine reads are deliberately excluded, so an agent cannot hold its own booth open by waiting on it.

A held booth is still yours to delete. The hold is protection from the timer, never from you: booth rm, the UI ×, and DELETE /b/<name> all work exactly as before. sweep_once is the only thing that honours a hold, exactly as it is the only thing that honours .forever.

Why this exists: .forever used to be the only way to say three different things — "this is durable", "I haven't answered yet", and "I'm still looking at it" — and the measurement showed it carrying all three. On 2026-09-22, 17 of 24 live booths (70%) held the sentinel, up from 54% the day before; three of the four booths in the fleet awaiting an answer had been pinned by hand as well. Only the first meaning is what keep means. The other two the service already knew and did not consult.

Kept boards — the explicit pin

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.

booth keep   my-board      # drop the sentinel — exempt from the sweep, forever
                           # (NOT for "waiting on an answer" — the pick holds it)
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:

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.

booth link <url> [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.

Marks — operator judgment, attached to an artifact

The one interactive primitive, and one primitive for what used to be three jobs:

shape who writes it what it is
pick a session declares the options, the operator chooses "which render wins?" — this is what booth ask poses
note the operator, in the browser free text for the session that posted the work
flag the operator, in the browser this one — selecting winners out of a set

All three are the operator judging something and the session reading the judgment back. They live in one file per booth, so "does this booth still owe me an answer?" is a single read:

<booth>/.marks.json    every mark in the booth   (dotfile: never a tile, never in the zip)
<booth>/.marks.lock    the write lock            (a session declares, the operator answers)

There are no note or flag CLI verbs, on purpose: the CLI is the session's side of the loop, and a session does not author the operator's judgment. It reads it.

# 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 marks  r18-ab                          # every mark in the booth, as JSON
booth marks  r18-ab --wait                   # block while any pick is still open

# Options can carry an id + detail line instead of a bare label. Write the whole
# declaration yourself and it is validated by the same normaliser the page uses:
booth ask r18-ab plan "Ship which?" "Plan A" "Plan B"    # or, for detail lines:
python3 -c '
import pathlib, sys; sys.path.insert(0, "/home/lkraven/development/booth")
from booth.marks import declare_pick
declare_pick(pathlib.Path("/home/lkraven/booth-data/r18-ab"), "plan", {
  "title": "optional short label above the question",
  "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"})'

# From another host: rsync your work in, then read the judgment over HTTP.
# ONE request for the whole booth, rather than one per question.
curl -sf http://10.100.10.50:8090/b/r18-ab/marks.json | jq '.open, .marks[].answer'

Several questions, one form. Give the declaration a questions list instead of prompt+options; the page renders one form with a radio group per question and a single submit. Per-question notes: true adds a small text field under that question; the form-level notes stays one field for the whole pick. The answer is keyed by question:

declare_pick(booth, "batch", {
  "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"})
# -> answer: {"stem", "title", "answers": {"r1": {"prompt", "choice",
#    "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}},
#    "unanswered", "complete", "notes", "answered_at", "answered_by"}

A pick can be about ONE item, not just the booth. Pass target — an item's booth-relative path — and the question renders beside that artifact:

declare_pick(booth, "which-crop", {"prompt": "Which crop?", "options": ["tight", "wide"]},
             target="v3/DSC03389.jpg")

A partial answer is recorded, not refused. A question left blank is a deliberate outcome — "none of these", "not yet", "ask me later" — so it lands in unanswered, stays absent from answers unless it carried a note, and complete stays false. A partially-answered pick still counts as open, which is what the index badge reports. The one refusal is a submission carrying nothing at all: no choice anywhere and no notes.

Migrating a booth that predates marks. The old two-sidecars-per-question files (<stem>.ask.json / <stem>.answer.json) are imported, never deleted:

booth marks-import r18-ab      # idempotent; the sidecars stay on disk

If the stem is already a mark the declaration is skipped, but a legacy answer still gets adopted, so the operator's recorded choice is never stranded on disk.

If a booth's .marks.json is damaged, reads degrade to "no marks" so the page still loads, and every WRITE refuses with a 409 rather than replacing the file — which would otherwise wipe every judgment in that booth. Repair or move the file by hand; nothing deletes it for you.

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):
    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/<id>/ (download links), or on nh3-dev straight off disk at ~/booth-data/<id>/.

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) → <img> (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) → <video controls>
    • audio (mp3 wav ogg flac m4a opus aac) → <audio controls>
    • anything else → a download link
  • Captions: a <file>.txt or same-stem <stem>.txt sidecar is folded in as that item's caption — the natural way to label an A/B pair:
    a.png              b.png
    a.txt  "baseline"  b.png.txt  "cudaMallocAsync (winner)"
    

Routes

Route Purpose
GET / Index — one card per booth (newest first), with expiry countdown
GET /b/<name>/ A booth (its index.html, else auto-gallery)
GET /b/<name>/<file> Serve a file out of the booth
POST /upload Upload files → new pickup booth; 303-redirects to /b/<id>/ (id in Location)
GET /b/<name>/marks The marks panel on its own page — the only place a verbatim-index.html booth can show its marks (/asks 308s here)
GET /b/<name>/marks.json Every mark as JSON, plus open — the read path for a session that is not on this host
POST /b/<name>/answer Answer a pick (fields ask = mark id, choice/choice.<key>, notes/notes.<key>, back); 303 back
POST /b/<name>/note Attach free text to an item (target) or to the booth (target empty); text required
POST /b/<name>/flag Flag or unflag one item (target, on) — an upsert; unflagging removes the mark
POST /b/<name>/unmark Withdraw one mark (mark = its id)
POST /b/<name>/import-asks Import this booth's legacy *.ask.json sidecars; idempotent, deletes nothing
POST /b/<name>/delete Wipe a booth (the UI's "Wipe now" button)
POST /b/<name>/keep Pin a booth — exempt from the sweep
POST /b/<name>/unkeep Release the pin (the UI's "release" button on kept cards)
POST /b/<name>/unlink Remove ONE row from a link board (form field entry = content id)
POST /b/<name>/unlink-many Remove SEVERAL rows — the multi-select delete (repeated form field sel = content ids)
POST /b/<name>/pin Toggle a row's pinned/favorite state (form field entry = content id)
DELETE /b/<name> Wipe a booth (curl/API)
GET /healthz {ok, ttl_hours, booths} — Homepage siteMonitor target

A booth containing links.md is the fleet's standing link board: every agent session appends operator-facing URLs to it so they outlive the terminal scrollback that would bury them. It is the one booth where the useful granularity is the row, not the folder — a dead link has to be removable without taking the other thirty with it.

It renders as real UI, not a markdown blob: each row shows the description, URL and provenance (who posted it, when), with a copy button and a per-row ×.

Order: pinned first, then newest on top. The board is an append log, so the most recently posted link leads — the one you almost certainly came to grab. Rows you want to keep in view regardless of churn get the ★ (pin/favorite), which floats them to a group at the very top; click it again to unpin. The header shows N pinned when any are.

Multi-select delete. Tick the checkbox on any set of rows and hit 🗑 delete to remove them all in one go (with a count confirmation). The select-all box in the header toggles the lot. The per-row × is still there for a single quick removal. Everything — checkboxes, ×, ★, bulk delete — works with JavaScript off (plain form POSTs via formaction); JS only adds select-all and the live count.

booth links                # row number, entry id, raw row
booth unlink 3             # by row number
booth unlink 8b40e0a5      # by entry id — what the × posts

Rows are addressed by CONTENT ID, never by position. The board is append-only and multi-writer: another session can post between the moment you list it and the moment you remove a row, so an index would delete a neighbour. An id either matches the row you saw or matches nothing. A row number typed at the CLI is resolved to its id before anything is deleted. The multi-select delete (/unlink-many) carries the same guarantee per selected id.

An id is exactly 8 hex characters, which is how the CLI tells ids from row numbers — roughly one id in forty is all digits, so "is it numeric" is not a safe test.

Pin state lives in a .pins sidecar (one content id per line), never inline in links.md. That keeps links.md a pure append log — booth link stays a single atomic write, which is what lets many sessions post concurrently — and means pinning a row never changes its content id. A pin whose row is later removed is dropped automatically; a pin orphaned by a hand-edit is inert (the renderer only stars a row a live id still matches). Pins are a UI action; there is no booth pin CLI yet.

Appends (booth link) and prunes (booth unlink, unlink-many, the ×) take the same flock on .links.lock, and pin toggles take it too, so a post cannot be lost inside a prune's or a toggle's read-modify-write window.

Deleting a kept board

Kept boards have no × in the UI on purpose — a one-click wipe next to the durable stuff is a footgun. But deliberate must not mean impossible, which is what it meant until 2026-08-23: the only routes out were ssh or a hand-written API call.

Now it is two deliberate steps. Release on the kept card drops the sentinel and the board moves to the ephemeral lane, where the × already lives; wipe it from there. Release is reversible — press keep again and nothing was lost. From the CLI, booth rm <name> deletes a kept board immediately and tells you it was kept.

Do not "unkeep and let it expire." Releasing a board is activity — you just touched it — so a released board's clock resets and it survives another full TTL. Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or booth rm when you mean now.

Ops

Runs as a user-level systemd service on nh3-dev (no root, no Docker), alongside the other fleet sidecars (herald, zellij-web, ttyd).

systemctl --user status booth.service
systemctl --user restart booth.service
journalctl --user -u booth.service -f          # sweeper logs "[booth] swept …"

Config is env in the unit (booth.service): BOOTH_DATA_DIR, BOOTH_TTL_HOURS, BOOTH_HOST_LABEL, BOOTH_SWEEP_INTERVAL_MIN, BOOTH_MAX_UPLOAD_MB (default 1024), BOOTH_MAX_FILES (default 50).

Install / update

cd services/booth
uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 python-multipart   # runtime deps
ln -sfn "$PWD/scripts/booth" ~/.local/bin/booth     # the `booth` CLI on PATH (nh3-dev has this)
cp booth.service ~/.config/systemd/user/booth.service
systemctl --user daemon-reload && systemctl --user enable --now booth.service

Code runs straight from this checkout (the unit's WorkingDirectory / ExecStart point here), so "deploy an update" = edit + systemctl --user restart booth.service.

Tests

cd services/booth && uv pip install pytest httpx && .venv/bin/python -m pytest -q

Notes / non-goals

  • No auth. LAN/WG-internal only, ephemeral content — don't drop secrets in a booth, and note anyone on the LAN can upload (bounded by the size/file limits). Uploaded files are served back with their own content-type, so an uploaded index.html renders as a page (a feature for custom reports; keep it in mind).
  • Booth names with /, .., or a leading . are rejected; file serving and uploaded filenames are guarded against path traversal and symlink escape.
S
Description
The Booth — the fleet operator-review surface. Agents post work; the operator looks, annotates, and decides.
Readme
1.2 MiB
Languages
Python 76.1%
HTML 17.3%
Shell 3.4%
JavaScript 1.8%
CSS 1.4%