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
| Package | Role |
|---|---|
smartchats-relay-cf | The relay. A Cloudflare Worker with one Durable Object per user that holds every socket for that user |
smartchats-relay-protocol | Wire format shared by every party: envelope, message types, acks, resume, the hub client |
smartchats-relay-connect | Client helper for outbound bridge sockets: auth, reconnect with backoff, token refresh, protocol v2 |
smartchats-mcp-bridge | A stdio MCP server that makes one coding agent a SmartChats participant |
smartchats-hub | A local daemon that holds one relay connection for every agent on the computer. See Hub |
smartchats-relay | The 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.
- 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. - 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.
- The first frame is
bridge_hello(orclient_hello). It registers the session and negotiates the protocol version. - 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 / Env | Default | Notes |
|---|---|---|
--relay / SMARTCHATS_RELAY | wss://relay.kind.computer | Relay endpoint |
--label / SMARTCHATS_AGENT_LABEL | <process title> @ <hostname> | Name in the SmartChats roster |
--model / SMARTCHATS_AGENT_MODEL | unset | Model hint for the roster |
--bridge-id / SMARTCHATS_BRIDGE_ID | stored per label | Default persists in ~/.smartchats-mcp/agent-bridge-<label>_id |
SMARTCHATS_CLOUD_DEV_TOKEN | unset | Skip 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.
| Variable | Default | Notes |
|---|---|---|
MAX_BRIDGES_PER_USER | 10 | Bridge sockets per account. Over the cap the upgrade gets a 429 |
MAX_CLIENTS_PER_USER | 20 | Browser sockets per account. 429 over the cap |
MAX_SESSIONS_PER_BRIDGE | 50 | Agent sessions one hub socket may host |
HELLO_TIMEOUT_MS | 5000 | Parsed but not used by the Cloudflare relay, which authenticates at upgrade |
TOKEN_SWEEP_INTERVAL_MS | 60000 | Parsed but not used. Expiry is checked on every frame instead |
DEV_TOKEN_BYPASS | unset | true 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 }
}| Field | Notes |
|---|---|
v | Always 2 |
id | ULID. Sortable, and the receiver’s dedupe key. The relay assigns one when a sender omits it |
type | Frame type, up to 64 characters |
ts | Milliseconds since epoch |
session_id | Optional. The bridge session the frame is to or from |
corr | Optional. The id of the frame this one answers |
body | The 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
| Type | Direction | Notes |
|---|---|---|
bridge_hello / bridge_registered | bridge, relay | Hello 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_list | client, relay | The roster of sessions. Hubs are listed in session_list.hubs only |
list_sessions | client to relay | Refresh the roster without reconnecting |
subscribe / subscribed | client to relay | Pick the session to talk to |
agent_message | client to agent | Your message: { text, timestamp }. Durable |
agent_event | agent to clients | { event, text?, artifacts?, status?, waiting_on?, ack?, timestamp }. Durable |
peer_message | bridge to bridge | Agent to agent within one account. Answered with peer_ack or peer_error. Durable |
list_peers / peer_list | bridge, relay | Other sessions of the same account |
session_offline | relay to clients | A session went away |
bridge_reauth / client_reauth | to relay | Token refresh |
hub_cmd / hub_result / hub_event | client, hub | Commands 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_error | hub, relay | Agent sessions hosted on a hub’s socket |
output, snapshot, input, resize, … | pty bridge, client | Terminal 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.