A private, web-based control surface for one or more zcoder.zsh protocol-1 servers. The interface follows the original TUI: flat panels, compact server state, remote sessions, plain-text transcripts, model readiness, command approvals, and cancellation.
Note: This web interface requires
zcoder.zshto be running. zcoder.zsh provides the backend protocol-1 servers that zweb connects to — the two projects are interlinked and zweb cannot function without it.
The browser never connects to zcoder directly. It talks only to this Next.js application; zweb reads upstream URLs and bearer tokens on the server from .env.local.
Requirements: Node.js 20.9 or newer and a reachable zcoder server (preferably through an SSH tunnel or private VPN).
pnpm install
cp .env.example .env.local
pnpm auth:hashPaste the complete APP_PASSWORD_HASH=... output into .env.local. Generate the session secret:
openssl rand -base64 48Put that value in AUTH_SECRET, set APP_ORIGIN, and configure the upstreams as a JSON array:
APP_USERNAME=operator
APP_PASSWORD_HASH=\$argon2id\$v=19\$m=65536,t=3,p=1\$...
AUTH_SECRET=a-long-random-value-from-openssl
APP_ORIGIN=https://zcoder.example.net
TRUST_PROXY_HEADERS=false
ZCODER_SERVERS_JSON='[
{"id":"workshop","name":"Workshop Mac","url":"http://127.0.0.1:7337","token":"the-zcoder-token"},
{"id":"lab","name":"Lab server","url":"http://127.0.0.1:7338","token":"the-other-zcoder-token"}
]'Server IDs may contain lowercase letters, numbers, _, and -. Tokens must follow zcoder’s URL-safe 32-character minimum. Restart zweb after changing .env.local.
Run locally:
pnpm devFor production:
pnpm build
pnpm start -H 127.0.0.1 -p 3000Terminate TLS in a trusted reverse proxy in front of 127.0.0.1:3000. Production cookies are Secure, so the public app must use HTTPS.
zweb is a responsive Progressive Web App. On a phone, open the HTTPS production URL and use your browser's Add to Home Screen or Install app action. It opens as a standalone app with mobile safe-area support, touch-sized controls, a conversation-first layout, and a full-screen session picker. Open Sessions to switch servers, resume conversations, inspect server details, or log out. Documents expand on demand; the composer stays at the bottom and the keyboard opens when you tap it. On touch devices, Enter adds a newline and the send button submits your message.
Backgrounding the app stops its polling. The event cursor is durable on the server, so a run keeps going while zweb is hidden and the transcript resumes from the seq it stopped at when you return. Coming back also re-reads the session list, and reconnects on its own if the event stream had exhausted its retries while the page was frozen.
The service worker is deliberately conservative because zweb handles private systems:
- Authenticated pages, transcripts, prompts, API responses, and mutations are never cached.
- Only the generic offline screen, app icons, stylesheet, and immutable Next.js assets may be cached.
- When the network is unavailable, navigation shows a generic offline screen rather than stale session data.
- Service-worker registration is production-only. Installation requires HTTPS, except during browser-supported localhost development.
After changing the manifest, icons, or service worker, run a new production build and reload the installed app while online so it can update.
Opening zweb starts a new conversation on the initial server. The composer becomes ready as soon as the new session is created (and the model and input queue are ready); saved sessions load in the sidebar in the background. No previous transcript is downloaded unless you select a saved session. If a run is already active, zweb restores its conversation instead. Reconnect and server switching retain the server's selected conversation, or create one if none is selected.
With zcoder.zsh 0.12.0 or later advertising input_queue: true, the composer stays available during a run. Choose Steering to add guidance after the current model response and its tools, or Follow-up to queue another task after the current task finishes. Both use the existing run and event stream, including command approvals.
Pending messages appear directly in the transcript with a clock and Queued, Paused, Unconfirmed, or Rejected status. Consumed and discarded entries leave the pending list; the server transcript owns consumed message history. Consumption does not mean the model has answered the input. Unconfirmed submissions retain their exact request and offer Retry exact submission. A rejected or malformed response leaves the draft available.
The browser saves queued requests and receipt states in local storage, scoped by configured server ID and session ID, before sending. These records include message text so retries can preserve exact bytes. They survive reloads; rejected messages can be dismissed to remove their local records. They are separate from the server-owned transcript and from service-worker caching. If browser storage is unavailable or full, new queue submissions are blocked before posting.
On reconnect, zweb loads the selected session, reconciles saved message IDs through /v1/input/status, and obtains the accepting run from /v1/input/list. The server listing is displayed as text: it cannot safely be parsed into individual messages, and user-message events do not contain IDs. Clearing browser storage loses per-message controls for those submissions; the listing and session-wide recovery remain available.
On zcoder.zsh 0.17.2 or later, stopping an operation asks the server to continue any messages queued for that same run. When the server confirms continuation, zweb keeps the existing event stream and cursor open while queued work takes over. Older servers stop normally and preserve unconsumed input. While idle in its original session, use Resume pending input to explicitly select that session and start /queue resume, or remove a known pending message with its × control. Discard races are reconciled by status; they never claim to undo consumed input. Servers without queue support keep active-run input as an unsent draft until a normal turn can start.
Tool activity appears as compact rows. Select Show output to mount the full result and Hide output to collapse it. Draft typing stays inside the composer so it does not rerender the transcript or sidebar.
Use Open document to read a .md or .markdown file from the active server's workspace. Enter a literal filename, including any spaces, without shell quoting. Relative paths resolve on the server; absolute paths must remain inside its workspace. The server must advertise documents: true; older servers show an update message.
Up to four read-only document tabs stay in memory alongside Coding. Canonical paths identify tabs, so opening a symlink or absolute alias reuses the same document. Tabs keep their scroll positions; Reload explicitly reads the latest contents, and failed reads preserve the previous view. Closing a tab or returning to Coding focuses the prompt. Switching servers or reloading the app clears the tabs; document contents are never saved in browser storage.
Reading works during warm-up and active turns without changing the draft, transcript, event cursor, or session. Command approvals take focus and temporarily disable the reader. Files are limited to 256 KiB by the server, which also enforces symlink and workspace boundaries. Markdown uses the existing safe renderer, with remote HTML and images kept inert.
Protocol 1 sends the zcoder token, prompts, reasoning, commands, and transcripts over plain HTTP. Do not expose port 7337 publicly. A loopback SSH tunnel is the safest simple option:
ssh -N -L 127.0.0.1:7337:127.0.0.1:7337 operator@workshopThen use http://127.0.0.1:7337 in ZCODER_SERVERS_JSON. Assign another local port for each additional server. A private WireGuard or Tailscale path with restrictive firewall rules is also suitable.
- Argon2id password verification; plaintext login passwords are never stored.
- Signed 12-hour
HttpOnly,Secure(production),SameSite=Strictsession cookie. - Same-origin validation on every mutation, including login and zcoder RPC calls.
- Login throttling with escalating lockouts. It is process-local; for multiple zweb instances, add shared rate limiting at the reverse proxy.
- Per-request nonce-based CSP, frame blocking, MIME sniffing protection, minimal browser permissions, no referrer, and production HSTS.
- Every API route checks authentication independently.
- Fixed RPC allowlist: browsers cannot choose arbitrary upstream paths or URLs.
- Bounded browser request bodies and bounded zcoder responses.
- Zcoder URLs and bearer tokens never enter client bundles or API responses.
Set TRUST_PROXY_HEADERS=true only when a trusted reverse proxy overwrites X-Forwarded-For and clients cannot reach zweb directly. Otherwise all login attempts intentionally share one conservative rate-limit bucket.
The zcoder bearer token remains a server-wide capability. Anyone who obtains either .env.local or server process access can control that upstream. Restrict the file to the service account (chmod 600 .env.local), run zweb as an unprivileged user, and keep application/debug logs private.
pnpm lint
pnpm test
pnpm build
pnpm auth:hash