Experimental — pre-release software. APIs, protocols, and on-disk formats may change without notice. Do not rely on it for anything you can't recreate.
Remote access to pty sessions over an end-to-end encrypted WebSocket tunnel. Connect from a browser, phone, or another terminal. Two modes:
-
Self-hosted (
pty-relay local start) — one process runs a lightweight relay + daemon on your machine. No accounts, no email. Auth is the#pk.secretfragment in the token URL printed on startup. Great for a laptop talking to a desktop over Tailscale or a LAN. -
Public relay (
pty-relay server signin) — your daemon connects outbound to a multi-tenant relay atrelay.pty.computer. Email + TOTP auth, account-scoped devices, works over any NAT. Great for reaching your machines from anywhere.
Both modes use the same Noise-encrypted session protocol; the relay only sees opaque binary frames.
pty and pty-relay are private
packages — not published to npm. pty-relay depends on pty as a local
sibling checkout via a file:../pty dependency, and runs its TypeScript
sources directly on Node.js 22.18+ or 23.6+ — a release that strips
types natively without a flag (default from Node 23.6, backported to the
22.18 LTS line), so pty-relay itself has no build step.
Adopt it standalone by cloning both repos side by side:
# 1. Clone pty and pty-relay as siblings — pty-relay resolves `file:../pty`.
git clone https://github.com/compoundingtech/pty
git clone https://github.com/compoundingtech/pty-relay
# 2. Build pty first: pty-relay imports pty's compiled dist/, which is
# gitignored, so `npm install` alone does not produce it.
(cd pty && npm install && npm run build)
# 3. Install pty-relay. The `file:../pty` dependency links your sibling
# pty checkout into node_modules automatically — no `npm link` needed.
(cd pty-relay && npm install)Verify (from the pty-relay checkout):
node src/cli.ts --version # <semver>+<short-sha>
node src/cli.ts doctor # loads the @compoundingtech/pty import — proves the wiringOptionally put pty-relay on your PATH: (cd pty-relay && npm link),
then run pty-relay --version / pty-relay doctor.
pty-relay doctor prints a diagnostic report (Node version, OS,
keychain status, external tools) safe to share when troubleshooting —
it does not print secrets.
- Node.js 22.18+ or 23.6+ (native TypeScript, no flag)
- macOS, Linux, or Windows
- A working system keyring for the zero-prompt experience:
- macOS: Keychain (always available)
- Windows: Credential Manager (always available)
- Linux desktop: GNOME Keyring or KDE Wallet via Secret Service
- Linux server: no keyring by default — use a passphrase
- Optional:
tailscaleCLI (for--tailscale),qrencode(for QR)
pty-relay init
pty-relay local start --tailscale --auto-approve --allow-new-sessionsPrints a token URL. Open it in a browser, scan the QR from your phone, or on another machine run:
pty-relay connect <token-url>For maximum security, drop the convenience flags:
pty-relay local startEach new client then has to be approved (see Client approval),
and remote session creation is disabled (clients can only attach to
sessions you've already started with pty run).
pty-relay local start -d --tailscale --auto-approve --allow-new-sessionsWraps the relay in a detached pty
session and prints the token URL. Reattach with
pty attach relay-daemon, stop with pty kill relay-daemon.
Check status any time:
pty-relay local status # pid / label / pubkey / client count
pty-relay local status --show-token # also print the token URLpty-relay init
pty-relay server signin --email you@example.com --relay https://relay.pty.computerPrompts for the 6-digit code emailed to you, then (on a fresh account)
prints an otpauth:// URL to add to an authenticator app. Then:
pty-relay server startLeave it running in the foreground, or run detached:
pty-relay server start -d
# reattach: pty attach relay-server
# stop: pty kill relay-serverpty-relay init
pty-relay client signin --email you@example.com --relay https://relay.pty.computerPrompts for an email code and a current 6-digit TOTP code from your authenticator. Then:
pty-relay server hosts --merge # pull the account's daemons into known-hosts
pty-relay client ls # list sessions on each daemon
pty-relay client connect <label>- Another daemon on the same account:
pty-relay server signinwith the same email. The relay asks for the current TOTP from your app (proof that you control an existing daemon). - Another account-wide client:
pty-relay client signinwith the same email + TOTP. - A pinned client (scoped to one daemon): on the daemon,
pty-relay server mintprints a one-time preauth URL. On the joining device,pty-relay client join <url>claims it. The resulting client key can only reach that one daemon — the relay enforces the pin.
pty-relay server rotate --role <daemon|client>— two-step Ed25519 rotation. Add--completeto finalize.pty-relay server revoke <label-or-key>— kick a device off the account. Prompts for confirmation.pty-relay server delete-account— nuke the whole account on the relay. Prompts for confirmation.pty-relay local reset— wipe just the self-hosted daemon's local state on this machine. Preserves public-relay enrollment.pty-relay reset— nuke everything in the config dir.
local start [port] Run the relay (default: 8099)
--tailscale Proxy HTTPS via 'tailscale serve'
--auto-approve Skip the per-client approval TUI
--allow-new-sessions Let remote clients spawn pty sessions
-d [--name <label>] Run detached in a 'pty' session
local status [--show-token] Daemon state: pid, label, pubkey, client count
local reset [--force] Wipe self-hosted daemon state only
server signin --email <addr> Register this machine as a daemon
[--label <name>] (defaults to OS hostname)
[--relay <url>] (defaults to http://localhost:4000)
server start Run the daemon attached to a public relay
[--allow-new-sessions]
-d [--name <label>] Run detached in a 'pty' session
(default label: relay-server)
server mint Mint a preauth URL (daemon-pinned client)
[--ttl-seconds N]
[--totp-code <code>]
server status [--json] Account, daemon key, client key, pin info
server hosts [--merge] List devices on the account; --merge adds
daemons to known-hosts
server rotate --role <daemon|client> [--complete]
server revoke <label-or-key> [-y] [--force]
server add-email <email>
server delete-account [-y]
server totp show Print the TOTP secret (re-add to authenticator)
server totp code Print the current 6-digit code
client signin --email <addr> Register this machine as an account-wide
[--label <name>] client (account must exist)
[--relay <url>]
client join <preauth-url> Claim a preauth (produces a daemon-pinned
[--label <name>] client key)
[--totp-code <code>]
client ls List known hosts and their sessions
client connect <host-or-url> Attach to a remote pty session
client peek <host> <session> Print a session's current screen
client send <host> <session> Send input
client tag <host> <session> Show / set tags
client events <host> Follow a daemon's events
client rename <old> <new> Rename a saved known-host entry
client forget <host-label> Remove a saved host
Session commands transparently handle both self-hosted (token URL) and public-relay hosts via known-hosts.
init Initialize the encrypted secret store
reset [--force] Nuke everything in the config dir (prompts)
doctor Print environment / diagnostics
set-name <label> Set the label this daemon advertises
clients Interactive client-approval TUI (self-hosted)
clients list | approve | revoke | invite
psk-gen Print a fresh PSK (for --psk-file / PTY_RELAY_PSK)
kill <host> <session> Terminate a remote session over ssh://
version Print the version
Global flags:
--config-dir <dir> Override config directory
--passphrase-file <path> Passphrase from file (non-interactive)
--psk-file <path> Load a Noise_NKpsk2 pre-shared key
For fleet-style setups (config-management drops a config file on every
machine, peers Just Work™), pty-relay reads a peers list at command
time. No add calls, no daemon restart, no encrypted-store
mutation — drop a file at the documented path and ls/peek/send/
tag/kill/events/connect discover the peers automatically.
Path (first found wins):
$PTY_RELAY_PEERS_FILE— explicit override (for tests / unusual layouts).$XDG_CONFIG_HOME/pty-relay/peers— canonical XDG.~/.config/pty-relay/peers— fallback whenXDG_CONFIG_HOMEis unset.
Format — one entry per line, blank lines + # comments ignored:
# Each line is either:
# <url> — peer with auto-derived label (hostname)
# <url> <label> — peer with explicit label
ssh://web1.example.com
ssh://nathan@web2.example.com:2222 prod-web-2
ssh://db1.example.com primary-db
# https://#pk.secret URLs work too (token-URL form from `pty-relay
# local start`'s output) — same line grammar, label defaults to host.
https://relay.example.com#PUBLICKEY.SECRET home-relay
URL kinds:
ssh://[user@]host[:port]— peer whereptyruns and ssh handles auth + transport. No relay daemon required. Subcommands shell out tossh <host> pty <op>directly. Needsptyon the remote's PATH and ssh key-based auth (BatchMode=yesis forced).http(s)://host[:port][/session]#pk.secret[.token]— the self-hosted relay token URL. Same formpty-relay local startprints, which is paste-friendly into the file.
Encrypted-store entries (saved interactively via add / connect /
server signin) win on label collisions; peers-file entries
that collide get a numeric suffix (web-2, web-3) so every line
stays reachable. Malformed lines are warned to stderr and skipped —
a typo on line 7 doesn't take down the other 49 peers.
By default, each new client has to be approved. Three ways:
Interactive TUI:
pty-relay clientsNavigate with arrows, approve with Enter, revoke with r.
CLI:
pty-relay clients list
pty-relay clients approve <id>
pty-relay clients revoke <id>Pre-auth invite URL:
pty-relay clients invite --label "iPhone"Prints an invite URL that auto-approves on first use.
Skip approval entirely:
pty-relay local start --auto-approve- CLI clients save their approved token to the encrypted known-hosts store. Reconnections are seamless.
- Browser clients save the token to
localStorage. The URL in the address bar never changes — safe to bookmark. - Revoking a token deny-lists it. The client gets an immediate error and re-enters the approval queue.
On both transports, if the network drops (laptop sleep, WiFi change,
mobile switch) the client reconnects with exponential backoff and
re-attaches to the same session. Terminal state is fully restored via
pty's SCREEN packet. Press Ctrl+\ during the reconnect wait to
detach instead.
Revocation (close code 4001) is terminal — the daemon exits, and the client surfaces a clear error rather than looping on 401.
All sensitive files are encrypted at rest. No plaintext fallback.
- System keychain (default when available): macOS Keychain, Windows Credential Manager, Linux Secret Service. No prompts.
- Passphrase (fallback): Argon2id + XChaCha20-Poly1305. Prompted on first run.
PTY_RELAY_PASSPHRASE=... pty-relay local start
pty-relay local start --passphrase-file /path/to/passphraseNo recovery. pty-relay reset --force deletes everything. Every client
has to re-approve, every public-relay device has to re-enroll.
Protected:
- Session traffic is end-to-end encrypted between client and daemon. The relay (self-hosted or public) only sees opaque frames.
- Noise NK handshake with fresh per-session ephemeral keys (forward secrecy).
- Public-relay client-pair connections add Noise KK, so both ends authenticate each other's static keys too.
- All HTTP + WS auth on the public relay is v2-canonical Ed25519 signatures: signed payload binds to method + path + body/query hash. A captured signature for one endpoint can't be replayed against another.
- Preauth-minted client keys are daemon-pinned server-side — they can only reach the minting daemon, never siblings on the account.
- Daemon keys and client keys have strictly separate roles. A daemon key cannot open a client-pair WebSocket, and vice versa.
- Files at rest: XChaCha20-Poly1305 via keychain or passphrase-derived
key.
0o600on all secret files.
Not protected:
- Same-user malware on a machine where the daemon is running.
- Physical access to an unlocked machine.
- A compromised email inbox (someone with inbox access plus your TOTP can enroll devices; email + TOTP are the two factors).
- Email + TOTP for signin. TOTP secret is generated on the first daemon signup for the account and persisted on every subsequent daemon that joins — any daemon can mint preauths.
- Clients never receive the TOTP secret.
- Adding another daemon to an existing account requires a current TOTP code from an already-enrolled daemon (proof-of-control).
- Account-wide client signin is email + TOTP; the relay rejects if the account doesn't exist or has no active daemon yet.
pty-relay doctor— environment, tool availability, config dir.pty-relay local status/pty-relay server status— per-mode state.PTY_RELAY_DEBUG=1 pty-relay …— verbose logging on connection lifecycles.
# Clone pty and pty-relay as siblings.
git clone https://github.com/compoundingtech/pty
git clone https://github.com/compoundingtech/pty-relay
# Build pty FIRST. pty-relay imports pty's compiled output
# (@compoundingtech/pty/client), and pty's dist/ is gitignored — `npm install`
# alone does not build it. Skip this and pty-relay fails at runtime with
# `ERR_MODULE_NOT_FOUND: .../@compoundingtech/pty/dist/client-api.js`.
(cd pty && npm install && npm run build)
# Install pty-relay. The `file:../pty` dependency links your sibling pty
# checkout into node_modules automatically — no `npm link` needed.
(cd pty-relay && npm install)Because @compoundingtech/pty resolves to your local ../pty checkout,
changes to either repo are picked up immediately — just re-run
npm run build in pty whenever you change its sources.
pty-relay itself has no build step and no bin/ wrapper — it runs
its TypeScript sources directly via Node's native type stripping, and
the entry point is src/cli.ts. Run and verify it any of these ways:
node src/cli.ts doctor # from the pty-relay checkout
./src/cli.ts doctor # via the shebang
npm link && pty-relay doctor # also puts `pty-relay` on your PATHUse doctor (not --version) as the smoke test: it loads the
@compoundingtech/pty import, so it actually proves the link is wired up.
--version prints before that import loads and succeeds even when pty
is unbuilt.
src/
cli.ts top-level dispatch
commands/
local/ self-hosted daemon (start, status, reset)
server/ public-relay daemon (signin, mint, etc.)
client/ public-relay client (signin, join)
connect.ts, ls.ts, peek.ts, … session commands (both modes)
start.ts, start-shared.ts self-hosted daemon internals
crypto/ Noise (NK + KK), Ed25519 signing, TOTP
relay/ HTTP client, WS primary + per-client, known-hosts
storage/ encrypted secret store (keychain + passphrase)
terminal/ CLI terminal bridge
browser/ web UI (vanilla TS, bundled)
test/ unit tests (vitest)
integration/ end-to-end tests with real pty sessions
npx vitest run # unit
npx vitest run --config integration/vitest.config.ts # integration
npx playwright test --config integration/playwright.config.ts # browser
npx tsc --noEmit # typechecknpm run build:browserOutput in browser/dist/ is committed to git — no build step needed to
run. Only rebuild after editing browser/src/.
- Session protocol is transport-agnostic. The same
handleSessionControlMessagedispatcher handleslist / attach / peek / send / tag / events_subscribe / spawnfor both self-hosted and public-mode daemons. - Noise has one token-driven engine (
src/crypto/noise.ts) supporting NK and KK. Pattern selection is driven by the relay'spairedframe metadata. - Public-relay wire contract is v2 canonical signed payloads (see
src/crypto/signing.ts). Any change to the signed bytes must match the Elixir relay byte-for-byte. - Storage has two backends behind
SecretStore; callers never see plaintext-on-disk paths.
MIT