Preview
May 21, 2026 ยท View on GitHub
Audience: developers and PMs who want to view an agent's running output (web app, dashboard, Storybook) before merge - without provisioning shared staging.
What: bernstein preview boots a sandboxed dev server inside the
originating session's worktree, captures the bound port, opens a public
tunnel through the existing bernstein tunnel wrapper, mints a short-lived
auth credential, and prints a single shareable URL.
Why: Reviewing an agent's diff in your editor tells you nothing about
whether the rendered UI actually works. A preview link lets a non-technical
stakeholder click a URL and see the change live, scoped to one task and
auto-expiring within hours. See
src/bernstein/cli/commands/preview_cmd.py:1-15.
What preview start does, end-to-end
Source: src/bernstein/core/preview/manager.py:1-21. One run-through:
- Discover a runnable command -
src/bernstein/core/preview/command_discovery.py:1-18. Precedence:package.json->scripts.dev, thenscripts.start.Procfile- firstweb:line, otherwise the first process..tool-versions- surfaces runtime hint only.bernstein.yaml::preview.command.
- Provision sandbox: reuse a worktree session under
.sdd/worktrees/(most-recent mtime) or carve a fresh lightweight one viaWorktreeSandboxBackend(cli/commands/preview_cmd.py:269-282). - Spawn the dev server inside the sandbox, stream stdout, and capture
the bound port via regex
(
src/bernstein/core/preview/port_capture.py). - Probe
localhost:<port>over TCP for up to 30 s before opening the tunnel (src/bernstein/core/preview/__init__.py:79-95). - Tunnel through
TunnelBridge- primary provider, thencloudflaredfallback when--provider autoand the primary binary is missing on PATH (src/bernstein/core/preview/tunnel_bridge.py:70-119). - Mint credentials via
PreviewTokenIssuer- short-lived JWT, basic auth, or none. Credentials bake into the printed URL (src/bernstein/core/preview/token_issuer.py:32-77). - Persist state to
.sdd/runtime/preview/state.jsonand append an HMAC-chained audit record. On any failure the manager rolls back: kill the dev-server, destroy the tunnel, drop the state row.
State file shape - PreviewState
(src/bernstein/core/preview/manager.py:131-189):
{
"previews": [
{
"preview_id": "p-abc12345",
"command": "pnpm dev",
"cwd": "/repo/.sdd/worktrees/task-42",
"port": 5173,
"sandbox_backend": "worktree",
"sandbox_session_id": "ws-...",
"tunnel_provider": "cloudflared",
"tunnel_name": "...",
"public_url": "https://abc.trycloudflare.com",
"share_url": "https://abc.trycloudflare.com/?token=eyJ...",
"auth_mode": "token",
"expires_at_epoch": 1714857600,
"process_pid": 12345,
"created_at_epoch": 1714843200
}
]
}
bernstein preview group
Source: src/bernstein/cli/commands/preview_cmd.py:46-232.
preview start
$ bernstein preview start [--cwd PATH] [--command "pnpm dev"]
[--provider auto|cloudflared|ngrok|bore|tailscale]
[--auth basic|token|none]
[--expire 30m|4h|1d]
[--list-commands]
[--no-clipboard]
Defaults: --cwd = most recent worktree under .sdd/worktrees/,
--provider = auto -> cloudflared, --auth = token,
--expire = 4h. The URL is auto-copied to the clipboard (best-effort)
unless --no-clipboard.
--list-commands prints every discovered candidate without starting
anything - useful when discovery picked the wrong script
(cli/commands/preview_cmd.py:240-251).
Output:
Started preview p-abc12345 (cloudflared -> localhost:5173)
URL: https://abc.trycloudflare.com/?token=eyJ...
auth=token sandbox=worktree/ws-... expires_epoch=1714857600
URL copied to clipboard.
preview list
$ bernstein preview list [--json]
Prints a fixed-width table (or JSON array with --json) of every active
preview row from state.json (cli/commands/preview_cmd.py:165-186).
preview status <preview_id>
$ bernstein preview status p-abc12345 [--json]
Prints the full PreviewState payload for one preview
(cli/commands/preview_cmd.py:194-208).
preview stop
$ bernstein preview stop <preview_id>
$ bernstein preview stop --all
Cleanly tears down: kill dev-server PID, destroy tunnel, drop the row from
state.json (cli/commands/preview_cmd.py:216-232).
Tunnel providers
The bridge delegates to TunnelRegistry shared with bernstein tunnel.
Built-in driver kinds are auto-registered via register_default_drivers
(src/bernstein/core/preview/tunnel_bridge.py:20); the wheel ships
cloudflared, ngrok, bore, tailscale driver bindings in
core/tunnels/drivers/. Any provider whose CLI binary is on PATH works.
--provider auto first asks the registry to pick the best available.
If no provider qualifies, the bridge re-tries explicitly with
cloudflared because the ticket pins it as the documented fallback
(src/bernstein/core/preview/tunnel_bridge.py:96-115). When even
cloudflared is missing the bridge raises TunnelBridgeError with the
provider's installation hint embedded.
Security considerations
The URL is the credential. Anyone with the share URL hits the dev server. Three auth modes layer on top:
--auth token(default) - JWT in?token=.... Validity =--expire. Issued viaPreviewTokenIssuerover the security layer'sJWTManager, so revocation works through the existing/auth/{revoke,validate}endpoints (src/bernstein/core/preview/token_issuer.py:1-17).--auth basic- random strong password baked into ahttps://user:pass@hostURL.--auth none- bare tunnel URL. Acceptable only when the dev server itself enforces auth (very rare). Default-off for that reason.
Other guarantees:
- Every state-changing transition (start/stop) appends an HMAC-chained
entry to
.sdd/auditso a misuse trail survives even after the preview row is deleted (src/bernstein/core/preview/manager.py:17-19). - The dev server runs inside the same sandbox primitive as the
originating agent run - Worktree by default, never the bare repo. The
preview process inherits filesystem isolation from
WorktreeSandboxBackend. - Tokens expire by default at 4 h. Increase only when you have a reason; reviewers leaking a 24 h URL on Slack is a real failure mode.
- The
share_urlfield instate.jsonincludes the token. Treat.sdd/runtime/preview/as confidential; the_state_to_payloadhelper redacts nothing (cli/commands/preview_cmd.py:285-306). - Default expire bound: 4 h
(
src/bernstein/core/preview/manager.py:66). Operators set a stricter ceiling via the--expireargument; there is no global cap today.
Configuration
Three knobs live outside the CLI:
| Where | Knob | Effect |
|---|---|---|
bernstein.yaml | preview.command | 4th-precedence dev-server command override |
.sdd/runtime/preview/state.json | persisted on every start | live registry consumed by list/status/stop |
.sdd/audit/ | HMAC chain | audit log for every preview lifecycle transition |
Tunnel state is shared with bernstein tunnel, so the bridge picks up
provider configuration that lives in core/tunnels/registry.py's state
file. There is no preview-specific tunnel config today.
PREVIEW_STATE_DIR and DEFAULT_AUDIT_DIR are constants in
src/bernstein/core/preview/manager.py:62-64; tests can override via
the PreviewStore and AuditLog injection points.
Observability
Three Prometheus counters (registered in
src/bernstein/core/preview/metrics.py):
preview_started_total{provider, auth_mode}- every successful start.preview_stopped_total{reason}- every stop (operator, expiry, crash).preview_link_issued_total{auth_mode}- every credential mint.
Surface via the existing /metrics endpoint. There is no separate
dashboard today; see docs/operations/observability-overview.md for the
broader observability plan.
Code pointers
src/bernstein/cli/commands/preview_cmd.py:46-232- CLI surfacesrc/bernstein/core/preview/__init__.py- public API exportssrc/bernstein/core/preview/manager.py:1-21- lifecycle rationalesrc/bernstein/core/preview/manager.py:62-189-PreviewState+PreviewStoresrc/bernstein/core/preview/manager.py:298-...-PreviewManagerorchestrationsrc/bernstein/core/preview/command_discovery.py- auto-discovery precedencesrc/bernstein/core/preview/port_capture.py- port detection regex + TCP probesrc/bernstein/core/preview/tunnel_bridge.py:38-119- tunnel facade + cloudflared fallbacksrc/bernstein/core/preview/token_issuer.py- JWT/basic/none credentialssrc/bernstein/core/preview/metrics.py- Prometheus counters