The standing link board is the one MULTI-WRITER booth -- every agent session appends operator-facing URLs to it. "Delete the folder" was the only granularity available, so removing one dead link meant hand-editing markdown. It is 32 rows and only grows. booth links row number, entry id, raw row booth unlink 3 by row number booth unlink 8b40e0a5 by entry id (what the UI's x posts) POST /b/<name>/unlink form field `entry` = content id ROWS ARE ADDRESSED BY CONTENT ID, NEVER BY POSITION. The board is append-only and multi-writer: another session can post between listing it and clicking x, and an index would then 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. Appends and prunes now take the same flock on .links.lock, so a post cannot be lost inside a prune's read-modify-write. UI: a booth carrying links.md renders as rows -- description, URL, provenance, copy button, per-row x -- instead of a markdown blob. links.md is filtered out of the gallery so it does not appear twice; the header counts LINKS not files; the empty-state and the one-click "Wipe now" both stand down for a board (same rule as the kept lane: nothing durable is one click from gone). booth/links.py extracted, STDLIB ONLY. The CLI needs this logic and must not require the service venv -- importing app.py drags in FastAPI, so deleting a line from a text file would have needed a web framework installed. THREE BUGS FOUND BY TESTING, all in the shell wrapper while the module was correct throughout -- module-only tests would have caught none of them: - `[ "$n" -eq 0 ] && echo ...` as the LAST statement made `booth links` exit 1 whenever the board had rows. `unlink`'s index lookup calls it inside $( ) under `set -e`, so a successful listing killed the caller and the removal silently did nothing while reporting success. - ids are 8 hex chars and roughly one in forty is ALL DIGITS; those were read as row numbers, resolved to nothing, and removed nothing. Now disambiguated by the id's actual shape, not by "is it numeric". - filtering links.md out of the gallery left `items` empty, so a full board rendered "This booth is empty" and an empty <div class="gallery"> under 32 visible rows. 87 tests (was 76): parser tolerance of hand-written prose, content-id stability across concurrent appends, removal precision, UI branch behaviour for board/normal/empty booths, and subprocess CLI tests pinning the two shell bugs. Deployed to nh3-dev and verified against the live 32-row board read-only; board file byte-identical afterwards.
10 KiB
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 like4-wombatorstar-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:
# 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.
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:
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
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.
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
- images (
- Captions: a
<file>.txtor same-stem<stem>.txtsidecar 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) |
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) |
DELETE /b/<name> |
Wipe a booth (curl/API) |
GET /healthz |
{ok, ttl_hours, booths} — Homepage siteMonitor target |
The standing link board
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 ×.
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.
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.
Appends (booth link) and prunes (booth unlink, the ×) take the same
flock on .links.lock, so a post cannot be lost inside a prune'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." Removing the sentinel bumps the booth
directory's mtime, and a booth's age is the newest mtime in its tree — 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
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.htmlrenders 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.