WebSocket & Unix Socket Protocol
relay-tty uses a binary framing protocol for communication between browsers, the CLI, the server (WS bridge), and pty-host processes. The same message types are used over both WebSocket and Unix socket transports.
Transport
- WebSocket (browser/CLI ↔ server): raw binary frames, no additional framing
- Unix socket (server/CLI ↔ pty-host): length-prefixed frames —
[4B uint32 BE length][payload]
In both cases, the first byte of the payload is the message type.
Message Types
| Byte | Direction | Name | Payload |
|---|---|---|---|
0x00 | bidirectional | DATA | Raw terminal data (UTF-8) |
0x01 | client→server | RESIZE | 2× uint16 BE: cols, rows |
0x02 | server→client | EXIT | int32 BE: exit code |
0x03 | server→client | BUFFER_REPLAY | Raw output buffer (on connect) |
0x04 | server→client | TITLE | UTF-8 string (from OSC 0/2 escape) |
0x05 | server→client | NOTIFICATION | UTF-8 string (from OSC 9 escape) |
0x10 | client→server | RESUME | float64 BE: byte offset to resume from; optional second float64 BE: max replay bytes (tail limit for full replay) |
0x11 | server→client | SYNC | float64 BE: current total byte offset |
0x12 | server→client | SESSION_STATE | 1 byte: 0x00 = idle, 0x01 = active |
0x13 | server→client | BUFFER_REPLAY_GZ | gzip-compressed output buffer (on connect) |
0x14 | server→client | SESSION_METRICS | 4× float64 BE: bps1, bps5, bps15, totalBytes |
0x24 | client→server | SET_TITLE | UTF-8 string: user-set title. Sets titlePinned so OSC title updates no longer overwrite it. Empty payload unpins. |
0x25 | client→server | SIGNAL | 1 byte: signal number, delivered to the foreground process group (kill(-tcgetpgrp(master), sig)) |
0x26 | client→server | OBSERVE | (none). Only meaningful as the first frame: marks the client as an observer, which gets no BUFFER_REPLAY/SYNC, is not counted as an attached viewer for agentState, but still receives every broadcast and may send control messages. Ignored if sent later. |
Constants are defined in shared/types.ts as WS_MSG.
Connection Flow
First connect (no prior offset)
Client pty-host
│── connect ──────────────────▶│
│ │── BUFFER_REPLAY or BUFFER_REPLAY_GZ ──▶
│ │── SYNC(totalBytes) ──────────────────▶
│◀─ ready for DATA ───────────│pty-host waits 100ms for a RESUME message. If none arrives (e.g., CLI clients), it sends the full buffer. A SPARKLINE_REQUEST sent as the first message is answered directly without a replay. An OBSERVE sent as the first message skips the replay entirely: the client joins the live broadcast stream (and gets EXIT immediately if the session already ended) without being counted as a viewer. Server monitors, gallery cells, and plugins should open sockets this way so a session can reach agentState: "done" while only they are connected.
Reconnect (delta resume)
Client pty-host
│── connect ──────────────────▶│
│── RESUME(lastOffset) ───────▶│
│ │── BUFFER_REPLAY (delta from offset) ──▶
│ │── SYNC(totalBytes) ──────────────────▶
│◀─ ready for DATA ───────────│If the requested offset is before the buffer start (data was overwritten in the ring buffer), pty-host sends a full replay instead.
Tail-limited replay
RESUME accepts an optional 16-byte form: [offset float64 BE][maxReplayBytes float64 BE]. When maxReplayBytes > 0 and the server would send a full replay (offset ≤ 0 or overwritten), the replayed data is clamped to the last maxReplayBytes bytes, starting after the first newline in the tail so it never begins mid-escape-sequence. Delta replays are never clamped, and SYNC always carries the authoritative total offset — so delta resume keeps working after a clamped replay. The 8-byte form behaves exactly as before. Gallery thumbnails use this with a 256KB limit to avoid pulling full 10MB buffers for every cell.
Ongoing session
Client pty-host
│◀── DATA ────────────────────│ (terminal output)
│── DATA ─────────────────────▶│ (keyboard input)
│── RESIZE ───────────────────▶│ (terminal resized)
│◀── TITLE ───────────────────│ (OSC title change)
│◀── NOTIFICATION ────────────│ (OSC 9 alert)
│◀── SESSION_STATE ───────────│ (idle/active transitions)
│◀── SESSION_METRICS ─────────│ (periodic throughput stats)
│◀── EXIT ────────────────────│ (process exited)Buffer & Replay
- pty-host maintains a 10MB ring buffer of terminal output
totalBytesis a monotonic counter (never resets) tracking all bytes writtenRESUME/SYNCuse float64 becausetotalBytescan exceed 2^32 for long-running sessionsBUFFER_REPLAY_GZis used when the buffer exceeds ~64KB to reduce transfer time- Browser writes replayed data in 64KB chunks with
setTimeoutyields to avoid UI jank - A full replay is trimmed at the last full-screen clear (
ESC[2J), because TUIs such as Claude Code redraw with a clear on every frame and only the last frame matters
Terminal mode restore
Trimming at the last clear also drops the sequences an application used to set up the terminal, usually long before. pty-host therefore tracks the modes the application has set and prefixes every full replay (first connect, or a stale offset) with the sequences that restore them. Delta replays never get the prefix, since the client already processed those bytes.
| Restored | Sequence |
|---|---|
| Alternate screen | ESC[?1049h, emitted first and only when the trimmed body no longer contains the switch |
| Cursor keys (DECCKM), autowrap, cursor visible | ESC[?1h, ESC[?7l, ESC[?25l |
| Mouse tracking and encodings | ESC[?1000h, ?1002h, ?1003h, ?1004h (focus), ?1005h, ?1006h, ?1015h, ?1016h |
| Bracketed paste | ESC[?2004h |
| Application keypad | ESC= (also set by ESC[?66h) |
| Cursor shape (DECSCUSR) | ESC[Ps q |
| Kitty keyboard flags | ESC[>flags u |
| xterm modifyOtherKeys | ESC[>4;Pv m |
Only settings that differ from the power-on defaults are emitted, so a plain shell gets no prefix. ESC c (RIS) and ESC[!p (DECSTR) reset the tracked state. The list is defined in crates/pty-host/src/term_modes.rs and mirrored by the client-side reset in shared/client/terminal-reset.ts, which turns all of these off when a terminal client leaves a session. Change them together.
Metrics (0x14)
The Rust pty-host broadcasts SESSION_METRICS every 5 seconds to all connected clients:
| Offset | Type | Field |
|---|---|---|
| 0 | float64 BE | bps1 — bytes/sec, 1-minute exponential moving average |
| 8 | float64 BE | bps5 — bytes/sec, 5-minute EMA |
| 16 | float64 BE | bps15 — bytes/sec, 15-minute EMA |
| 24 | float64 BE | totalBytes — total bytes written to PTY |
The Node.js fallback only provides a single bytesPerSecond (30-second window) via SESSION_STATE messages.
Session Metadata
pty-host is the only writer of ~/.relay-tty/sessions/<id>.json. The server watches that file and forwards changes to browsers as SESSION_UPDATE, so every field below reaches clients without a dedicated message type. Besides the basics (id, command, args, cwd, status, cols, rows, pid, timestamps, throughput, title, foregroundProcess), the file carries:
| Field | Type | Meaning |
|---|---|---|
agentState | "working" | "blocked" | "done" | "idle" | "unknown" | Heuristic state of the foreground agent, recomputed once a second from foregroundProcess, bps1, and the last 4KB of output with escapes stripped. idle when the shell prompt is in the foreground, unknown for processes that are not a recognized agent (a bare version number such as 2.1.266 counts as Claude Code, since that is how its native binary reports its name). blocked means a permission or confirmation prompt is on screen. done means a known agent settled while no client was connected; it clears to idle on the next connect. |
agentStateChangedAt | number (epoch ms) | When agentState last changed. The file is rewritten immediately on a transition rather than waiting for the 5-second flush. |
titlePinned | boolean (omitted when false) | Set by SET_TITLE. While true, OSC 0/2 title sequences from the program are ignored and title stays as the user set it. An empty SET_TITLE clears it. |
The rule tables live in crates/pty-host/src/agent_state.rs; adding an agent or a prompt pattern is a one-line change with unit tests alongside.
Tunnel Relay Protocol
When --tunnel is used, relay-tty connects outbound to a relay service (relaytty.com) which reverse-proxies browser traffic back to localhost. The relay service and the tunnel client communicate over a single WebSocket using the binary framing described below.
This protocol is implemented in two places:
- relay-tty (Node.js):
shared/tunnel.ts— usesBuffer - relaytty.com (Cloudflare Workers):
src/types.ts— usesArrayBuffer/DataView
Both implementations MUST produce identical wire bytes. Changes to this spec require updates to both codebases.
Transport
A single WebSocket connection between the tunnel client and the relay service:
wss://relaytty.com/ws/tunnel?key=<api_key>Authentication is via the key query parameter (format: rly_ + base62-encoded 32 random bytes). The relay service resolves the key to an account and routes to the Durable Object for that account's tunnel slug.
Frame Format
All messages are binary WebSocket frames:
[1B type][4B client_id BE][payload...]- type — one of the frame types below
- client_id — uint32 big-endian, identifies a specific browser connection (assigned by the relay service)
- payload — type-dependent, may be empty
Minimum frame size is 5 bytes (header only, no payload).
Frame Types
| Byte | Name | Direction | Payload |
|---|---|---|---|
0x01 | CLIENT_OPEN | relay→tunnel | UTF-8 WS path (e.g. /ws/sessions/abc123) |
0x02 | CLIENT_CLOSE | bidirectional | (none) |
0x03 | DATA | bidirectional | Raw bytes (terminal I/O or WS messages) |
0x04 | HTTP_REQUEST | relay→tunnel | JSON-encoded TunnelHttpRequest |
0x05 | HTTP_RESPONSE | tunnel→relay | JSON-encoded TunnelHttpResponse |
Constants are defined in shared/tunnel.ts as TunnelFrameType.
WebSocket Bridging
Browser WebSocket connections are multiplexed over the single tunnel WebSocket using client_id:
Browser A ──WS──▶ relay service ──CLIENT_OPEN(1, "/ws")──▶ tunnel client
Browser B ──WS──▶ relay service ──CLIENT_OPEN(2, "/ws")──▶ tunnel clientCLIENT_OPEN (0x01)
Sent by the relay when a browser opens a WebSocket to <slug>.relaytty.com. The payload is the request path (UTF-8). The tunnel client opens a local WebSocket to ws://localhost:<port><path> and associates it with the client_id.
DATA (0x03)
Bidirectional. Browser messages are wrapped as DATA(client_id, bytes) and forwarded to the tunnel client, which delivers them to the corresponding local WebSocket. Responses flow in reverse.
CLIENT_CLOSE (0x02)
Sent in either direction when a WebSocket closes. The receiving side closes its corresponding connection and releases the client_id.
HTTP Proxying
Non-WebSocket HTTP requests to <slug>.relaytty.com are proxied through the tunnel connection using request/response frames. The relay assigns a temporary client_id for each HTTP transaction.
HTTP_REQUEST (0x04)
Payload is a JSON object:
{
"method": "GET",
"path": "/s/eyJ...",
"headers": { "accept": "text/html", "cookie": "..." },
"body": "<base64>"
}| Field | Type | Description |
|---|---|---|
method | string | HTTP method |
path | string | Request path including query string |
headers | Record<string, string> | Request headers |
body | string? | Base64-encoded request body (omitted for GET/HEAD) |
HTTP_RESPONSE (0x05)
Payload is a JSON object:
{
"status": 200,
"headers": { "content-type": "text/html" },
"body": "<base64>"
}| Field | Type | Description |
|---|---|---|
status | number | HTTP status code |
headers | Record<string, string> | Response headers |
body | string? | Base64-encoded response body (omitted if empty) |
The relay waits up to 30 seconds for an HTTP_RESPONSE with the matching client_id. On timeout it returns 504 Gateway Timeout to the browser. On tunnel disconnect it returns 502 Bad Gateway.
Connection Lifecycle
tunnel client relay service browser
───────────── ───────────── ───────
WS connect (key=rly_...) ──▶ verify key, bind to DO
◀── WS accepted
◀── HTTP GET /
HTTP_REQUEST(1) ──▶
fetch localhost ◀──
HTTP response ──▶ HTTP_RESPONSE(1) ──▶ 200 OK
◀── WS upgrade /ws
CLIENT_OPEN(2,"/ws") ──▶
open local WS ◀──
──▶ WS data
DATA(2, bytes) ──▶
local WS send ◀──
local WS recv ──▶ DATA(2, bytes) ──▶ WS data
──▶ WS close
CLIENT_CLOSE(2) ──▶
close local WS ◀──Security Notes
- The tunnel client strips
cf-*headers and setsHost: localhost:<port>on proxied HTTP requests so the local server's localhost bypass works correctly. - The relay service enforces limits: 1 tunnel per account, 2 concurrent browser viewers per tunnel (free tier).
- API keys are stored as SHA-256 hashes; the plaintext is only shown once at provisioning.
- The tunnel WebSocket uses auto-ping/pong for keepalive (relay sends pings, tunnel responds with pongs).