docsRelay & MCP Bridge

Relay & MCP bridge

SmartChats runs in a browser or on a phone. The agents and terminals you want it to reach run on your own computer, behind NAT, with no public address. The browser can’t open a connection to them. The relay solves this: both sides dial out to it over WebSocket, and it routes frames between them.

Components

PackageRole
smartchats-relay-cfThe relay. A Cloudflare Worker with one Durable Object per user that holds every socket for that user
smartchats-relay-protocolWire format shared by every party: envelope, message types, acks, resume, the hub client
smartchats-relay-connectClient helper for outbound bridge sockets: auth, reconnect with backoff, token refresh, protocol v2
smartchats-mcp-bridgeA stdio MCP server that makes one coding agent a SmartChats participant
smartchats-hubA local daemon that holds one relay connection for every agent on the computer. See Hub
smartchats-relayThe older Node relay (single process, in-memory routing). Speaks protocol v1 only and has no hub support

The open clients default to wss://relay.kind.computer, which runs smartchats-relay-cf. The Node relay’s README does not mark it superseded, but the Cloudflare relay is what every default points at.

  browser (SmartChats)                         your computer
  --------------------                         ------------------------------------
  /client socket  ---WSS--->  relay  <---WSS---  /bridge socket
                              (one DO           |
                               per user)        +-- smartchats-mcp-bridge  <-stdio->  coding agent
                                                |   (kind: agent, one per agent)
                                                |
                                                +-- smartchats-hub  <-MCP/HTTP->  agents A, B, C
                                                |   (kind: hub, hosts every agent session)
                                                |
                                                +-- bin/pty-bridge.mjs  <-pty->  CLI in a terminal
                                                    (kind: pty)

A bridge is anything on your computer that dials /bridge. A client is a browser that dials /client, lists the sessions, and subscribes to one at a time. Every bridge is a session with a session_id, kind (pty, agent or hub), label and model.

Auth and pairing

There is no separate pairing step. Both sides present a Firebase ID token for your SmartChats account, and the relay routes only between sockets of the same account.

  1. The socket opens with ?token=<Firebase ID token>. The Worker verifies the RS256 signature, issuer, audience and expiry against Google’s public keys before the WebSocket upgrade completes. A missing or bad token gets a 401 and no socket.
  2. The Worker forwards the upgrade to the Durable Object named by the token’s uid. Every socket for one user lands on the same object.
  3. The first frame is bridge_hello (or client_hello). It registers the session and negotiates the protocol version.
  4. The relay checks the token’s expiry on every frame and closes an expired socket with 1008. Bridges refresh the token every 50 minutes with bridge_reauth. A reauth that fails closes the socket with 1008. A valid token for a different uid also closes it (identity changed).

Bridges get their token through smartchats-cloud-client, from the credentials file that smartchats login writes (~/.smartchats-mcp/credentials.json).

⚠️

Plaintext at the relay. Frames are encrypted in transit (TLS) but the relay can read them. There is no end-to-end encryption.

Run the MCP bridge

Use the bridge on a machine without a hub (a remote VM, CI). Build it from the repo, then add it to your agent’s MCP config:

cd packages/smartchats-mcp-bridge && npm run build
smartchats login   # once, for the relay token
{
  "mcpServers": {
    "smartchats-patch-through": {
      "command": "node",
      "args": ["/path/to/smartchats/packages/smartchats-mcp-bridge/dist/index.js",
               "--label", "claude-code @ my-mac"]
    }
  }
}
Flag / EnvDefaultNotes
--relay / SMARTCHATS_RELAYwss://relay.kind.computerRelay endpoint
--label / SMARTCHATS_AGENT_LABEL<process title> @ <hostname>Name in the SmartChats roster
--model / SMARTCHATS_AGENT_MODELunsetModel hint for the roster
--bridge-id / SMARTCHATS_BRIDGE_IDstored per labelDefault persists in ~/.smartchats-mcp/agent-bridge-<label>_id
SMARTCHATS_CLOUD_DEV_TOKENunsetSkip Firebase auth, for a local dev relay

Tools: send_message_to_user, wait_for_message, poll_messages, ask_user, respond_and_wait, list_peers, send_message_to_peer. They have the same names and result shapes as the hub’s tools. wait_for_message waits 30 s by default.

Limits

Relay limits are Worker variables in packages/smartchats-relay-cf/wrangler.jsonc.

VariableDefaultNotes
MAX_BRIDGES_PER_USER10Bridge sockets per account. Over the cap the upgrade gets a 429
MAX_CLIENTS_PER_USER20Browser sockets per account. 429 over the cap
MAX_SESSIONS_PER_BRIDGE50Agent sessions one hub socket may host
HELLO_TIMEOUT_MS5000Parsed but not used by the Cloudflare relay, which authenticates at upgrade
TOKEN_SWEEP_INTERVAL_MS60000Parsed but not used. Expiry is checked on every frame instead
DEV_TOKEN_BYPASSunsettrue accepts any token. Never set in production

Durable frames are buffered per session until acked: at most 500 frames and 24 h, oldest dropped first. A receiver remembers the last 1,000 ids to drop replayed repeats.

Count computers, not agents. With a hub on a relay that can host sessions, all of that computer’s agents ride one socket, so MAX_BRIDGES_PER_USER limits computers. Each standalone MCP bridge still takes a socket of its own.

Protocol

Protocol v2 wraps every data frame in an envelope:

{
  "v": 2,
  "id": "01JB8Z...",
  "type": "agent_event",
  "ts": 1790520000000,
  "session_id": "agent-bridge-...",
  "corr": "01JB8Y...",
  "body": { "event": "message", "text": "Done: tests pass.", "status": "complete", "timestamp": 1790520000000 }
}
FieldNotes
vAlways 2
idULID. Sortable, and the receiver’s dedupe key. The relay assigns one when a sender omits it
typeFrame type, up to 64 characters
tsMilliseconds since epoch
session_idOptional. The bridge session the frame is to or from
corrOptional. The id of the frame this one answers
bodyThe frame’s fields

Versioning. A peer offers v: 2 in its hello. The relay answers bridge_registered or session_list with v: 2 if it speaks v2. A reply without v means v1, and the peer falls back to flat frames ({ type, ...fields }). A hello without v is a v1 peer and gets flat frames with no ids, acks or replay. The type names are the same in both versions.

Control frames stay flat:

{ "v": 2, "type": "ack", "ids": ["01JB8Z..."] }
{ "v": 2, "type": "resume", "session_id": "...", "after": "01JB8Z..." }

A receiver acks what it handled (up to 500 ids a frame). On reconnect it sends resume with the last id it saw, and the relay replays newer buffered frames. If frames were lost to the buffer limits, the relay sends pending_dropped { count, overflow, expired } first.

Main message types

TypeDirectionNotes
bridge_hello / bridge_registeredbridge, relayHello carries kind, label, model, bridge id, v. The reply carries session_id, and mux: { max_sessions } when the relay can host a hub’s sessions
client_hello / session_listclient, relayThe roster of sessions. Hubs are listed in session_list.hubs only
list_sessionsclient to relayRefresh the roster without reconnecting
subscribe / subscribedclient to relayPick the session to talk to
agent_messageclient to agentYour message: { text, timestamp }. Durable
agent_eventagent to clients{ event, text?, artifacts?, status?, waiting_on?, ack?, timestamp }. Durable
peer_messagebridge to bridgeAgent to agent within one account. Answered with peer_ack or peer_error. Durable
list_peers / peer_listbridge, relayOther sessions of the same account
session_offlinerelay to clientsA session went away
bridge_reauth / client_reauthto relayToken refresh
hub_cmd / hub_result / hub_eventclient, hubCommands to a hub’s control session. Never buffered. A command to an offline hub gets hub_offline at once
session_open / session_opened / session_close / session_closed / session_errorhub, relayAgent sessions hosted on a hub’s socket
output, snapshot, input, resize, …pty bridge, clientTerminal stream frames. Best effort, not buffered

peer_error codes: BAD_PEER_MESSAGE, PEER_SELF, PEER_NOT_FOUND (also for another user’s session, so nothing leaks), PEER_OFFLINE. The relay sets from_session_id and from_label on a delivered peer message from the sender’s registration, so they can’t be spoofed.

Message status values on agent_event are ack, update, blocked, complete and failed. waiting_on is { "type": "user" } or { "type": "agent", "label": "..." } and comes only with blocked.

Connect your own bridge

import { connectRelay, loadOrCreateBridgeId } from 'smartchats-relay-connect';
import { resolveConfig } from 'smartchats-cloud-client';
 
const relay = connectRelay({
  relayUrl: 'wss://relay.kind.computer',
  config: resolveConfig(),
  bridgeId: await loadOrCreateBridgeId(),
  tag: 'agent',
  protocol: 2,
  hello: { kind: 'agent', label: 'my-tool @ my-mac', model: 'none' },
});
 
relay.on('registered', ({ session_id }) => console.log('session', session_id));
relay.on('message', (msg, meta) => { /* msg is { type, ...body }, meta is { id, ts, session_id?, corr? } */ });
relay.sendFrame('agent_event', { event: 'message', text: 'hi', timestamp: Date.now() });

Without protocol: 2 the helper speaks v1. It reconnects with backoff from 1 s to 30 s and refreshes the token every 50 minutes. Pass getToken to use your own token source.

See also