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.2.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.2.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. The wire path is one of two flavours, picked by the host and signalled in the instance manifest.

Default since @tesseron/vite@2.2.0 and @tesseron/mcp@2.4.0, and the reason the protocol went to 1.2.0. See tesseron#60.

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, and sets helloHandledByHost: true. That flag means "do not auto-dial." The host answers its own app's tesseron/hello locally with a synthesized welcome, so the user sees a claim code without any gateway involved yet.

When the user pastes the code into one specific Claude session, that gateway scans every host-mint manifest for a matching hostMintedClaim.code and dials only the matching instance, carrying the code in a bind step that the host validates in constant time before accepting. The user's paste deterministically picks the gateway, so there is no race and no "switch to the right Claude" detour.

The bind step is per-binding, because a Unix socket has no upgrade handshake to carry a subprotocol:

BindingMechanism
WebSocketSec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.<code> on the upgrade
Unix domain sockettesseron/bind { code } as the first NDJSON frame after connect

Either way the host validates before the session exists, and four rules hold for both:

  • Constant-time comparison. A short-circuiting compare leaks the code one character at a time to a process that can already reach the endpoint.
  • Sliding TTL. expiresAt is mintedAt + 10 minutes, refreshed every 5 minutes by rewriting the manifest. The heartbeat stops once the claim is spent. A gateway scanning for a code skips entries whose expiresAt has passed.
  • Rate limit. 5 mismatches within a 60-second rolling window trip a 60-second lockout. A successful bind resets the window.
  • One shot. boundAgent goes non-null on success and the claim is never re-bindable.

After a successful bind the host replays the app's cached hello to the gateway and swallows the gateway's id-matched reply, so the app keeps the welcome it already resolved and never sees a second one.

A gateway that dials a host-minted instance without binding is refused (426 Upgrade Required on WebSocket, -32600 InvalidRequest and a close on UDS). That gateway predates 1.2, and letting it through would deliver a second welcome against an already-resolved hello promise.

Porting a host? The bind handshake is optional. Leave helloHandledByHost unset and the gateway auto-dials and mints the code for you, which is the simpler implementation and stays supported. Advertising the flag without implementing bind is the one combination that breaks. See Port Tesseron to your language.

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.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/" } }

An SDK also refuses an unreadable welcome result or one from a different major version. It closes that transport and does not run later requests from the rejected session.

Next: the action model.