Skip to content

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

import {
useTesseronAction,
useTesseronResource,
useTesseronConnection,
// Option types
UseTesseronActionOptions,
UseTesseronResourceOptions,
UseTesseronConnectionOptions,
// State
TesseronConnectionState,
} from '@tesseron/react';

The full @tesseron/web surface is re-exported too.

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, or resume disabled).
  • 'resumed' - tesseron/resume succeeded; the prior session was reattached.
  • 'failed' - resume was attempted but the gateway rejected it; the hook fell back to a fresh tesseron/hello and 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.

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 default

The 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:

FormBehaviour
true (default)Persist in localStorage under 'tesseron:resume'.
falseNo persistence. Every connect is a fresh hello with a new claim code. Use for incognito-style flows.
stringPersist in localStorage under that exact key. Use a per-app value if you mount multiple WebTesseronClient instances on one page.
ResumeStorageCustom { 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.

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_changed spam.
  • The hook returns nothing. The action is invoked by the agent, not by your component.

Registers a resource for the component's lifetime. Two call shapes, same result.

// Short form - read-only resource
useTesseronResource('todoStats', () => ({
total: todos.length,
completed: todos.filter((t) => t.done).length,
}));
// Full form - with description + subscribe
useTesseronResource('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;
}

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.

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} />
</>
);
}