Merkur
Merkur is a terminal for your remote machines that runs in the browser. Link a workstation or a
server once, then open it from any laptop or phone and work in Neovim, tmux, Claude Code or Codex
as you would at the desk.
Try Merkur · Website · Quickstart ·
How it's different · How it works ·
Documentation
Claude Code and Neovim in tmux on a remote Linux machine, from a desktop browser and from a phone.
Created and maintained by @ph1losof, with AI coding agents (Claude
Code, Codex) writing most of the code. The design is mine, and a few choices were made just
because the technology was shiny, like WebTransport and WebGPU. They work, so they stayed.
Quickstart
You need a Mac or Linux machine to reach and a supported browser.
-
Open Merkur, create an account, and choose Add a machine.
-
Paste the command it shows into a shell on that machine. It installs the daemon and prints
an approval link and a QR code.
curl -fsSL https://app.merkur.sh/install | MERKUR_LINK_TOKEN=<token> sh
-
Approve it by opening the link, or scanning the code with a signed-in phone, and entering
your password. The daemon starts as a user service and the machine appears in your list. Open
it.
To have typing show up at the prompt before the machine answers, add the
shell integration to the end of your shell's startup file:
eval "$(merkur shell-integration bash)" # ~/.bashrc
eval "$(merkur shell-integration zsh)" # ~/.zshrc
merkur shell-integration fish | source # ~/.config/fish/config.fish
The first install trusts HTTPS to the server for the script, the binary and the release key
compiled into it. Every later update is verified against that key. Compare the fingerprint the
installer prints, or merkur release-key prints later, with:
733e336c becc7559 82591288 a138b3e3 6f74e40e ac916431 cf355b10 c234f059
If it differs, do not link the machine. See the security policy.
How Merkur is different
Claude Code Remote Control gives you a chat with one agent, not the range of a real terminal.
SSH clients like Termius and Blink need their own app and account on every device, plus your
keys or a VPN to reach each machine, and terminals built on xterm.js tend to draw agent TUIs with
gaps and rendering glitches. I wanted something I could open from anywhere, with all my machines
in one place, no keys to copy and no VPN, that works like a native app even on a bad network.
- It renders like a native terminal. Text, Nerd Font icons and box drawing come out sharp at
your screen's resolution, so Neovim, tmux and your agents look the way they do at the desk.
- It runs in the browser. Any laptop or phone works, a borrowed one included, and installed on
a phone it adds a keyboard with Esc, Ctrl, Tab and arrows.
- It holds up on bad networks. Like Mosh, it stays usable over high latency and packet loss, and
your session survives a switch from Wi-Fi to cellular.
- You sign in with a password. There are no SSH keys to carry, and the machine needs no open
port because it connects out.
- Images and links work. Kitty graphics render inline, links are clickable, and
merkur open
sends a URL from the remote shell to the browser you're using.
- It replaces a stack of services. Tailscale to reach the machine, Termius or Blink on each
device and Mosh on the server become one sign-in, encrypted end to end.
- It notifies you through web push. Turn notifications on in Settings, and a terminal bell on
any of your machines, such as an agent asking for input, reaches your phone or laptop even with
Merkur closed.
- You can work from anywhere: the browser on any device, or
merkur in your own terminal on a
computer, opens the same machines.
How it works
flowchart LR
Client[Browser or merkur client]
Server[Application server]
Edge[Blind WebTransport relay]
Daemon[Daemon on your machine]
Shell[Shell / PTY]
Client <-->|Encrypted terminal traffic| Edge
Edge <-->|Encrypted terminal traffic| Daemon
Client <-.->|Direct encrypted path when reachable| Daemon
Client <-->|Account and session setup| Server
Daemon <-->|Presence and session commands| Server
Daemon <--> Shell
The application server handles accounts, machine linking and session authorization. It never
carries terminal input or display updates. A session starts through a relay that sees only
ciphertext. The daemon only dials out; when the client and the daemon can reach each other, the
session moves to a direct WebTransport connection punched through NAT and the relay drops out.
SSH and most browser terminals send a byte stream over TCP, so one lost packet holds up
everything behind it. Merkur syncs the screen instead. The daemon's Rust dataplane runs a terminal
emulator that owns the grid and sends changed cells, encoded with MZ, a codec built for terminal
screens, as QUIC datagrams. Each datagram is a complete update to the rows it carries, so a loss
costs those rows until replay or forward error correction repairs them, and nothing queues behind
it. The daemon keeps the session through a network gap, and the client rejoins it.
In the browser, a WebAssembly build of the same terminal emulator decodes the updates and a WebGPU
renderer draws them, both in workers, so the UI thread handles only input. Glyphs rasterize at the
device's pixel density from a bundled Nerd Font. The transport design,
display invariants and
performance methodology cover the implementation and its tradeoffs.
Requirements
| Where | What you need |
|---|
| Browser | WebGPU, WebTransport with certificate-hash pinning, WebAssembly SIMD, and cross-origin-isolated SharedArrayBuffer support. GPU and driver support matter; see the browser requirements. |
| Mac | macOS 13 or newer, Apple Silicon or Intel with AVX2. |
| Linux | glibc 2.36 or newer, kernel 5.1 or newer, a systemd user session, and arm64 or x86-64 with AVX2. |
| Network | Outbound HTTPS/WSS and UDP for QUIC. |
Hardware identity uses Secure Enclave or TPM 2.0 where available; software custody requires
explicit opt-in. The full requirements list browser versions, hardware
identity prerequisites, and platform checks.
Security model
Your password never leaves the browser: Merkur signs you in with OPAQUE. Each machine's identity
lives in its Secure Enclave or TPM and is approved once from a signed-in device, and each browser
holds a delegation you can revoke. Every terminal session starts with a fresh ML-KEM-1024
handshake and runs under Noise. Daemon releases ship as signed manifests with rollback protection.
The encryption protects terminal contents on the relay and on the direct path. It does not
hide traffic timing or sizes, protect a compromised endpoint, or remove trust in the JavaScript
the application origin serves. Account and management traffic rely on TLS. The
threat model covers the post-quantum design and its limits.
Report vulnerabilities privately through the security policy.
Development
Use Bazelisk (it runs the Bazel release in .bazelversion), pnpm, the Bun
release in .bun-version, the pinned Rust toolchain, and
Docker with Compose. Run these commands from the repository root:
pnpm install
bun run infra:up # Start local Redis
bun run setup # Configure local env, hooks, and Rust/WASM artifacts
bun run dev # Start the edge, server, and browser app
Open the local URL printed by the development server. Link a local daemon to use a terminal;
bun run dev:full also runs that daemon once it is linked. bun run dev:doctor diagnoses missing
services, tools, and artifacts.
See the development guide for daemon development, build commands, tests,
and deployment prerequisites. CONTRIBUTING.md covers contributions and the CLA.
PRs welcome, including AI-assisted ones, if you've reviewed and tested the change.
Pull requests and pushes to main run CI. Version tags trigger the
release workflow: building, signing, service deployment, and
publication. See CI operations and releases.
Documentation
License
Merkur is licensed under the GNU AGPL v3.0. One component has separate terms: the
shared logger, packages/logger, uses Apache 2.0.
See NOTICE for details and run merkur licenses for bundled dependency notices and the
corresponding-source offer. Contributions require the Contributor License Agreement.
Rights beyond the public licenses, such as proprietary modification, OEM use, or a hosted
service, are available under a separate agreement; see commercial licensing.
The licenses do not cover the Merkur name or logo; see the trademark policy.
Workspace Layout
Bun and TypeScript handle the application and orchestration; Rust owns the terminal data path.
| Path | Role |
|---|
apps/web | Browser app: SolidJS, workers, WebGPU, and WASM. |
apps/server | Accounts, device linking, presence, session issuance, and web serving. |
apps/edge | Blind WebTransport relay. |
apps/daemon | Local CLI, service lifecycle, signed updates, and server control connection. |
apps/daemon/dataplane | Rust PTY, authentication, transport, input, and display synchronization. |
apps/stun | Authenticated STUN observations and direct-path verification. |
apps/site | Website: static pages, their build checks, and the static server. |
apps/tui | Native terminal client (merkur-tui): interactive and headless sessions. |
packages/auth | OPAQUE account authentication, tokens, and session authorization. |
packages/config | Server and daemon configuration. |
packages/logger | Structured logging and Effect integration. |
packages/protocol | Binary terminal protocol. |
packages/shared | Shared contracts, crypto helpers, transport utilities, and IPC framing. |
packages/daemon-control-protocol | Server–daemon control messages. |
packages/keyboard | Configurable touch terminal keyboard. |
packages/quicksilver | Quicksilver design system: tokens, UnoCSS preset, UI faces, Vite plugins. |
packages/user-agent | Bounded browser and OS identification. |
packages/profile | Native trace capture reader for profiling and the e2e oracles. |
packages/term-wasm | Browser terminal state, display decoding, fonts, and rendering data. |
packages/merkur-authorization | Delegations, daemon bindings, revocations, link approval, root envelope. |
|
Configuration
For local development, bun run setup creates the server environment. For deployments, start
with apps/server/.env.example and the
deployment guide.
Loaded by packages/config/src/server-config.ts.
| Variable | Required | Notes |
|---|
HOST | No | HTTP server bind address. Default: 0.0.0.0. |
PORT | No | HTTP server listen port. Default: 3000. |
PUBLIC_ORIGIN | No | Canonical origin browsers reach Merkur at. Set explicitly for deployments. Local setup uses http://127.0.0.1:3000. It is checked against signed browser delegations and used as the daemon linking target. Default: https://localhost:3000. |
DB_URL | No | Defaults to file:./data/merkur.db. file: opens a local database in-process; http(s):// or libsql:// reach a libSQL server, which is what production runs so more than one process can share one database. The compiled server carries only the network client, so a file: URL is refused there. Default: file:./data/merkur.db. |
DB_AUTH_TOKEN | No | Bearer credential for a remote database; unset for a local one. |
REDIS_URL | Yes | Redis or Dragonfly URL. |
ACCESS_TOKEN_HMAC_KEY | Yes | Canonical unpadded base64url for exactly 64 random bytes. It authenticates the single fixed access-token format; there is no key id or algorithm negotiation. |
JWT_ISSUER | No | Access-token issuer. Default: merkur. |
JWT_AUDIENCE | No | Access-token audience. Default: merkur-clients. |
TOKEN_HMAC_SECRET | Yes | Canonical unpadded base64url for exactly 64 random bytes; domain-separated HMAC-SHA-512 hashes refresh and link tokens, and derives deterministic synthetic absent-account auth-start material. Rotation changes future provisional values; already-created Redis auth flows retain their recorded material until consumed or expired. |
AUTH_ALLOW_REGISTRATION | Yes | Explicit account-creation policy. The combined OPAQUE start remains available in either state. When , every registration finish answers , whether the username is new or belongs to an account whose password was mistyped, so the refusal does not enumerate usernames; existing-account login is unchanged. When , account creation is limited to five per source address per hour. |
Edge replicas are configured through MERKUR_EDGE_* variables documented in
apps/edge/README.md; each replica's registration key is the exact per-id
entry from EDGE_REGISTRATION_KEYS_JSON and is never sent, and every replica verifies attach tickets
with the one deployment-wide EDGE_ATTACH_TICKET_KEY. STUN responders are configured through
MERKUR_STUN_* variables documented in apps/stun/README.md, including the
two-address topology that direct-path verification needs. The daemon's ~/.merkur/config.json
schema and its hardware identity custody (Secure Enclave on macOS, TPM 2.0 on Linux, or
--identity-backend software) are in
docs/security.md.