Handshake & claiming
A Tesseron session goes through three states: connected, awaiting claim, claimed. Only claimed sessions can have their actions invoked.
The tesseron/hello request
Section titled “The tesseron/hello request”Sent by the app right after the WebSocket opens.
{ "jsonrpc": "2.0", "id": 1, "method": "tesseron/hello", "params": { "protocolVersion": "1.1.0", "app": { "id": "shop", "name": "Acme Shop", "description": "Product catalog and cart", "origin": "http://localhost:3000", "version": "1.0.0", "iconUrl": "https://shop.example/icon.svg" }, "actions": [ { "name": "searchProducts", "description": "Search the product catalog", "inputSchema": { /* JSON Schema */ }, "outputSchema": { /* JSON Schema, optional */ }, "annotations": { "readOnly": true }, "timeoutMs": 60000 } ], "resources": [ { "name": "currentRoute", "description": "URL the user is viewing", "subscribable": true } ], "capabilities": { "streaming": true, "subscriptions": true, "sampling": true, "elicitation": true } }}Rules:
app.idmust match/^[a-z][a-z0-9_]*$/. It becomes the prefix on every MCP tool this app contributes.app.originis informational; the MCP gateway treats its own origin check (see Transport) as authoritative.- Action
inputSchema/outputSchemaare JSON Schema. The SDK derives them from your Standard Schema validator where possible, or you can pass them explicitly. capabilitiesis what the app can do, not what the agent can do - that comes back inwelcome.
The welcome response
Section titled “The welcome response”{ "jsonrpc": "2.0", "id": 1, "result": { "sessionId": "s_a1b2c3de1234567", "protocolVersion": "1.1.0", "capabilities": { "streaming": true, "subscriptions": true, "sampling": true, "elicitation": true }, "agent": { "id": "pending", "name": "Awaiting agent" }, "claimCode": "AB3X-7K" }}sessionIdis opaque and only meaningful to the gateway - log it for debugging.capabilitieshere is the intersection of app and agent capabilities. If the agent doesn't support sampling, it will befalsehere even if you asked for it.agentstays at{ id: "pending", name: "Awaiting agent" }until a claim happens.claimCodeis a 6-character human-friendly string likeAB3X-7K. Alphanumerics minus visually confusing characters.
Claiming
Section titled “Claiming”The claim code is not sent on the wire to the agent. It's displayed in two places, for the human to transfer out-of-band:
- The gateway prints it to stderr (which Claude Code surfaces).
- The app is free to render it in its UI - e.g. a "Connect Claude" button that reveals the code.
The user then tells the agent:
Claim Tesseron session AB3X-7K
The agent calls the built-in tesseron__claim_session MCP tool with { code: "AB3X-7K" }. The gateway looks up the pending claim, and if it matches:
- Marks the session
claimed: true. - Sets
agenton the session to the agent's identity. - Emits
notifications/tools/list_changedso the agent refreshes its tool list. - Sends a
tesseron/claimednotification to the SDK (see below) so the app can clear the spent claim code from its UI. - From this point,
tools/call <app_id>__<action>is allowed.
If the code doesn't match (expired, wrong app, already used): error -32009 Unauthorized.
The tesseron/claimed notification
Section titled “The tesseron/claimed notification”Once a session is claimed, the previously-issued claimCode is consumed and no longer redeemable. The gateway notifies the SDK so the app can update any UI that displays the code (otherwise users keep trying to type a dead string into the agent).
{ "jsonrpc": "2.0", "method": "tesseron/claimed", "params": { "agent": { "id": "claude-code", "name": "Claude Code" }, "claimedAt": 1714145210123 }}@tesseron/web and @tesseron/core handle this internally: the cached WelcomeResult is patched in place (agent updated, claimCode cleared) and any listener registered via client.onWelcomeChange(...) fires. @tesseron/react's useTesseronConnection clears connection.claimCode and updates connection.welcome.agent on the next render. Apps wiring the lower-level client directly should subscribe with client.onWelcomeChange(...) to drive their own UI updates.
The notification only fires on a fresh-hello path. After a successful tesseron/resume the welcome carries no claimCode to begin with, so no further notification is needed.
Why out-of-band claim?
Section titled “Why out-of-band claim?”Because in-band claim is just security theatre over localhost. Anything running on the user's machine can read ~/.tesseron/instances/ and dial one of those endpoints. The claim code is a user-typed confirmation - proof that a human authorised this specific app to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).
Multiple gateways on one machine
Section titled “Multiple gateways on one machine”A developer machine may have several Tesseron MCP gateways alive at once - typically one per running Claude Code session, sometimes more if a dev checkout's plugin/server/index.cjs is also loaded. The wire path is one of two flavours, picked at host build time and signalled in the instance manifest.
Claim-mediated dial (default since @tesseron/vite@2.2.0, @tesseron/mcp@2.4.0). The host (Vite plugin / @tesseron/server) mints the claim code, session id, and resume token at instance creation, writes them into the manifest's hostMintedClaim field, and sets helloHandledByHost: true. The gateway treats these as the signal "do not auto-dial." When the user pastes the code into one specific Claude session, that gateway scans every host-mint manifest for a matching hostMintedClaim.code, dials only the matching instance with the Sec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.<code> upgrade header, and the host validates the bind in constant time before accepting. No race, no "switch to the right Claude" detour: the user's paste deterministically picks the gateway. See tesseron#60.
Legacy auto-dial. Gateways before 2.4.0 (and hosts before 2.2.0) take the original path: each gateway watches ~/.tesseron/instances/ and dials the bindings it discovers. The first gateway to upgrade a given browser instance owns the bridge for that session (the Vite plugin rejects subsequent upgrades with HTTP 409); the welcome+claim code returns through that one gateway.
In the legacy flow, sibling gateways see the user-typed claim code but have no matching pending session locally. Without a hint they would fail with a flat "no pending session", and the user has no way to tell which Claude window minted the code. To make the failure explicit, every gateway in the legacy path drops a breadcrumb at ~/.tesseron/claims/<CODE>.json when it mints a claim code:
{ "version": 1, "code": "AB3X-7K", "sessionId": "s_a1b2c3de1234567", "appId": "shop", "appName": "Acme Shop", "gatewayPid": 12345, "mintedAt": 1714145210123}A non-owning gateway that receives tesseron__claim_session for a code it doesn't own locally reads the breadcrumb and surfaces an error of the form "Claim code AB3X-7K belongs to a different Tesseron gateway (pid 12345, app 'Acme Shop', minted 2026-04-26T15:16:09Z). Switch to the Claude session that opened this connection...". The breadcrumb is removed when the owning gateway claims the session, when an unclaimed session closes, and on gateway shutdown; if the breadcrumb's gatewayPid is no longer running, the file is tombstoned and a "stale" error is returned instead.
This is a UX hint, not a transfer protocol - claim ownership stays with the gateway that minted the code. To actually claim, the user has to be in the right Claude session.
Protocol version mismatch
Section titled “Protocol version mismatch”The MCP gateway parses protocolVersion as major.minor:
- Different major → hard reject with error code
-32000 ProtocolMismatchand the WebSocket is closed. - Different minor → accepted, with a warning logged to gateway stderr. New fields added in later minors may be silently dropped; rebuild the SDK bundle to resync.
- Exact match → no logging.
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32000, "message": "Gateway speaks protocol 1.1.0; SDK sent 2.0.0. Major version mismatch - pin compatible package versions." } }Next: the action model.