Live preview
Live preview lets you look at the web app you’re building — the one running on
localhost on your laptop — on your phone, without deploying it, without a
tunnel provider, and without opening a port to the internet.
The page’s bytes travel the same end-to-end encrypted channel your messages do. The relay forwards ciphertext and nothing else: it never learns a port, a path, a header, or a byte of the page. See Security for the model that guarantee rests on.
Use it
Section titled “Use it”On the machine running the dev server, in your project directory:
chime preview --port 5173That mints a grant and sends a preview card to the thread on your
phone — the same thread chime notify would use. Tap the card and the app
opens the preview: the entry document and its sub-resources are fetched through
the tunnel and rendered on the phone.
To stop it, either end works:
- On the phone — Settings → Live previews lists what’s currently live, with a one-tap Stop all previews.
- On the machine —
chime preview --revoke <id>, orchime preview --revoke all.
Grants also expire on their own (8 hours by default) and die with the daemon, so a forgotten preview closes itself.
chime preview --port 5173 # grant :5173 for 8h, send the cardchime preview --port 5173 --ttl 90m # shorter-lived grantchime preview --port 5173 --port 8787 # up to 8 ports in one grantchime preview --port 5173 --alias my-app # send the card on a specific threadchime preview --port 5173 --no-card # mint the grant, send no cardchime preview --list # what is reachable right nowchime preview --revoke all # the panic buttonThe full flag reference is in CLI.
What works, and what doesn’t
Section titled “What works, and what doesn’t”The supported shape is a built or static app served on the granted port:
vite build && vite preview # ✅npx serve dist # ✅python3 -m http.server 5173 # ✅# any classic multi-page site, any Go/Python/Rust static server…plus any JSON API on that port (or another port in the same grant) that
the page reaches with fetch or XMLHttpRequest. Those calls are brokered
back through the tunnel for you.
Everything the page loads has to come from the granted port. The app
fetches the entry document’s declared sub-resources — <script src>,
stylesheets, images, fonts — through the tunnel, but only the ones on the same
localhost:<port> the preview opened. Anything off-origin is left in the
document as it was written and then blocked by the preview frame’s
content-security policy, which is what stops the phone becoming a proxy for the
open internet. So a static build that pulls a font from Google Fonts, a script
from a CDN, or an analytics pixel renders without them — the page loads,
those pieces are missing. Nothing is silent about it: each one is reported as a
diagnostic and the preview’s status line counts them (live · 3 blocked). The
fix is to vendor those files into your build so they’re served from the same
port. Note the same rule catches a second granted port used for a declared
sub-resource — grant-wide access applies to runtime fetch/XHR, not to tags
in the entry document.
ES-module graphs do not work yet. A <script type="module"> entry loads,
but relative import specifiers inside it can’t resolve from the opaque origin
the page runs in. In practice that means:
- ❌
vite dev/npm run dev— the dev server serves an unbundled module graph, so the page comes up blank. - ❌ Code-split production builds — a build that emits dynamic
import()chunks hits the same wall. - ✅ The workaround is a real build:
vite build && vite preview, and configure a single bundle if your build code-splits by default.
There’s no live reload. WebSocket and SSE proxying aren’t implemented, so HMR doesn’t reach the phone. Rebuild, then reload the preview by hand.
examples/preview-sample/ in the repo is exactly the supported shape — a
static app plus a JSON API — and is worth running first if a preview of your
own app misbehaves.
The security model
Section titled “The security model”Live preview is a hole you punch in a default-deny fence, and it’s shaped so that a lost phone can’t widen it.
- Loopback only. The daemon will only ever dial
127.0.0.1/::1. The hostname is resolved once, every answer must be a loopback address, and the resolved literal is what gets dialled. There is no proxy, no TLS dialer, and redirects are never followed. - Grants are minted on the machine, never from the phone. A grant is
{project, ports, expiry}. The phone can use a grant and revoke one; it can’t create one, add a port to one, or extend one. A request for a port the grant doesn’t name is refused where the request is issued, not just where the stream opens. - Grants expire. 8 hours by default, 24 hours maximum, at most 8 ports each — and they die with the daemon, so nothing survives a reboot implicitly.
- Privileged ports are refused. Anything below 1024, with no override flag.
- Nothing listening is a hard failure.
chime preview --port 5173with no server on:5173mints no grant and says so, instead of handing you a grant that fails as an opaque error on the phone. - The page has no network of its own. It renders in a fresh
sandbox="allow-scripts"iframe with its own content-security policy; everyfetch/XHRit makes is brokered back to the host app, checked against the grant, and only then issued. Anything the broker can’t account for fails closed. - The relay still sees only ciphertext. Preview traffic rides a non-durable envelope class: it’s delivered to a live socket or dropped, never queued, and never readable by the relay.
Requirements
Section titled “Requirements”- A WebSocket relay. The managed relay at
api.chime.shqualifies. A self-hosted Go relay does not yet — it’s long-poll only, and the app will tell you “Live preview needs a WebSocket relay” rather than fail obscurely. See Self-host. - Android or the desktop CLI. iOS is not supported — no tunnel-enabled iOS build ships, and the feature stays off there.
- A release build of
chime, v0.9.0 or newer. Every v0.9.0+ release asset — the fourchime-<os>-<arch>binaries and the Android APK — ships with live preview compiled in, so the install script and the release APK just work. v0.8.0 and earlier shipped it compiled out of every binary and APK, and v0.7.0 predates the feature entirely. Check withchime version: a tunnel-enabled binary prints<version>+tunnel. A plain from-source build (go build ./cmd/chime) is a stock build with the feature compiled out entirely;chime previewthere exists only to tell you so.