relay-ttydocs
Reference

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

ByteDirectionNamePayload
0x00bidirectionalDATARaw terminal data (UTF-8)
0x01client→serverRESIZE2× uint16 BE: cols, rows
0x02server→clientEXITint32 BE: exit code
0x03server→clientBUFFER_REPLAYRaw output buffer (on connect)
0x04server→clientTITLEUTF-8 string (from OSC 0/2 escape)
0x05server→clientNOTIFICATIONUTF-8 string (from OSC 9 escape)
0x10client→serverRESUMEfloat64 BE: byte offset to resume from; optional second float64 BE: max replay bytes (tail limit for full replay)
0x11server→clientSYNCfloat64 BE: current total byte offset
0x12server→clientSESSION_STATE1 byte: 0x00 = idle, 0x01 = active
0x13server→clientBUFFER_REPLAY_GZgzip-compressed output buffer (on connect)
0x14server→clientSESSION_METRICS4× float64 BE: bps1, bps5, bps15, totalBytes
0x24client→serverSET_TITLEUTF-8 string: user-set title. Sets titlePinned so OSC title updates no longer overwrite it. Empty payload unpins.
0x25client→serverSIGNAL1 byte: signal number, delivered to the foreground process group (kill(-tcgetpgrp(master), sig))
0x26client→serverOBSERVE(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
  • totalBytes is a monotonic counter (never resets) tracking all bytes written
  • RESUME/SYNC use float64 because totalBytes can exceed 2^32 for long-running sessions
  • BUFFER_REPLAY_GZ is used when the buffer exceeds ~64KB to reduce transfer time
  • Browser writes replayed data in 64KB chunks with setTimeout yields 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.

RestoredSequence
Alternate screenESC[?1049h, emitted first and only when the trimmed body no longer contains the switch
Cursor keys (DECCKM), autowrap, cursor visibleESC[?1h, ESC[?7l, ESC[?25l
Mouse tracking and encodingsESC[?1000h, ?1002h, ?1003h, ?1004h (focus), ?1005h, ?1006h, ?1015h, ?1016h
Bracketed pasteESC[?2004h
Application keypadESC= (also set by ESC[?66h)
Cursor shape (DECSCUSR)ESC[Ps q
Kitty keyboard flagsESC[>flags u
xterm modifyOtherKeysESC[>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:

OffsetTypeField
0float64 BEbps1 — bytes/sec, 1-minute exponential moving average
8float64 BEbps5 — bytes/sec, 5-minute EMA
16float64 BEbps15 — bytes/sec, 15-minute EMA
24float64 BEtotalBytes — 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:

FieldTypeMeaning
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.
agentStateChangedAtnumber (epoch ms)When agentState last changed. The file is rewritten immediately on a transition rather than waiting for the 5-second flush.
titlePinnedboolean (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 — uses Buffer
  • relaytty.com (Cloudflare Workers): src/types.ts — uses ArrayBuffer/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

ByteNameDirectionPayload
0x01CLIENT_OPENrelay→tunnelUTF-8 WS path (e.g. /ws/sessions/abc123)
0x02CLIENT_CLOSEbidirectional(none)
0x03DATAbidirectionalRaw bytes (terminal I/O or WS messages)
0x04HTTP_REQUESTrelay→tunnelJSON-encoded TunnelHttpRequest
0x05HTTP_RESPONSEtunnel→relayJSON-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 client

CLIENT_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>"
}
FieldTypeDescription
methodstringHTTP method
pathstringRequest path including query string
headersRecord<string, string>Request headers
bodystring?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>"
}
FieldTypeDescription
statusnumberHTTP status code
headersRecord<string, string>Response headers
bodystring?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 sets Host: 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).

On this page