Skip to content

Scratch Previews

Status: implemented (/scratch/ in openbus/server.py, opt-in via TMUXRC_SCRATCH_DIR). How to use it: Previewing work .

Why

Agents produce things a person has to look at: a mock, a built site, a report. The person is often on the phone, where “open this file” means nothing. The habit that grew up was a quick static server plus a second tunnel client per preview. Those clients authenticated with the developer’s interactive login, which the organization’s reauthentication policy expires about once a day, so previews died overnight while the main tunnel, running on a service-account credential, stayed up. Nobody was watching an unattended preview to notice.

The daemon already has a front door that is authenticated, supervised and on the phone. Serving previews through it removes a moving part instead of adding one.

Decisions

Static files only. A directory of files covers every case that actually came up: mocks, dist/ folders, reports, images, PDFs. Starlette’s static mount already refuses traversal and serves no listings, so the feature is a few lines on a code path /docs already uses, not new surface.

A gallery at /scratch/, rendered by the daemon. Agents name folders as they like and hand out one link, so after a week nobody could find yesterday’s mock again: the stopgap was an index.html regenerated by hand. Listing folders, not files, keeps the gallery to what someone meant to show, and the folder (not a file) is the unit that has a title and an author. Who made it is the part that turns a list into something navigable, so each folder says it in a meta.json (the pane, which tmux-rc already knows everything else about), and the transcripts are a fallback for folders from before that, or whose pane has closed. That search is a single grep over the transcripts active when the folder appeared, since Python’s regex took seconds per gigabyte. A found author never changes, so its session id is written into the folder’s meta.json, marked generated like a description: the search runs once per folder, not once per daemon restart, which had made the first visit after each restart take seconds. A miss is remembered in memory only and retried after ten minutes, because agent-history indexes a session some time after it starts. The page stays under the scratch sandbox like every other /scratch/ response: one policy for the whole prefix is easier to trust than an exception for one path. That costs it the app’s saved theme (no localStorage in an opaque origin), so it follows the system’s, and it inlines the app’s colour tokens rather than loading the stylesheet with the viewer’s cookie.

Thumbnails and descriptions are sidecar files, made in the background. A gallery of titles alone made people open pages to remember what they were, so each card has a picture and a sentence. Both sit beside the page (thumb.webp, and the description in meta.json) rather than in a database: deleting a folder deletes them, and a copied folder keeps them. A live <iframe> per card was rejected, since it runs every preview’s scripts at once on a phone. The daemon instead shoots each page once with Playwright’s bundled headless Chromium, in a fresh profile that it then deletes. It never uses the user’s own browser, whose profile carries their logins. The page is served to that browser by a throwaway loopback listener that serves scratch and nothing else, under the same sandbox, and every other request goes to a proxy that isn’t there. The shot runs on the daemon’s machine, where a page could otherwise frame, or navigate itself to, the daemon’s API or a loopback or private-network service, and have the answer published as its thumbnail. Everything outside /scratch/ also refuses to be framed by such a page. Pages that pull fonts or images from the internet render without them, which is acceptable for a thumbnail. Shots run one at a time on a worker thread: the first listing after an upgrade backfills every folder without ever waiting on it, and cards show a placeholder until their picture exists. A thumbnail is redone when it is older than the page’s newest file. A missing description is asked of the parse model once, from the page’s title and visible text, and written into meta.json marked generated. It is only ever added where no description exists, so an agent’s own always wins. Sidecars don’t count as the page or its changes, so writing them never reorders the gallery.

Grid and list are one renderer. The list is the same cards laid out as rows, using CSS alone, so the two views can’t drift apart. A phone starts on the list, because full-width thumbnails left room for barely two cards on a screen. The choice can’t be remembered: the gallery is sandboxed like every /scratch/ page, so its localStorage throws. The page tries anyway and falls back to the default for the screen size. Inside the app the app keeps the choice: the gallery posts each pick up, and the app posts it back on every load.

Inside the app, the gallery only asks. The app shows the gallery in a frame, and the frame can’t navigate the app, so each in-app action is a message the app checks. It accepts messages only from the gallery’s frame: the pane to open, the Grid/List pick, and a page to view. The app never hears a page it is showing, because that page is arbitrary HTML. A page to view must resolve to a path under /scratch/ on the app’s own origin. The app refuses an encoded slash, because the daemon would decode it into a real one. A card opens over the whole app in a viewer, a fresh frame under a slim bar. This was chosen over a new tab because an installed phone app has no tab strip to come back from. The viewer’s own frame can’t drive the app, and the daemon’s CSP sandboxes it as it does every scratch page. The bar keeps “Open in browser”, a plain link, for a page that wants a whole window. Other links in the gallery still open a tab, so the gallery’s frame only ever holds the gallery. On a phone the gallery drops the app’s page head and runs under the tab bar, and its own filter row carries Grid/List. A strip down the left edge is kept for the app’s swipe back, because touches inside a frame never reach the app.

Clean-up archives first, and the app does it. Scratch fills with stale folders, and deleting from a gallery of look-alike thumbnails is the kind of mistake that can’t be taken back. So the gallery archives, and only the archive deletes: a slip in the gallery costs an Undo, and deleting needs two separate steps. An archived folder moves to a hidden .archive/ beside the others rather than into a database flag. The gallery already skips hidden folders, and a folder that has moved can’t be listed by mistake. Its link keeps serving, because links to it sit in chats and notes, and a 404 there would make archiving feel like deleting. The gallery can’t make these changes itself, since its sandbox allows no request that writes. It asks the app, as it does for everything else. The app checks every name strictly (a single folder directly under scratch, never a link or a dot name) and refuses a .archive that is a link. It raises the Delete confirm itself, so a page that got into the gallery’s frame could at worst archive something, which can be undone.

The open gallery checks for changes, by image. A gallery left open used to miss a folder made after it loaded. Its sandbox forbids fetch, so it can’t make a conditional request. Instead it loads ?seen=<its listing's tag> as an image every five seconds while on screen. The answer is a pixel only when the listing changed, and the page then reloads. The tag hashes each folder’s name, newest file, whether it is in progress, and its thumbnail, so a reload means there is something new to see. Giving the gallery fetches was rejected. It would need CORS for the opaque origin, which every agent page shares, or the app’s own origin for a page that renders agent-written text. A push channel was rejected too: the daemon only notices scratch changes on a ten-second scan, so the poll costs no latency.

Opt-in, with no default directory. Whatever sits in the directory is published to everyone the front door admits. A default path (say under ~/.local/share) would mean a file saved there for an unrelated reason gets published by an upgrade nobody read the notes for. Making the operator name the directory makes publishing a deliberate act.

Sandboxed by CSP rather than trusted. Previews are arbitrary HTML, often written by an agent, served from the daemon’s own origin, where a script could call the API that types into terminals, carrying the viewer’s session. A sandbox directive without allow-same-origin moves the page into an opaque origin, so that API becomes cross-origin to it, as it is to any website. Cross-origin only protects responses, though: a form post or a no-cors fetch still delivers the request, with the front door’s cookie. So form-action and connect-src are 'none' too. The price is that pages needing a backend or fetching remote data do not work. That is the right trade for a review tool; such pages have a documented escape hatch.

Resource loads (<img>, <script src>) are deliberately left open: a bundle loads its own files that way, the API’s GET routes only read, and a default-src 'self' would hinge on how each browser resolves 'self' for an opaque-origin document, which is exactly where a preview would silently lose its own stylesheet on one phone and not another.

Committed mocks live in the docs site. A mock that justifies a PR belongs with the PR, so docs-site/static/mocks/ is the home for the ones worth keeping, and scratch stays explicitly disposable. Two places by lifetime keeps the scratch dir free to be wiped. /docs/mocks/ gets the same sandbox: promoting an agent-written page into the repo should not be what grants it the daemon’s API.

Agents learn about it from the environment, opt-in. The daemon cannot edit anyone’s agent instructions, and should not. What it can do is put the dir and URL in tmux’s global environment, the one place every new pane, and so every agent, reads from. That is a write to a server the user owns, so it is off unless asked for; the instruction to actually use scratch stays a line the user adds to their own CLAUDE.md or AGENTS.md.

Alternatives rejected

A second tunnel per preview. The status quo. Each one is another credential, process and hostname to keep alive, and the credential is the part that kept failing. It stays documented as the escape hatch for previews that genuinely need their own hostname, with the fix applied: its own client configuration, and a non-interactive credential like the tunnel service’s.

A reverse proxy to local dev servers. It would preview live apps, not just builds, but it would put any local app and its unauthenticated API on the daemon’s origin and behind its login, where the CSP sandbox cannot apply without breaking the app. Dev servers also expect to own the site root and need WebSockets for hot reload, so a path-prefixed proxy half-works. It turns tmux-rc into a general gateway, a much larger thing to secure than a read-only file mount.

A default scratch directory. Rejected above: convenience at the cost of accidental publication.