Skip to content

Links and protocol / WebSocket protocol

WebSocket protocol

Every frame between your app and Relay, the rules for hello and calls, keep-alive, close codes and reconnecting.

The app holds one WebSocket to Relay per link. Frames are JSON text, one object per frame, each with a type. The protocol version is the integer protocol in hello; this page describes version 1.

⫻

socket URL

wss://api.meslzy.com/relay/v1/socket
NoteIn TypeScript or JavaScript, @meslzy/relay speaks this protocol for you, as a plain helper or as a transport for the official MCP SDK: see the Quickstart.

Lifecycle#

  1. Open the socket and send hello within 10 seconds, or the socket is closed.
  2. Relay checks the link token and the tools, stores the tool list on the link and answers ready.
  3. Each tool call arrives as call. Answer each with result or error. Calls can overlap, and you can answer them in any order.
  4. Keep the socket open. Relay pings it, and your WebSocket library answers on its own.
  5. When the socket closes, reconnect with backoff unless the close code says not to.

Frames the app sends#

hello#

⫻

hello

{  "type": "hello",  "protocol": 1,  "token": "lt_3Vd9xZr2Lm8Tn4Bw6Yc1Hs7Pf5Gj0A2U",  "app": {    "name": "Notes",    "version": "1.4.2"  },  "instructions": "Notes is the person's local notebook. Search it before answering questions about their notes.",  "tools": [    {      "name": "search_notes",      "description": "Full-text search of the person's notes",      "inputSchema": {        "type": "object",        "properties": {          "query": {            "type": "string"          }        },        "required": [          "query"        ]      },      "annotations": {        "readOnlyHint": true      }    }  ]}
type
stringrequired
The first frame on every socket.
Rules
hello
protocol
integerrequired
The protocol version. Any other value closes the socket with 4400.
Rules
1
token
stringrequired
The link token from link creation. An unknown or revoked token closes the socket with 4401.
Rules
lt_…
app.name
stringrequired
Your app's name, as the person knows it. The dashboard shows it on the link.
Rules
1–64 characters
app.version
stringrequired
Your app's version, shown on the link in the dashboard.
Rules
1–32 characters
instructions
stringoptional
Guidance for the model that spans tools, such as which tool to call first. Hosts read it from initialize.
Rules
At most 4,000 characters
tools
object[]required
The tools, in MCP's tool definition shape. See Tools.
Rules
1–64 tools with unique names

A hello that breaks any rule gets an `error` frame saying what is wrong, then the socket closes with 4400. To change the tools, close the socket and connect again with a new hello.

Tools#

Each tool is an MCP tool definition. Relay passes every field to hosts unchanged and never renames or prefixes a tool: the connector's name in the host already tells apps apart.

name
stringrequired
Unique within the hello. Verbs in snake case, such as search_notes, read best to models.
Rules
1–64 letters, digits, _ or -
title
stringoptional
A display name that some hosts show instead of name.
description
stringoptional
What the tool does. The model reads it to choose a tool, so say when to use it and when not to.
Rules
At most 2,000 characters
inputSchema
objectrequired
The arguments. Relay compiles it when the app connects and checks every call against it; a call that does not match never reaches the app. Relay supports JSON Schema draft-07 and 2020-12: a schema with no $schema is treated as 2020-12, and any other dialect is rejected at hello.
Rules
A JSON Schema with "type": "object"
outputSchema
objectoptional
Describes structuredContent in your results, for hosts that use it.
Rules
A JSON Schema with "type": "object"
annotations
objectoptional
Hints such as readOnlyHint, destructiveHint and idempotentHint. ChatGPT asks the person to confirm every call to a tool without readOnlyHint: true.
Rules
MCP tool annotations
NoteThe manifest (app, instructions and tools as JSON) must be at most 64 KiB. Hosts cache tool lists, so after you remove a tool a host may call it until it lists tools again; those calls answer -32602 and never reach the app.

result#

⫻

result

{  "type": "result",  "id": "c_9Fq2Lr7Vx4Tm1Nb8",  "content": [    {      "type": "text",      "text": "3 notes match: Q3 invoices, Invoice template, Unpaid invoices."    }  ],  "isError": false}
type
stringrequired
The answer to one call.
Rules
result
id
stringrequired
The id of the call it answers.
content
object[]required
What the model reads.
Rules
MCP content items: { "type": "text", "text" } or { "type": "image", "data", "mimeType" } with base64 data
structuredContent
objectoptional
Machine-readable output that matches the tool's outputSchema.
isError
booleanoptional
Set it when the tool ran and failed, so the model knows the content describes a failure.
Default
false

content and structuredContent together must be at most 128 KiB as JSON.

error#

⫻

error

{  "type": "error",  "id": "c_9Fq2Lr7Vx4Tm1Nb8",  "message": "database is locked"}

A short way to report a failed call. Relay returns it to the host as a result with isError: true and the message as its text, so write the message for the model to repeat to the person. The call counts like any other answer.

ping#

⫻

ping and pong

{"type":"ping"}{"type":"pong"}

Optional. Relay answers pong. Browser code cannot see WebSocket ping frames, so a web app that wants to notice a dead socket sends ping now and then and expects pong.

The person controls their own link from your app, with no account of their own. Send a command with an id of your choice; Relay answers with the same id.

{"type":"link.rotate","id":"r1"}{"type":"link.pause","id":"r2"}{"type":"link.resume","id":"r3"}{"type":"link.revoke","id":"r4"}
CommandEffect
link.rotateIssues a new MCP URL and answers link.rotated with it. The old URL answers 404 at once. Replace the stored URL and show the new one so the person can update the host. The link token stays the same.
link.pauseAnswers link.state with paused: true. Calls then answer with a tool error saying the link is paused, and never count.
link.resumeAnswers link.state with paused: false.
link.revokeAnswers link.revoked, then closes the socket with 4410. The link token and the MCP URL stop working for good.

The dashboard offers the same actions for every link of the project. A rotate there sends link.rotated with the new URL to a connected app, and a pause or resume sends link.state, both with id: null, so the app never holds a dead URL or a stale paused flag. Handle these frames whenever they arrive, not only after a command.

Frames Relay sends#

ready#

The link is live: hosts can reach the app from now on. ready does not carry the MCP URL; the app has it from link creation.

⫻

ready

{  "type": "ready",  "linkId": "0199c4e2-5b7a-7d31-8f2e-6a1c9b3d4e57",  "paused": false,  "limits": {    "callTimeoutSeconds": 180,    "resultMaxBytes": 131072  }}
FieldMeaning
linkIdThe link's id.
pausedWhether the link is paused. Show it next to a resume switch.
limits.callTimeoutSecondsHow long Relay waits for an answer to each call.
limits.resultMaxBytesThe largest content plus structuredContent a result may carry, in bytes.

call#

⫻

call

{  "type": "call",  "id": "c_9Fq2Lr7Vx4Tm1Nb8",  "tool": "search_notes",  "arguments": {    "query": "invoices"  },  "deadline": "2026-10-12T10:03:00.000Z"}
  • arguments already match the tool's inputSchema.
  • deadline is when Relay stops waiting, 180 seconds after it sent the call. An answer after the deadline is too late: the host has been told the app did not answer.
  • Calls can overlap. At most 8 calls per link are in flight at once; Relay refuses more before they reach the app.
  • Every call reaches the app before anything runs, so the app can show what is about to happen and ask the person first before a destructive tool.

cancel#

⫻

cancel

{  "type": "cancel",  "id": "c_9Fq2Lr7Vx4Tm1Nb8"}

The host cancelled the call, closed its connection, or the deadline passed. Stop the work if you can. No answer is needed, and an answer sent after cancel is not delivered.

error#

⫻

error

{  "type": "error",  "id": null,  "code": "invalid_message",  "message": "tools[2].inputSchema is not a valid JSON Schema"}

Relay answers a frame it cannot use with an error frame. id is the id of the frame it answers, or null.

CodeWhen
invalid_messageThe frame is not JSON, or matches no frame type.
invalid_resultA result or error breaks the content rules or the size limit. The call ends for the host with a tool error saying the app sent an answer Relay could not accept.
unknown_callA result or error names a call Relay is not waiting for: it already ended, was cancelled or passed its deadline.
rate_limitedA ping or link.* command arrived after 30 in the last minute on this link. Relay does not execute it, and the socket stays open.

Keep-alive#

  • Relay sends a WebSocket ping every 30 seconds. Every WebSocket library answers it with a pong on its own.
  • A socket that stays silent for 90 seconds is closed. Reconnect as below.
  • Pings, pongs, ping frames and an open socket with no calls are free.

The newest socket wins#

A link has one live socket. When a new socket sends hello with the same link token, Relay closes the older one with 4001 and sends calls to the new one. Calls still in flight on the old socket end with an error for the host and are taken off the month's count. An app that restarts can therefore reconnect at once, before Relay notices the old socket is dead.

Close codes#

CodeMeaningReconnect
4400The hello was invalid or late, its protocol is not supported, or the socket sent more than 10 invalid frames.With backoff, after fixing the hello. The same hello fails again.
4401The link token is unknown or revoked.No. Create a new link.
4403The socket's Origin is not on the project's allowed origins. Relay checks it after hello.No.
4001A newer socket connected with the same link token.With backoff. If it keeps happening, two copies of the app share one link.
4410The link was revoked from the dashboard or with link.revoke.No. Create a new link if the person wants to connect again.
4423The project is suspended.With backoff. The socket is accepted again once the project is reinstated.
4429The project has too many open sockets.With backoff.
1006The connection dropped with no close frame. Relay drops it when a frame is larger than 256 KiB; a network failure looks the same.With backoff, after making any oversized frame smaller.
otherThe network dropped, or Relay restarted, for example during a deploy.With backoff.

Reconnecting#

Reconnect on every close except 4401, 4403 and 4410, waiting 1 second at first and doubling up to 30 seconds, with random jitter. Reset the wait once ready arrives. A deploy of Relay closes every socket at once, and the jitter keeps apps from all reconnecting in the same second.

⫻

JavaScript

const FINAL_CODES = [4401, 4403, 4410];
let attempt = 0;
const connect = () => {  const socket = new WebSocket("wss://api.meslzy.com/relay/v1/socket");
  socket.onopen = () => sendHello(socket);
  socket.onmessage = (event) => {    const frame = JSON.parse(event.data);
    if (frame.type === "ready") {      attempt = 0;    }
    handle(socket, frame);  };
  socket.onclose = (event) => {    if (FINAL_CODES.includes(event.code)) {      return showLinkProblem(event.code);    }
    const seconds = Math.min(30, 1 * 2 ** attempt);
    attempt += 1;    setTimeout(connect, seconds * (0.5 + Math.random() / 2) * 1000);  };};
connect();
NoteCalls made while the app is reconnecting answer that it is not running, and cost nothing. See Limits and errors for every limit in one table.