Skip to content

Handshake & claiming

A Tesseron session goes through three states: connected, awaiting claim, claimed. Only claimed sessions can have their actions invoked.

From page load to first tool call. YOUR APP @tesseron/web MCP GATEWAY @tesseron/mcp USER AGENT Claude Code 1 tesseron/hello { app, actions, resources, caps } 2 tesseron/welcome { sessionId, claimCode: 'AB3X-7K' } 3 claim code (web UI or stdout) 4 connect AB3X-7K 5 tools/call tesseron__claim_session 6 notifications/tools/list_changed 7 tools/call shop__searchProducts 8 actions/invoke { invocationId, input } 9 result 10 tools/call result
From page load to first tool call.

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.id must match /^[a-z][a-z0-9_]*$/. It becomes the prefix on every MCP tool this app contributes.
  • app.origin is informational; the MCP gateway treats its own origin check (see Transport) as authoritative.
  • Action inputSchema / outputSchema are JSON Schema. The SDK derives them from your Standard Schema validator where possible, or you can pass them explicitly.
  • capabilities is what the app can do, not what the agent can do - that comes back in welcome.
{
"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"
}
}
  • sessionId is opaque and only meaningful to the gateway - log it for debugging.
  • capabilities here is the intersection of app and agent capabilities. If the agent doesn't support sampling, it will be false here even if you asked for it.
  • agent stays at { id: "pending", name: "Awaiting agent" } until a claim happens.
  • claimCode is a 6-character human-friendly string like AB3X-7K. Alphanumerics minus visually confusing characters.

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:

  1. The gateway prints it to stderr (which Claude Code surfaces).
  2. 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 agent on the session to the agent's identity.
  • Emits notifications/tools/list_changed so the agent refreshes its tool list.
  • Sends a tesseron/claimed notification 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.

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.

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).

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.

The MCP gateway parses protocolVersion as major.minor:

  • Different major → hard reject with error code -32000 ProtocolMismatch and 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.