@tesseron/react
@tesseron/react wraps @tesseron/web in three hooks. Registration becomes a declarative part of your component tree; unmount tears down cleanly.
No <Provider> is required - the hooks use the tesseron singleton from @tesseron/web by default. Pass an explicit client as the last argument if you need multiple clients in one tree.
Exports
Section titled “Exports”import { useTesseronAction, useTesseronResource, useTesseronConnection, // Option types UseTesseronActionOptions, UseTesseronResourceOptions, UseTesseronConnectionOptions, // State TesseronConnectionState,} from '@tesseron/react';The full @tesseron/web surface is re-exported too.
useTesseronConnection
Section titled “useTesseronConnection”Manages the WebSocket for the component's lifetime.
function App() { const { status, claimCode, welcome, error } = useTesseronConnection();
if (status === 'connecting') return <p>Connecting to Tesseron…</p>; if (status === 'error') return <p>Gateway unavailable: {error?.message}</p>; if (status === 'open') return <ClaimBanner code={claimCode!} />; return null;}State shape:
interface TesseronConnectionState { status: 'idle' | 'connecting' | 'open' | 'error' | 'closed'; welcome?: WelcomeResult; claimCode?: string; error?: Error; resumeStatus?: 'none' | 'resumed' | 'failed';}resumeStatus is set when status === 'open':
'none'- no resume was attempted (no stored creds, orresumedisabled).'resumed'-tesseron/resumesucceeded; the prior session was reattached.'failed'- resume was attempted but the gateway rejected it; the hook fell back to a freshtesseron/helloand persisted the new credentials. Useful for telemetry, and for UIs that want to show "your previous session expired" instead of silently switching to a new claim code.
claimCode clears automatically once the session has been claimed by an agent. The gateway sends a tesseron/claimed notification (see protocol/handshake) and the hook updates claimCode to undefined and merges the new agent identity into welcome.agent on the next render. Render the claim banner with connection.claimCode != null (rather than from a snapshot taken at mount time) and it will disappear on its own after the agent claims.
Options:
interface UseTesseronConnectionOptions { url?: string; // defaults to `<location.origin>/@tesseron/ws` (served by @tesseron/vite) enabled?: boolean; // gate the connect, e.g. only when logged in resume?: boolean | string | ResumeStorage;}Only one component should call useTesseronConnection per client - it owns the WebSocket. Most apps put it at the root.
Surviving page refresh / HMR with resume
Section titled “Surviving page refresh / HMR with resume”Since 2.9.0, resume defaults to true - the hook automatically persists { sessionId, resumeToken } in localStorage and sends tesseron/resume on the next page load instead of tesseron/hello. The agent stays paired across refreshes, HMR reloads, and brief network blips with no extra code:
const conn = useTesseronConnection(); // resume: true is the defaultThe hook handles the backing protocol details for you - token rotation, the ResumeFailed fallback to a fresh hello when the gateway zombie has expired (default TTL: 4 hours), and clearing stale credentials. Inspect conn.resumeStatus to tell whether the current session was resumed ('resumed'), is a fallback after a rejected resume ('failed'), or was a plain hello ('none'). See Session resume for the underlying primitives.
The resume option accepts four forms:
| Form | Behaviour |
|---|---|
true (default) | Persist in localStorage under 'tesseron:resume'. |
false | No persistence. Every connect is a fresh hello with a new claim code. Use for incognito-style flows. |
string | Persist in localStorage under that exact key. Use a per-app value if you mount multiple WebTesseronClient instances on one page. |
ResumeStorage | Custom { load, save, clear } callbacks (sync or async). Use this when localStorage is not available - Electron with strict CSP, an iframe partition, the OS keychain, etc. |
interface ResumeStorage { load: () => | ResumeCredentials | null | undefined | Promise<ResumeCredentials | null | undefined>; save: (credentials: ResumeCredentials) => void | Promise<void>; clear: () => void | Promise<void>;}Resume tokens are one-shot - the gateway rotates the token on every successful handshake (hello or resume), so the hook always overwrites the stored value with the freshest token. After a successful resume welcome.claimCode is undefined, since the session is already claimed.
Resume re-establishes the session, not its resources/subscribe bindings. useTesseronResource re-registers subscriptions naturally on remount, so apps using the provided hooks see no behavioural difference; if you wire subscriptions by hand against the lower-level client, re-subscribe after each connect.
Storage failures (private mode, quota exceeded, a throwing custom backend) are non-fatal: the hook treats them as a no-op for save/clear, and as "no saved session" for load. The connection itself is never failed by storage problems.
useTesseronAction
Section titled “useTesseronAction”Registers a typed action for the component's lifetime.
useTesseronAction('addTodo', { description: 'Add a new todo', input: z.object({ text: z.string().min(1) }), handler: ({ text }) => { const todo = { id: uuid(), text, done: false }; setTodos((prev) => [...prev, todo]); return todo; },});Options:
interface UseTesseronActionOptions<I, O> { description?: string; input?: StandardSchemaV1<I>; inputJsonSchema?: unknown; output?: StandardSchemaV1<O>; outputJsonSchema?: unknown; annotations?: ActionAnnotations; timeoutMs?: number; strictOutput?: boolean; handler: (input: I, ctx: ActionContext) => O | Promise<O>;}Notes:
- The handler is held via a ref internally, so calling state setters from inside works without stale closures.
- The action is registered on mount and unregistered on unmount. Be aware that agents cache tool lists - rapidly mounting/unmounting actions produces
tools/list_changedspam. - The hook returns nothing. The action is invoked by the agent, not by your component.
useTesseronResource
Section titled “useTesseronResource”Registers a resource for the component's lifetime. Two call shapes, same result.
// Short form - read-only resourceuseTesseronResource('todoStats', () => ({ total: todos.length, completed: todos.filter((t) => t.done).length,}));// Full form - with description + subscribeuseTesseronResource('filterState', { description: 'Current todo filter', read: () => ({ search, onlyDone }), subscribe: (emit) => { const onChange = () => emit({ search, onlyDone }); store.on('filter', onChange); return () => store.off('filter', onChange); },});Options:
interface UseTesseronResourceOptions<T> { description?: string; output?: StandardSchemaV1<T>; outputJsonSchema?: unknown; read?: () => T | Promise<T>; subscribe?: (emit: (value: T) => void) => () => void;}Conditional registration
Section titled “Conditional registration”useTesseronAction / useTesseronResource both run every render; they're no-ops when the connection isn't open. To register an action only for authenticated users, gate the hook by mounting / unmounting the component:
return ( <> {user && <ActionsForLoggedInUsers />} <GlobalActions /> </>);Don't try to conditionally call the hooks themselves - that breaks the Rules of Hooks.
Full component example
Section titled “Full component example”Pulled from examples/react-todo/src/app.tsx:
import { useTesseronAction, useTesseronConnection, useTesseronResource } from '@tesseron/react';import { z } from 'zod';import { useState } from 'react';
type Todo = { id: string; text: string; done: boolean };
export function TodoApp() { const [todos, setTodos] = useState<Todo[]>([]); const conn = useTesseronConnection();
useTesseronAction('addTodo', { description: 'Add a new todo item. Returns the created todo.', input: z.object({ text: z.string().min(1) }), handler: ({ text }) => { const todo = { id: crypto.randomUUID(), text, done: false }; setTodos((prev) => [...prev, todo]); return todo; }, });
useTesseronAction('toggleTodo', { input: z.object({ id: z.string() }), annotations: { destructive: true }, handler: ({ id }) => { setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)), ); return { id }; }, });
useTesseronResource('todoStats', () => ({ total: todos.length, completed: todos.filter((t) => t.done).length, }));
return ( <> {conn.status === 'open' && conn.claimCode && ( <ClaimBanner code={conn.claimCode} /> )} <TodoList todos={todos} /> </> );}