Skip to content

Session resume

A Tesseron session lives somewhere — the gateway's memory for gateway-minted sessions, the host's memory for host-minted sessions (e.g. behind @tesseron/vite). When the underlying WebSocket drops (tab refresh, window close, network blip, HMR reload), the session would normally go away and a reconnecting app would have to go through the full tesseron/hello + claim-code dance again — even if the user had already paired it.

The tesseron/resume method lets the app rejoin an existing session it paired earlier, skipping the re-claim. Storage of the resume credentials is deliberately the implementer's responsibility: different apps have different opinions about where session credentials can live (localStorage, a cookie, an Electron store, the OS keychain), so the SDK exposes the primitive and leaves the choice to you.

Both mint flows (gateway-minted and host-minted) honour resume:

  • Gateway-minted sessions (Node-side hosts via @tesseron/server): the gateway holds a zombie of the closed session for resumeTtlMs and reattaches the new socket on a matching { sessionId, resumeToken }.
  • Host-minted sessions (browser tabs via @tesseron/vite): the host holds the Session in memory for sessionIdleTtlMs and reattaches the new browser WebSocket on a matching { sessionId, resumeToken }. The gateway-side bridge stays open across the detach — the agent sees no disconnect.
  1. On a fresh tesseron/hello, the gateway returns a resumeToken in the welcome. Stash it alongside the sessionId wherever fits your app.
  2. When the transport drops, the gateway keeps the session's metadata as a "zombie" for resumeTtlMs (default 4 hours, configurable via env var TESSERON_RESUME_TTL_MS or per-gateway new TesseronGateway({ resumeTtlMs })).
  3. On reconnect, the app sends tesseron/resume with { sessionId, resumeToken }. If the token matches (constant-time compare) and the zombie is still within its TTL, the gateway reattaches the fresh socket to the existing session and rotates the token.
  4. The caller persists the new resumeToken from the resume response.

Resume tokens are one-shot: every successful resume rotates the token and the previous value stops working. This means the freshest welcome is always the one to persist.

{
"jsonrpc": "2.0",
"id": 1,
"method": "tesseron/resume",
"params": {
"protocolVersion": "1.1.0",
"sessionId": "s_a1b2c3de1234567",
"resumeToken": "Xk9f3nN9kOeGqR7mWpLc2v",
"app": { "id": "shop", "name": "Acme Shop", "origin": "http://localhost:3000" },
"actions": [ /* same shape as tesseron/hello */ ],
"resources": [ /* same shape as tesseron/hello */ ],
"capabilities": {
"streaming": true,
"subscriptions": true,
"sampling": true,
"elicitation": true
}
}
}

ResumeParams carries the same app / actions / resources / capabilities as HelloParams because a fresh app build may have added, removed, or changed them since the previous connect. The gateway replaces the stored manifest with what resume brings in.

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"sessionId": "s_a1b2c3de1234567",
"protocolVersion": "1.1.0",
"capabilities": { "streaming": true, "subscriptions": true, "sampling": true, "elicitation": true },
"agent": { "id": "claude-ai", "name": "Claude Desktop" },
"resumeToken": "NEW_ROTATED_TOKEN_VALUE"
}
}
  • sessionId matches the one in the request (same session, reattached to the fresh socket).
  • resumeToken is rotated. Overwrite whatever you stashed with this new value.
  • claimCode is omitted - the session is already claimed, no need for a re-pair.

All resume failures surface as a TesseronError with code TesseronErrorCode.ResumeFailed (-32011). Callers typically catch the error and fall back to a plain tesseron/hello.

ConditionMessage pattern
Unknown sessionIdNo resumable session "<id>"
Cross-app resumeSession "<id>" is owned by app "<other>"
Unclaimed zombie<id> was never claimed
TTL elapsedFalls under "no resumable session" - the zombie was already evicted
Wrong resumeTokenInvalid resumeToken for session "<id>"
Malformed params (missing app, non-string sessionId / resumeToken, missing actions / resources / capabilities)Invalid tesseron/resume request: expected { protocolVersion, sessionId, resumeToken, app, actions, resources, capabilities }
Protocol major-version mismatchSame rules as tesseron/hello - throws ProtocolMismatch

Token comparison uses crypto.timingSafeEqual with a length pre-check, so a wildly-wrong token doesn't leak timing information about the correct length.

Two knobs on new TesseronGateway({ ... }) shape how aggressive resume is:

const gateway = new TesseronGateway({
resumeTtlMs: 300_000, // 5 minutes (default: 14_400_000 / 4 hours)
maxZombies: 500, // cap on zombies held simultaneously (default: 100)
});
  • resumeTtlMs — how long a closed session is retained as a resumable zombie. Default 4 hours; long enough to span a normal working session (casual refreshes, dev-server restarts, lunch breaks, brief laptop sleep) without forcing the user back through the claim-code dance. Set to 0 to disable resume entirely: closed sessions drop immediately and any reconnect must start fresh. The @tesseron/mcp CLI also reads the TESSERON_RESUME_TTL_MS env var (non-negative integer milliseconds) so operators can tune it without forking the gateway.
  • maxZombies — ceiling on the in-memory zombie map. When inserting a new zombie would exceed it, the oldest (longest-retained) zombie is evicted to make room. Keeps a connect/disconnect flood from piling up zombies faster than their TTLs expire. Set to 0 to disable resume entirely (same effect as resumeTtlMs: 0).

@tesseron/web auto-persists by default. From 2.9.0, tesseron.connect() loads stored credentials, sends tesseron/resume, saves the rotated token, and transparently falls back to a fresh tesseron/hello if the resume fails — no glue code required:

import { tesseron } from '@tesseron/web';
tesseron.app({ id: 'shop', name: 'Acme Shop' });
tesseron.action('searchProducts').handler(/* ... */);
const welcome = await tesseron.connect();
// Refresh the page → next connect resumes the same session,
// agent stays paired, no new claim code.

Refresh costs nothing inside the TTL window. The default backend is localStorage under the key tesseron:resume. To use a different key, pass a string. To opt out of persistence (incognito-style flows), pass resume: false. To run a custom backend (OS keychain, Electron store, IPC channel), implement ResumeStorage:

import { tesseron, type ResumeStorage } from '@tesseron/web';
const keychainBackend: ResumeStorage = {
load: () => ipc.invoke('tesseron:load'),
save: (creds) => ipc.invoke('tesseron:save', creds),
clear: () => ipc.invoke('tesseron:clear'),
};
await tesseron.connect(undefined, { resume: keychainBackend });

If you've already loaded credentials yourself and just want to forward them, pass a ResumeCredentials literal — the SDK uses it as-is and does not auto-persist (that's your job):

await tesseron.connect(undefined, {
resume: { sessionId, resumeToken }, // explicit; SDK won't write to localStorage
});

If you're using @tesseron/react, the useTesseronConnection hook bakes in the same flow and exposes resumeStatus ('none' | 'resumed' | 'failed') for UIs that want to show "your previous session expired" instead of silently rendering a new claim code.

try {
await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);
} catch (err) {
if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {
localStorage.removeItem('tesseron:shop');
await tesseron.connect(url); // fresh hello
} else {
throw err;
}
}
  • It does not replay in-flight actions. An action the agent invoked just before the socket dropped is cancelled on the gateway; the agent sees an error (see lifecycle) and can retry at its own layer.
  • It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.
  • It does not persist across a gateway restart. Zombies live in gateway process memory; stopping the gateway evicts them. A fresh tesseron/hello is required after any gateway restart.
  • It does not work for sessions that were never claimed. The gateway surfaces ResumeFailed with never claimed so the SDK can fall back to tesseron/hello without ambiguity.