@tesseron/docs-mcp 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":"4343b50","generatedAt":"2026-05-10T11:07:21.126Z","count":42,"docs":[{"slug":"examples/express-prompts","title":"express-prompts","description":"REST API + Tesseron on the same Node process, backed by the same state. Sampling-heavy prompt-library domain.","section":"examples","related":["sdk/typescript/server","examples/node-prompts"],"bodyRaw":"\n**What it teaches:** how to expose the same backend operations via two channels at once - HTTP for human or programmatic clients, Tesseron for the agent (with `ctx.sample` and `ctx.elicit` layered on top). Both channels mutate the same state and fire the same resource subscribers.\n\n**Source:** [`examples/express-prompts`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/express-prompts)\n\n## Run it\n\n```bash\npnpm --filter express-prompts dev\n# REST on http://localhost:3001\n# @tesseron/server binds its WS endpoint on a random loopback port and writes\n# ~/.tesseron/instances/<instanceId>.json; the gateway dials it in. No port to configure.\n```\n\n## Domain\n\nA prompt library. REST clients (curl, internal dashboards) can CRUD prompts over `GET/POST/PATCH/DELETE /prompts`. Claude sees the same library via Tesseron, plus four actions that don't exist on the REST side because they depend on the agent's own LLM:\n\n- `testPrompt` - run a prompt through `ctx.sample`, store the response.\n- `refinePrompt` - elicit a refinement instruction, rewrite via `ctx.sample`.\n- `generateVariants` - ask the LLM for N alternative phrasings, stream progress as they land.\n- `purgeAll` - wipe everything; demands a typed `DELETE` confirmation via `ctx.elicit`.\n\nA resource subscription to `tesseron://prompt_lab/library` updates whether the mutation came from REST or from Claude.\n\n## Pattern: shared state, two interfaces\n\n```ts title=\"src/index.ts (excerpt)\"\nimport express from 'express';\nimport { tesseron } from '@tesseron/server';\nimport { z } from 'zod';\n\nconst prompts = new Map<string, Prompt>();\nconst librarySubs = new Set<(v: Prompt[]) => void>();\nfunction notifyLibrary() {\n const v = Array.from(prompts.values());\n librarySubs.forEach((fn) => fn(v));\n}\n\n// --- REST ---\nconst app = express();\napp.post('/prompts', (req, res) => {\n const p = { id: newId(), name: req.body.name, template: req.body.template, /* ... */ };\n prompts.set(p.id, p);\n notifyLibrary(); // <-- also fires Tesseron subscribers\n res.status(201).json(p);\n});\n// GET /prompts, PATCH /prompts/:id, DELETE /prompts/:id, GET /last-test ...\n\n// --- Tesseron ---\ntesseron.app({ id: 'prompt_lab', name: 'Prompt Lab (Express)' });\n\ntesseron.action('testPrompt')\n .input(z.object({\n id: z.string(),\n variables: z.record(z.string(), z.string()).optional(),\n }))\n .handler(async ({ id, variables }, ctx) => {\n const prompt = prompts.get(id)!;\n const response = await ctx.sample({\n prompt: applyTemplate(prompt.template, variables ?? {}),\n });\n // store response, bump timesTested, notifyLibrary(), notifyLastTest()\n return { id, response };\n });\n\ntesseron.resource<Prompt[]>('library')\n .read(() => Array.from(prompts.values()))\n .subscribe((emit) => { librarySubs.add(emit); return () => librarySubs.delete(emit); });\n\n// start both\napp.listen(3001);\nconst welcome = await tesseron.connect();\nconsole.log('Tesseron claim code:', welcome.claimCode);\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`deletePrompt`), `ctx.elicit` with schema (`refinePrompt`, `purgeAll`), `ctx.progress` (`importPrompts`, `generateVariants`), `ctx.sample` free-text (`testPrompt`, `refinePrompt`) and `ctx.sample` with Zod schema (`generateVariants`), cancellation via `ctx.signal`, capability gating via `ctx.agentCapabilities.sampling`, coexistence with an HTTP server in one process, unified notification layer that keeps Tesseron subscribers in sync with REST writes**.\n\n## When this pattern fits\n\n- You already have a backend and want Claude to drive it without duplicating business logic.\n- You want a single source of truth (the `Map`, in this example - a database, in real life).\n- You want the two channels to stay out of each other's way - no HTTP calls pretending to be agent calls, no awkward \"AI mode\" in your REST routes.\n- The agent's own LLM is part of the workflow for at least some operations (`ctx.sample`), and you'd rather not burn an extra API key on the server side.\n","bodyText":"**What it teaches:** how to expose the same backend operations via two channels at once - HTTP for human or programmatic clients, Tesseron for the agent (with `ctx.sample` and `ctx.elicit` layered on top). Both channels mutate the same state and fire the same resource subscribers.\n\n**Source:** [`examples/express-prompts`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/express-prompts)\n\n## Run it\n\n```bash\npnpm --filter express-prompts dev\n# REST on http://localhost:3001\n# @tesseron/server binds its WS endpoint on a random loopback port and writes\n# ~/.tesseron/instances/<instanceId>.json; the gateway dials it in. No port to configure.\n```\n\n## Domain\n\nA prompt library. REST clients (curl, internal dashboards) can CRUD prompts over `GET/POST/PATCH/DELETE /prompts`. Claude sees the same library via Tesseron, plus four actions that don't exist on the REST side because they depend on the agent's own LLM:\n\n- `testPrompt` - run a prompt through `ctx.sample`, store the response.\n- `refinePrompt` - elicit a refinement instruction, rewrite via `ctx.sample`.\n- `generateVariants` - ask the LLM for N alternative phrasings, stream progress as they land.\n- `purgeAll` - wipe everything; demands a typed `DELETE` confirmation via `ctx.elicit`.\n\nA resource subscription to `tesseron://prompt_lab/library` updates whether the mutation came from REST or from Claude.\n\n## Pattern: shared state, two interfaces\n\n```ts title=\"src/index.ts (excerpt)\"\n\nconst prompts = new Map<string, Prompt>();\nconst librarySubs = new Set<(v: Prompt[]) => void>();\nfunction notifyLibrary() {\n const v = Array.from(prompts.values());\n librarySubs.forEach((fn) => fn(v));\n}\n\n// --- REST ---\nconst app = express();\napp.post('/prompts', (req, res) => {\n const p = { id: newId(), name: req.body.name, template: req.body.template, /* ... */ };\n prompts.set(p.id, p);\n notifyLibrary(); // <-- also fires Tesseron subscribers\n res.status(201).json(p);\n});\n// GET /prompts, PATCH /prompts/:id, DELETE /prompts/:id, GET /last-test ...\n\n// --- Tesseron ---\ntesseron.app({ id: 'prompt_lab', name: 'Prompt Lab (Express)' });\n\ntesseron.action('testPrompt')\n .input(z.object({\n id: z.string(),\n variables: z.record(z.string(), z.string()).optional(),\n }))\n .handler(async ({ id, variables }, ctx) => {\n const prompt = prompts.get(id)!;\n const response = await ctx.sample({\n prompt: applyTemplate(prompt.template, variables ?? {}),\n });\n // store response, bump timesTested, notifyLibrary(), notifyLastTest()\n return { id, response };\n });\n\ntesseron.resource<Prompt[]>('library')\n .read(() => Array.from(prompts.values()))\n .subscribe((emit) => { librarySubs.add(emit); return () => librarySubs.delete(emit); });\n\n// start both\napp.listen(3001);\nconst welcome = await tesseron.connect();\nconsole.log('Tesseron claim code:', welcome.claimCode);\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`deletePrompt`), `ctx.elicit` with schema (`refinePrompt`, `purgeAll`), `ctx.progress` (`importPrompts`, `generateVariants`), `ctx.sample` free-text (`testPrompt`, `refinePrompt`) and `ctx.sample` with Zod schema (`generateVariants`), cancellation via `ctx.signal`, capability gating via `ctx.agentCapabilities.sampling`, coexistence with an HTTP server in one process, unified notification layer that keeps Tesseron subscribers in sync with REST writes**.\n\n## When this pattern fits\n\n- You already have a backend and want Claude to drive it without duplicating business logic.\n- You want a single source of truth (the `Map`, in this example - a database, in real life).\n- You want the two channels to stay out of each other's way - no HTTP calls pretending to be agent calls, no awkward \"AI mode\" in your REST routes.\n- The agent's own LLM is part of the workflow for at least some operations (`ctx.sample`), and you'd rather not burn an extra API key on the server side."},{"slug":"examples/index","title":"All examples","description":"Six runnable apps across two domains that together cover every framework adapter and every major feature of the SDK.","section":"examples","related":["overview/quickstart","sdk/typescript/index"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\n\nAll six examples live in [`examples/`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples). Each is complete, runnable, and intentionally simple so the Tesseron-specific code is easy to read. Client-side examples share a **todo** domain; server-side examples share a sampling-heavy **prompt-library** domain.\n\n<CardGrid>\n <LinkCard title=\"vanilla-todo\" href=\"./vanilla-todo/\"\n description=\"Zero-framework baseline. Start here.\" />\n <LinkCard title=\"node-prompts\" href=\"./node-prompts/\"\n description=\"Headless Node prompt library. No browser.\" />\n <LinkCard title=\"express-prompts\" href=\"./express-prompts/\"\n description=\"HTTP REST + MCP on the same Node process.\" />\n <LinkCard title=\"react-todo\" href=\"./react-todo/\"\n description=\"React 18 + `@tesseron/react` hooks.\" />\n <LinkCard title=\"svelte-todo\" href=\"./svelte-todo/\"\n description=\"Svelte 5 runes (`$state`, `$derived`).\" />\n <LinkCard title=\"vue-todo\" href=\"./vue-todo/\"\n description=\"Vue 3 composition API.\" />\n</CardGrid>\n\n## Feature matrix\n\n| Feature | vanilla | node | express | react | svelte | vue |\n|---|:-:|:-:|:-:|:-:|:-:|:-:|\n| Basic actions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Subscribable resources | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Annotations (`destructive`, `requiresConfirmation`, `readOnly`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Zod input validation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.confirm` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.elicit` with schema | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.progress` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.sample` | ✅ | ✅ (first-class) | ✅ (first-class) | ✅ | ✅ | ✅ |\n| Framework hooks | ✗ | - | ✗ | ✅ | ✗ | ✗ |\n| Runs in the browser | ✅ | ✗ | ✗ | ✅ | ✅ | ✅ |\n| Runs in Node | ✗ | ✅ | ✅ | ✗ | ✗ | ✗ |\n| Parallel REST API | ✗ | ✗ | ✅ | ✗ | ✗ | ✗ |\n\n## Recommended reading order\n\n1. **[vanilla-todo](/examples/vanilla-todo/)** - plain DOM, no framework. The SDK's builder API with nothing in the way.\n2. **[node-prompts](/examples/node-prompts/)** - a headless prompt library on Node. Shows the same builder API on the server, with `ctx.sample` and `ctx.elicit` as the center of the domain.\n3. **[express-prompts](/examples/express-prompts/)** - the same prompt library plus a REST API. Demonstrates \"same state, two channels\": HTTP writes fire Tesseron resource notifications and vice versa.\n4. **[react-todo](/examples/react-todo/)** - hooks-based integration.\n5. **[svelte-todo](/examples/svelte-todo/)** - mutation via `$state` runes.\n6. **[vue-todo](/examples/vue-todo/)** - mutation via `ref().value` / `computed()`.\n\n## Running any of them\n\n```bash\ngit clone https://github.com/BrainBlend-AI/tesseron\ncd tesseron\npnpm install\npnpm --filter <example-name> dev\n```\n\nThen claim the session from your agent - see the [quickstart](/overview/quickstart/) if you haven't already.\n","bodyText":"All six examples live in [`examples/`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples). Each is complete, runnable, and intentionally simple so the Tesseron-specific code is easy to read. Client-side examples share a **todo** domain; server-side examples share a sampling-heavy **prompt-library** domain.\n\n## Feature matrix\n\n| Feature | vanilla | node | express | react | svelte | vue |\n|---|:-:|:-:|:-:|:-:|:-:|:-:|\n| Basic actions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Subscribable resources | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Annotations (`destructive`, `requiresConfirmation`, `readOnly`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Zod input validation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.confirm` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.elicit` with schema | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.progress` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.sample` | ✅ | ✅ (first-class) | ✅ (first-class) | ✅ | ✅ | ✅ |\n| Framework hooks | ✗ | - | ✗ | ✅ | ✗ | ✗ |\n| Runs in the browser | ✅ | ✗ | ✗ | ✅ | ✅ | ✅ |\n| Runs in Node | ✗ | ✅ | ✅ | ✗ | ✗ | ✗ |\n| Parallel REST API | ✗ | ✗ | ✅ | ✗ | ✗ | ✗ |\n\n## Recommended reading order\n\n1. **[vanilla-todo](/examples/vanilla-todo/)** - plain DOM, no framework. The SDK's builder API with nothing in the way.\n2. **[node-prompts](/examples/node-prompts/)** - a headless prompt library on Node. Shows the same builder API on the server, with `ctx.sample` and `ctx.elicit` as the center of the domain.\n3. **[express-prompts](/examples/express-prompts/)** - the same prompt library plus a REST API. Demonstrates \"same state, two channels\": HTTP writes fire Tesseron resource notifications and vice versa.\n4. **[react-todo](/examples/react-todo/)** - hooks-based integration.\n5. **[svelte-todo](/examples/svelte-todo/)** - mutation via `$state` runes.\n6. **[vue-todo](/examples/vue-todo/)** - mutation via `ref().value` / `computed()`.\n\n## Running any of them\n\n```bash\ngit clone https://github.com/BrainBlend-AI/tesseron\ncd tesseron\npnpm install\npnpm --filter <example-name> dev\n```\n\nThen claim the session from your agent - see the [quickstart](/overview/quickstart/) if you haven't already."},{"slug":"examples/node-prompts","title":"node-prompts","description":"Headless Node prompt library - no HTTP, no browser. Shows sampling and elicitation as first-class domain features.","section":"examples","related":["sdk/typescript/server"],"bodyRaw":"\n**What it teaches:** a pure-Node Tesseron integration whose domain revolves around `ctx.sample` and `ctx.elicit`. No Express, no HTTP server - just a Node script that registers actions and connects. Good when you're building a CLI, a daemon, or a worker that Claude should drive, and the agent's LLM is part of the workflow.\n\n**Source:** [`examples/node-prompts`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/node-prompts)\n\n## Run it\n\n```bash\npnpm --filter node-prompts dev\n# prints the claim code to stdout; no browser\n```\n\n## Domain\n\nA library of reusable LLM prompts. Claude can:\n\n- `addPrompt`, `listPrompts`, `deletePrompt`, `importPrompts`, `purgeAll` - CRUD over the library.\n- `testPrompt` - fill `{{var}}` placeholders, send the prompt through `ctx.sample`, store the response as `lastTest`.\n- `refinePrompt` - elicit a free-text refinement instruction from the user via `ctx.elicit`, then ask the agent LLM via `ctx.sample` to rewrite the template in place.\n- `generateVariants` - ask the agent LLM for N alternative phrasings of a prompt, stream progress as each lands, store them as new prompts.\n\nTwo subscribable resources push updates on every mutation: `library` (`Prompt[]`) and `lastTest` (`TestResult | null`).\n\n## What's inside\n\n```ts title=\"src/index.ts (excerpt)\"\nimport { tesseron } from '@tesseron/server';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'prompt_lab', name: 'Prompt Lab' });\n\ntesseron.action('testPrompt')\n .input(z.object({\n id: z.string(),\n variables: z.record(z.string(), z.string()).optional(),\n }))\n .handler(async ({ id, variables }, ctx) => {\n const prompt = prompts.get(id)!;\n if (!ctx.agentCapabilities.sampling) {\n throw new Error('Agent does not support sampling.');\n }\n const filled = applyTemplate(prompt.template, variables ?? {});\n const response = await ctx.sample({ prompt: filled, maxTokens: 512 });\n // store response as lastTest, bump timesTested, notify subscribers\n return { id, response };\n });\n\ntesseron.action('refinePrompt')\n .input(z.object({ id: z.string() }))\n .handler(async ({ id }, ctx) => {\n const answer = await ctx.elicit({\n question: `What should change?`,\n schema: z.object({ instruction: z.string().min(1) }),\n jsonSchema: { /* ... */ },\n });\n if (answer === null) return { id, refined: false, cancelled: true };\n const rewritten = await ctx.sample({\n prompt: `Rewrite this prompt per instruction: ${answer.instruction}\\n\\n${prompt.template}`,\n });\n // replace template with rewritten.trim(), notify subscribers\n });\n\ntesseron.resource<Prompt[]>('library')\n .read(() => Array.from(prompts.values()))\n .subscribe((emit) => { librarySubs.add(emit); return () => librarySubs.delete(emit); });\n\nconst welcome = await tesseron.connect();\nlog(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`deletePrompt`), `ctx.elicit` with schema (`refinePrompt`, `purgeAll`), `ctx.progress` (`importPrompts`, `generateVariants`), `ctx.sample` free-text (`testPrompt`, `refinePrompt`) and `ctx.sample` with Zod schema (`generateVariants`), cancellation via `ctx.signal`, capability gating via `ctx.agentCapabilities.sampling`, structured logging via `log()`, signal-aware shutdown**.\n\nPair with [`express-prompts`](/examples/express-prompts/) to see the same domain served over HTTP.\n","bodyText":"**What it teaches:** a pure-Node Tesseron integration whose domain revolves around `ctx.sample` and `ctx.elicit`. No Express, no HTTP server - just a Node script that registers actions and connects. Good when you're building a CLI, a daemon, or a worker that Claude should drive, and the agent's LLM is part of the workflow.\n\n**Source:** [`examples/node-prompts`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/node-prompts)\n\n## Run it\n\n```bash\npnpm --filter node-prompts dev\n# prints the claim code to stdout; no browser\n```\n\n## Domain\n\nA library of reusable LLM prompts. Claude can:\n\n- `addPrompt`, `listPrompts`, `deletePrompt`, `importPrompts`, `purgeAll` - CRUD over the library.\n- `testPrompt` - fill `{{var}}` placeholders, send the prompt through `ctx.sample`, store the response as `lastTest`.\n- `refinePrompt` - elicit a free-text refinement instruction from the user via `ctx.elicit`, then ask the agent LLM via `ctx.sample` to rewrite the template in place.\n- `generateVariants` - ask the agent LLM for N alternative phrasings of a prompt, stream progress as each lands, store them as new prompts.\n\nTwo subscribable resources push updates on every mutation: `library` (`Prompt[]`) and `lastTest` (`TestResult | null`).\n\n## What's inside\n\n```ts title=\"src/index.ts (excerpt)\"\n\ntesseron.app({ id: 'prompt_lab', name: 'Prompt Lab' });\n\ntesseron.action('testPrompt')\n .input(z.object({\n id: z.string(),\n variables: z.record(z.string(), z.string()).optional(),\n }))\n .handler(async ({ id, variables }, ctx) => {\n const prompt = prompts.get(id)!;\n if (!ctx.agentCapabilities.sampling) {\n throw new Error('Agent does not support sampling.');\n }\n const filled = applyTemplate(prompt.template, variables ?? {});\n const response = await ctx.sample({ prompt: filled, maxTokens: 512 });\n // store response as lastTest, bump timesTested, notify subscribers\n return { id, response };\n });\n\ntesseron.action('refinePrompt')\n .input(z.object({ id: z.string() }))\n .handler(async ({ id }, ctx) => {\n const answer = await ctx.elicit({\n question: `What should change?`,\n schema: z.object({ instruction: z.string().min(1) }),\n jsonSchema: { /* ... */ },\n });\n if (answer === null) return { id, refined: false, cancelled: true };\n const rewritten = await ctx.sample({\n prompt: `Rewrite this prompt per instruction: ${answer.instruction}\\n\\n${prompt.template}`,\n });\n // replace template with rewritten.trim(), notify subscribers\n });\n\ntesseron.resource<Prompt[]>('library')\n .read(() => Array.from(prompts.values()))\n .subscribe((emit) => { librarySubs.add(emit); return () => librarySubs.delete(emit); });\n\nconst welcome = await tesseron.connect();\nlog(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`deletePrompt`), `ctx.elicit` with schema (`refinePrompt`, `purgeAll`), `ctx.progress` (`importPrompts`, `generateVariants`), `ctx.sample` free-text (`testPrompt`, `refinePrompt`) and `ctx.sample` with Zod schema (`generateVariants`), cancellation via `ctx.signal`, capability gating via `ctx.agentCapabilities.sampling`, structured logging via `log()`, signal-aware shutdown**.\n\nPair with [`express-prompts`](/examples/express-prompts/) to see the same domain served over HTTP."},{"slug":"examples/react-todo","title":"react-todo","description":"React 18 + `@tesseron/react` hooks. Idiomatic integration with component lifecycle.","section":"examples","related":["sdk/typescript/react","sdk/typescript/web"],"bodyRaw":"\n**What it teaches:** declarative action registration in React. Mount = register; unmount = unregister. State is mutated through `setTodos` exactly like in a normal React app.\n\n**Source:** [`examples/react-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/react-todo)\n\n## Run it\n\n```bash\npnpm --filter react-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```tsx title=\"src/app.tsx (excerpt)\"\nimport { useTesseronAction, useTesseronResource, useTesseronConnection } from '@tesseron/react';\nimport { z } from 'zod';\nimport { useState } from 'react';\n\nexport function App() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.claimCode && <ClaimBanner code={conn.claimCode} />}\n <TodoList todos={todos} />\n </>\n );\n}\n```\n\nFeatures exercised: **all three React hooks (`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`), annotations, Zod input, setState-driven UI reactivity, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), subscribable resources**.\n\nSee the [React adapter docs](/sdk/typescript/react/) for the full hook API.\n","bodyText":"**What it teaches:** declarative action registration in React. Mount = register; unmount = unregister. State is mutated through `setTodos` exactly like in a normal React app.\n\n**Source:** [`examples/react-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/react-todo)\n\n## Run it\n\n```bash\npnpm --filter react-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```tsx title=\"src/app.tsx (excerpt)\"\n\nexport function App() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.claimCode && }\n \n </>\n );\n}\n```\n\nFeatures exercised: **all three React hooks (`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`), annotations, Zod input, setState-driven UI reactivity, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), subscribable resources**.\n\nSee the [React adapter docs](/sdk/typescript/react/) for the full hook API."},{"slug":"examples/svelte-todo","title":"svelte-todo","description":"Svelte 5 runes driven by `@tesseron/svelte` and bridged by `@tesseron/vite`.","section":"examples","related":["sdk/typescript/svelte","sdk/typescript/vite"],"bodyRaw":"\n**What it teaches:** integrating Tesseron with Svelte 5's rune-based reactivity via `@tesseron/svelte`. Handlers reassign `$state` variables and Svelte re-renders; the `@tesseron/vite` plugin bridges the browser WebSocket to the gateway.\n\n**Source:** [`examples/svelte-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/svelte-todo)\n\n## Run it\n\n```bash\npnpm --filter svelte-todo dev\n# http://localhost:5175\n```\n\n## What's inside\n\n```ts title=\"vite.config.ts\"\nimport { defineConfig } from 'vite';\nimport { svelte } from '@sveltejs/vite-plugin-svelte';\nimport { tesseron } from '@tesseron/vite';\n\nexport default defineConfig({\n plugins: [svelte(), tesseron({ appName: 'svelte-todo' })],\n server: { port: 5175 },\n});\n```\n\n```svelte title=\"src/app.svelte (excerpt)\"\n<script lang=\"ts\">\n import { tesseron, tesseronAction, tesseronResource, tesseronConnection } from '@tesseron/svelte';\n import { z } from 'zod';\n\n let todos = $state<Todo[]>([]);\n let filter = $state<'all' | 'active' | 'done'>('all');\n const visibleTodos = $derived(\n filter === 'all' ? todos : todos.filter((t) => (filter === 'done' ? t.done : !t.done))\n );\n\n tesseron.app({ id: 'svelte_todo', name: 'Svelte Todo' });\n\n tesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos = [...todos, todo];\n return todo;\n },\n });\n\n tesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n const connection = tesseronConnection();\n</script>\n\n{#if $connection.status === 'open'}\n <p>Claim code: {$connection.claimCode}</p>\n{/if}\n```\n\nFeatures exercised: **`$state` / `$derived` runes, component-scoped actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`, with graceful fallback when sampling isn't advertised)**.\n\nThe Vite plugin serves `/@tesseron/ws` on the same port as the dev server; the adapter package handles lifecycle scoping. If you prefer the raw API, you can use `@tesseron/web` directly inside `onMount` - the adapter is a convenience.\n","bodyText":"**What it teaches:** integrating Tesseron with Svelte 5's rune-based reactivity via `@tesseron/svelte`. Handlers reassign `$state` variables and Svelte re-renders; the `@tesseron/vite` plugin bridges the browser WebSocket to the gateway.\n\n**Source:** [`examples/svelte-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/svelte-todo)\n\n## Run it\n\n```bash\npnpm --filter svelte-todo dev\n# http://localhost:5175\n```\n\n## What's inside\n\n```ts title=\"vite.config.ts\"\n\nexport default defineConfig({\n plugins: [svelte(), tesseron({ appName: 'svelte-todo' })],\n server: { port: 5175 },\n});\n```\n\n```svelte title=\"src/app.svelte (excerpt)\"\n<script lang=\"ts\">\n import { tesseron, tesseronAction, tesseronResource, tesseronConnection } from '@tesseron/svelte';\n import { z } from 'zod';\n\n let todos = $state<Todo[]>([]);\n let filter = $state<'all' | 'active' | 'done'>('all');\n const visibleTodos = $derived(\n filter === 'all' ? todos : todos.filter((t) => (filter === 'done' ? t.done : !t.done))\n );\n\n tesseron.app({ id: 'svelte_todo', name: 'Svelte Todo' });\n\n tesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos = [...todos, todo];\n return todo;\n },\n });\n\n tesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n const connection = tesseronConnection();\n</script>\n\n{#if $connection.status === 'open'}\n <p>Claim code: {$connection.claimCode}</p>\n{/if}\n```\n\nFeatures exercised: **`$state` / `$derived` runes, component-scoped actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`, with graceful fallback when sampling isn't advertised)**.\n\nThe Vite plugin serves `/@tesseron/ws` on the same port as the dev server; the adapter package handles lifecycle scoping. If you prefer the raw API, you can use `@tesseron/web` directly inside `onMount` - the adapter is a convenience."},{"slug":"examples/vanilla-todo","title":"vanilla-todo","description":"Plain Vite + TypeScript. The minimum environment for exercising the SDK.","section":"examples","related":["sdk/typescript/web","sdk/typescript/index"],"bodyRaw":"\n**What it teaches:** the raw action / resource builder API with no framework in the way. Read this before any of the framework-specific examples.\n\n**Source:** [`examples/vanilla-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vanilla-todo)\n\n## Run it\n\n```bash\npnpm --filter vanilla-todo dev\n# opens http://localhost:5173\n```\n\n## What's inside\n\n```ts title=\"src/main.ts (excerpt)\"\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'vanilla_todo', name: 'Vanilla Todo' });\n\ntesseron\n .action('addTodo')\n .describe('Add a new todo item. Returns the created todo.')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n state.todos = [...state.todos, todo];\n render();\n return todo;\n });\n\ntesseron.action('toggleTodo')\n .input(z.object({ id: z.string() }))\n .annotate({ destructive: true })\n .handler(/* … */);\n\ntesseron.resource('todoStats')\n .read(() => ({ total: state.todos.length, completed: state.todos.filter(t => t.done).length }));\n\nawait tesseron.connect();\n```\n\nNine actions (`addTodo`, `toggleTodo`, `deleteTodo`, `listTodos`, `setFilter`, `clearCompleted`, `renameTodo`, `importTodos`, `suggestTodos`) and two subscribable resources (`currentFilter`, `todoStats`) - a realistic-but-contained surface for experimenting.\n\nFeatures exercised: **actions, annotations (`destructive`, `requiresConfirmation`, `readOnly`), subscribable resources, Zod input validation, `ctx.confirm` (in `clearCompleted`), `ctx.elicit` with schema (in `renameTodo`), `ctx.progress` (in `importTodos`), `ctx.sample` (in `suggestTodos`), connection lifecycle**.\n","bodyText":"**What it teaches:** the raw action / resource builder API with no framework in the way. Read this before any of the framework-specific examples.\n\n**Source:** [`examples/vanilla-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vanilla-todo)\n\n## Run it\n\n```bash\npnpm --filter vanilla-todo dev\n# opens http://localhost:5173\n```\n\n## What's inside\n\n```ts title=\"src/main.ts (excerpt)\"\n\ntesseron.app({ id: 'vanilla_todo', name: 'Vanilla Todo' });\n\ntesseron\n .action('addTodo')\n .describe('Add a new todo item. Returns the created todo.')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n state.todos = [...state.todos, todo];\n render();\n return todo;\n });\n\ntesseron.action('toggleTodo')\n .input(z.object({ id: z.string() }))\n .annotate({ destructive: true })\n .handler(/* … */);\n\ntesseron.resource('todoStats')\n .read(() => ({ total: state.todos.length, completed: state.todos.filter(t => t.done).length }));\n\nawait tesseron.connect();\n```\n\nNine actions (`addTodo`, `toggleTodo`, `deleteTodo`, `listTodos`, `setFilter`, `clearCompleted`, `renameTodo`, `importTodos`, `suggestTodos`) and two subscribable resources (`currentFilter`, `todoStats`) - a realistic-but-contained surface for experimenting.\n\nFeatures exercised: **actions, annotations (`destructive`, `requiresConfirmation`, `readOnly`), subscribable resources, Zod input validation, `ctx.confirm` (in `clearCompleted`), `ctx.elicit` with schema (in `renameTodo`), `ctx.progress` (in `importTodos`), `ctx.sample` (in `suggestTodos`), connection lifecycle**."},{"slug":"examples/vue-todo","title":"vue-todo","description":"Vue 3 composition API driven by `@tesseron/vue` and bridged by `@tesseron/vite`.","section":"examples","related":["sdk/typescript/vue","sdk/typescript/vite"],"bodyRaw":"\n**What it teaches:** integrating Tesseron with Vue 3's reactivity via `@tesseron/vue`. Handlers mutate `todos.value` and the `@tesseron/vite` plugin bridges the browser WebSocket to the gateway.\n\n**Source:** [`examples/vue-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vue-todo)\n\n## Run it\n\n```bash\npnpm --filter vue-todo dev\n# http://localhost:5176\n```\n\n## What's inside\n\n```ts title=\"vite.config.ts\"\nimport { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport { tesseron } from '@tesseron/vite';\n\nexport default defineConfig({\n plugins: [vue(), tesseron({ appName: 'vue-todo' })],\n server: { port: 5176 },\n});\n```\n\n```vue title=\"src/app.vue (excerpt)\"\n<script setup lang=\"ts\">\nimport { ref, computed } from 'vue';\nimport { tesseron, tesseronAction, tesseronResource, tesseronConnection } from '@tesseron/vue';\nimport { z } from 'zod';\n\nconst todos = ref<Todo[]>([]);\nconst filter = ref<'all' | 'active' | 'done'>('all');\nconst visibleTodos = computed(() =>\n filter.value === 'all'\n ? todos.value\n : todos.value.filter((t) => (filter.value === 'done' ? t.done : !t.done))\n);\n\ntesseron.app({ id: 'vue_todo', name: 'Vue Todo' });\n\ntesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.value = [...todos.value, todo];\n return todo;\n },\n});\n\ntesseronResource('todoStats', () => ({\n total: todos.value.length,\n completed: todos.value.filter((t) => t.done).length,\n}));\n\nconst connection = tesseronConnection();\n</script>\n\n<template>\n <p v-if=\"connection.status === 'open'\">Claim code: {{ connection.claimCode }}</p>\n</template>\n```\n\nFeatures exercised: **`ref` + `computed`, component-scoped actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`)**.\n\nThe Vite plugin serves `/@tesseron/ws` on the same port as the dev server; the adapter package handles lifecycle scoping. If you prefer the raw API, you can use `@tesseron/web` directly inside `onMounted` - the adapter is a convenience.\n","bodyText":"**What it teaches:** integrating Tesseron with Vue 3's reactivity via `@tesseron/vue`. Handlers mutate `todos.value` and the `@tesseron/vite` plugin bridges the browser WebSocket to the gateway.\n\n**Source:** [`examples/vue-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vue-todo)\n\n## Run it\n\n```bash\npnpm --filter vue-todo dev\n# http://localhost:5176\n```\n\n## What's inside\n\n```ts title=\"vite.config.ts\"\n\nexport default defineConfig({\n plugins: [vue(), tesseron({ appName: 'vue-todo' })],\n server: { port: 5176 },\n});\n```\n\n```vue title=\"src/app.vue (excerpt)\"\n<script setup lang=\"ts\">\n\nconst todos = ref<Todo[]>([]);\nconst filter = ref<'all' | 'active' | 'done'>('all');\nconst visibleTodos = computed(() =>\n filter.value === 'all'\n ? todos.value\n : todos.value.filter((t) => (filter.value === 'done' ? t.done : !t.done))\n);\n\ntesseron.app({ id: 'vue_todo', name: 'Vue Todo' });\n\ntesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.value = [...todos.value, todo];\n return todo;\n },\n});\n\ntesseronResource('todoStats', () => ({\n total: todos.value.length,\n completed: todos.value.filter((t) => t.done).length,\n}));\n\nconst connection = tesseronConnection();\n</script>\n\n<template>\n <p v-if=\"connection.status === 'open'\">Claim code: {{ connection.claimCode }}</p>\n</template>\n```\n\nFeatures exercised: **`ref` + `computed`, component-scoped actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`)**.\n\nThe Vite plugin serves `/@tesseron/ws` on the same port as the dev server; the adapter package handles lifecycle scoping. If you prefer the raw API, you can use `@tesseron/web` directly inside `onMounted` - the adapter is a convenience."},{"slug":"index","title":"Tesseron","description":"Expose typed web-app actions to MCP-compatible AI agents over WebSocket. No browser automation, no scraping.","section":"","related":["overview/quickstart","overview/why","overview/architecture"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Diagram from '../../components/Diagram.astro';\n\n<Diagram\n caption=\"Your web app declares actions. The MCP gateway bridges them to any MCP-capable agent (Claude Code, Cursor, Claude Desktop).\"\n nodeWidth={130}\n spacing={140}\n pad={42}\n nodes={[\n { id: 'user', label: 'USER', sub: ['human at', 'the keyboard'], icon: 'user' },\n { id: 'app', label: 'YOUR APP', sub: 'browser or Node', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'WS client + MCP', icon: 'bridge', variant: 'accent' },\n { id: 'agent', label: 'AGENT', sub: ['Claude Code,', 'Cursor, Desktop'], icon: 'agent' },\n ]}\n edges={[\n { from: 'user', to: 'app' },\n // Arrow direction controls ELK's layer assignment — `app → gw → agent`\n // forms a linear chain so the four cards sit in one horizontal row.\n // The bidirectional flag keeps the visual meaning (both protocols flow\n // both ways) regardless of source/target.\n { from: 'app', to: 'gw', label: 'WebSocket', bidirectional: true, accent: true },\n { from: 'gw', to: 'agent', label: 'MCP stdio', bidirectional: true, accent: true },\n ]}\n/>\n\n## What you get\n\n<CardGrid>\n <Card title=\"Typed actions\" icon=\"seti:typescript\">\n Declare actions with a fluent builder backed by any [Standard Schema](https://standardschema.dev) validator - Zod, Valibot, ArkType, Effect Schema. The MCP tool schema is derived automatically.\n </Card>\n <Card title=\"Real UI, not a shadow DOM\" icon=\"open-book\">\n The agent drives your actual running app. State, auth, feature flags - all intact. Nothing to scrape, nothing to re-implement.\n </Card>\n <Card title=\"Full MCP capability set\" icon=\"rocket\">\n Streaming progress, cancellation, resources (read + subscribe), sampling, and elicitation work out of the box over a single WebSocket.\n </Card>\n <Card title=\"Framework-agnostic\" icon=\"puzzle\">\n One-file integrations for vanilla TS, React, Svelte, Vue, Node, and Express. Same builder API everywhere.\n </Card>\n</CardGrid>\n\n## Read the docs in two halves\n\n<CardGrid>\n <LinkCard\n title=\"Protocol\"\n href=\"./protocol/\"\n description=\"The wire format, handshake, action model, and advanced MCP features - with a diagram for every flow.\"\n />\n <LinkCard\n title=\"SDK\"\n href=\"./sdk/\"\n description=\"Build with @tesseron/web, /server, /react, or port Tesseron to a new language.\"\n />\n</CardGrid>\n\n## 60-second taste\n\n```ts title=\"src/main.ts\"\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\n\n// 1. A plain action - input, handler, streaming progress, return value.\ntesseron\n .action('searchProducts')\n .describe('Search the product catalog')\n .input(z.object({ query: z.string().min(1), limit: z.number().default(10) }))\n .handler(async ({ query, limit }, ctx) => {\n ctx.progress({ message: 'searching...', percent: 20 });\n const items = await store.search(query, { limit });\n return { items }; // becomes the MCP tool result the agent sees\n });\n\n// 2. An action that pauses to ask the user through the agent's UI.\ntesseron\n .action('checkout')\n .describe('Place the pending order')\n .input(z.object({ cartId: z.string() }))\n .handler(async ({ cartId }, ctx) => {\n const ok = await ctx.confirm({\n question: `Place order for $${cart.total(cartId)}? This charges your card.`,\n });\n if (!ok) throw new Error('User cancelled');\n return await orders.place(cartId);\n });\n\n// 3. A resource - readable, subscribable app state. No polling needed.\ntesseron\n .resource('currentRoute')\n .describe('URL the user is viewing')\n .read(() => location.pathname)\n .subscribe((emit) => {\n const fn = () => emit(location.pathname);\n addEventListener('popstate', fn);\n return () => removeEventListener('popstate', fn);\n });\n\n// 4. Connect. `connect()` resolves with the claim code - surface it\n// in your UI so the human can paste it into their agent.\nconst { claimCode } = await tesseron.connect();\ndocument.querySelector('#connect-banner')!.textContent =\n `Paste \"${claimCode}\" into Claude to connect this tab.`;\n```\n\n**What the agent sees once connected:**\n\n- Two MCP tools: `shop__searchProducts` and `shop__checkout`. It can call either, pass typed input, and receive your typed output.\n- One resource: `tesseron://shop/currentRoute`. It can read once, or subscribe and get pushed updates every time the user navigates - no polling, no webhooks.\n\n**What you didn't have to do:**\n\n- No HTTP server. The WebSocket goes to the gateway that runs next to the agent.\n- No OpenAPI spec, no tool schemas. They're derived from your Zod validators.\n- No glue between tools. The agent reads `searchProducts`'s output, picks a product, calls `checkout` with it, and pauses on `ctx.confirm` until the user approves - all orchestrated by the agent loop.\n\nThat's the whole surface: `.action()`, `.resource()`, and `.connect()`. Everything else is detail.\n\n**The other half runs next to the agent.** The gateway is `@tesseron/mcp` - an MCP server that opens the WebSocket port, hands out claim codes, and translates MCP tool calls into `actions/invoke` frames on your app's socket. You don't write MCP code; the gateway *is* the MCP server.\n\nYou wire it into your agent's MCP config once. Claude Desktop example (`claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@tesseron/mcp\"]\n }\n }\n}\n```\n\nClaude Code / Cursor / any MCP-capable client: same pattern, their own config file.\n","bodyText":"## What you get\n\n## Read the docs in two halves\n\n## 60-second taste\n\n```ts title=\"src/main.ts\"\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\n\n// 1. A plain action - input, handler, streaming progress, return value.\ntesseron\n .action('searchProducts')\n .describe('Search the product catalog')\n .input(z.object({ query: z.string().min(1), limit: z.number().default(10) }))\n .handler(async ({ query, limit }, ctx) => {\n ctx.progress({ message: 'searching...', percent: 20 });\n const items = await store.search(query, { limit });\n return { items }; // becomes the MCP tool result the agent sees\n });\n\n// 2. An action that pauses to ask the user through the agent's UI.\ntesseron\n .action('checkout')\n .describe('Place the pending order')\n .input(z.object({ cartId: z.string() }))\n .handler(async ({ cartId }, ctx) => {\n const ok = await ctx.confirm({\n question: `Place order for $${cart.total(cartId)}? This charges your card.`,\n });\n if (!ok) throw new Error('User cancelled');\n return await orders.place(cartId);\n });\n\n// 3. A resource - readable, subscribable app state. No polling needed.\ntesseron\n .resource('currentRoute')\n .describe('URL the user is viewing')\n .read(() => location.pathname)\n .subscribe((emit) => {\n const fn = () => emit(location.pathname);\n addEventListener('popstate', fn);\n return () => removeEventListener('popstate', fn);\n });\n\n// 4. Connect. `connect()` resolves with the claim code - surface it\n// in your UI so the human can paste it into their agent.\nconst { claimCode } = await tesseron.connect();\ndocument.querySelector('#connect-banner')!.textContent =\n `Paste \"${claimCode}\" into Claude to connect this tab.`;\n```\n\n**What the agent sees once connected:**\n\n- Two MCP tools: `shop__searchProducts` and `shop__checkout`. It can call either, pass typed input, and receive your typed output.\n- One resource: `tesseron://shop/currentRoute`. It can read once, or subscribe and get pushed updates every time the user navigates - no polling, no webhooks.\n\n**What you didn't have to do:**\n\n- No HTTP server. The WebSocket goes to the gateway that runs next to the agent.\n- No OpenAPI spec, no tool schemas. They're derived from your Zod validators.\n- No glue between tools. The agent reads `searchProducts`'s output, picks a product, calls `checkout` with it, and pauses on `ctx.confirm` until the user approves - all orchestrated by the agent loop.\n\nThat's the whole surface: `.action()`, `.resource()`, and `.connect()`. Everything else is detail.\n\n**The other half runs next to the agent.** The gateway is `@tesseron/mcp` - an MCP server that opens the WebSocket port, hands out claim codes, and translates MCP tool calls into `actions/invoke` frames on your app's socket. You don't write MCP code; the gateway *is* the MCP server.\n\nYou wire it into your agent's MCP config once. Claude Desktop example (`claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@tesseron/mcp\"]\n }\n }\n}\n```\n\nClaude Code / Cursor / any MCP-capable client: same pattern, their own config file."},{"slug":"overview/architecture","title":"Architecture at a glance","description":"The three moving parts - your app, the MCP gateway, the agent - and how a single action flows between them.","section":"overview","related":["overview/quickstart","protocol/handshake","protocol/actions","sdk/typescript/mcp"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\n<Diagram\n caption=\"Three processes, two protocols. Your app hosts a WebSocket endpoint and announces itself; the gateway dials in and speaks MCP stdio to the agent.\"\n nodeWidth={130}\n spacing={140}\n pad={42}\n nodes={[\n { id: 'user', label: 'USER', sub: ['human at', 'the keyboard'], icon: 'user' },\n { id: 'app', label: 'YOUR APP', sub: ['WS server +', 'tab file'], icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: ['WS client', '+ MCP'], icon: 'bridge', variant: 'accent' },\n { id: 'agent', label: 'AGENT', sub: ['Claude Code,', 'Cursor, Desktop'], icon: 'agent' },\n ]}\n edges={[\n { from: 'user', to: 'app' },\n // Arrow direction controls ELK's layer assignment — `app → gw → agent`\n // forms a linear chain so the four cards sit in one horizontal row.\n // The bidirectional flag keeps the visual meaning (both protocols flow\n // both ways) regardless of source/target. In reality the gateway is\n // the WS client dialling into the app; only the layout is reversed.\n { from: 'app', to: 'gw', label: 'WebSocket', bidirectional: true, accent: true },\n { from: 'gw', to: 'agent', label: 'MCP stdio', bidirectional: true, accent: true },\n ]}\n/>\n\n## Three processes\n\n- **Your app** - browser tab, React / Svelte / Vue / vanilla-TS, or a Node / desktop process. Hosts the action handlers and the real state they mutate. Also hosts a local endpoint the gateway dials into:\n - Browser apps get that endpoint for free by adding the `@tesseron/vite` plugin (or the equivalent for your dev server). The binding is WebSocket.\n - Node apps get it via `@tesseron/server`, which by default binds a loopback WebSocket; pass `{ transport: 'uds' }` for a Unix domain socket on Linux/macOS.\n - Anything else (Electron main, .NET, Python, Go, Rust, ...) can follow the same pattern: bind whichever [transport binding](/protocol/transport/) makes sense, drop an instance manifest at `~/.tesseron/instances/<instanceId>.json`, speak the protocol.\n- **The MCP gateway** - a small Node process (`@tesseron/mcp`) bundled into the Claude Code plugin. Runs on stdio for the agent. Watches `~/.tesseron/instances/` and dials each app it finds via the binding the manifest advertises. The gateway never binds a port of its own.\n- **The agent** - Claude Code, Claude Desktop, Cursor, or any other MCP client. Doesn't know or care about transport bindings - it only sees standard MCP tools.\n\n## Two protocols\n\n| Hop | Protocol | Transport |\n|---|---|---|\n| app ↔ gateway | Tesseron JSON-RPC 2.0 (custom) | WebSocket or UDS (per binding) |\n| gateway ↔ agent | Model Context Protocol | stdio |\n\nThe gateway is the only place that knows both dialects. Everything else is clean on each side: your app speaks one flavour of JSON-RPC, the agent speaks MCP.\n\n## What travels on each hop\n\n**App → Gateway** (you send these):\n- `tesseron/hello` - register app, actions, resources, capabilities.\n- `actions/invoke` response - the return value of an invoked action.\n- `actions/progress` - streaming progress updates.\n- `resources/updated` - push notifications for subscribed resources.\n- `sampling/request`, `elicitation/request` - ask the agent or user something mid-handler.\n\n**Gateway → App** (you handle these):\n- `actions/invoke` - the agent called one of your actions.\n- `actions/cancel` - the agent cancelled a running invocation.\n- `resources/read`, `resources/subscribe`, `resources/unsubscribe` - resource I/O.\n\n**Gateway → Agent** (abstracted - the SDK takes care of MCP framing):\n- `tools/list` with entries named `<app_id>__<action_name>`.\n- `tools/call` results, streamed via `notifications/progress` where available.\n- `resources/list`, `resources/read`, `resources/subscribe`.\n\n## Why an MCP gateway?\n\nBecause MCP doesn't run over WebSocket, and JSON-RPC-over-stdio doesn't work from a browser tab. The gateway reconciles the two, plus:\n\n- **Session claiming.** A 6-character code (`AB3X-7K`) that the user pastes into the agent binds one tab to one agent session. Keeps strangers out.\n- **Local-only.** Apps bind to loopback (TCP `127.0.0.1`) or a private Unix socket; the gateway never binds a port. Nothing leaks off the machine.\n- **Multi-app fan-in.** You can run several apps at once; tools are namespaced by `app.id` so `shop__addItem` and `admin__banUser` never collide.\n\n## Discovery, not binding\n\nThere is exactly one discovery mechanism and it works the same for every runtime:\n\n1. Your app binds an endpoint locally - WebSocket on loopback, or a Unix domain socket. Whichever [binding](/protocol/transport/) fits the runtime.\n2. It writes `~/.tesseron/instances/<instanceId>.json` with a `{ kind, url | path }` spec.\n3. The gateway (watching that directory) picks up the file and dials the advertised endpoint via the matching dialer.\n4. Once connected, the app sends `tesseron/hello` and the normal protocol takes over.\n\nNo fixed ports. No environment variables. No \"which gateway do I connect to\". Just bind, announce, serve. If you want to port Tesseron to a new language, that's the entire runtime contract - everything else is libraries of your language's choosing.\n\nNext: the [5-minute quickstart](/overview/quickstart/) walks through getting this running end-to-end.\n","bodyText":"## Three processes\n\n- **Your app** - browser tab, React / Svelte / Vue / vanilla-TS, or a Node / desktop process. Hosts the action handlers and the real state they mutate. Also hosts a local endpoint the gateway dials into:\n - Browser apps get that endpoint for free by adding the `@tesseron/vite` plugin (or the equivalent for your dev server). The binding is WebSocket.\n - Node apps get it via `@tesseron/server`, which by default binds a loopback WebSocket; pass `{ transport: 'uds' }` for a Unix domain socket on Linux/macOS.\n - Anything else (Electron main, .NET, Python, Go, Rust, ...) can follow the same pattern: bind whichever [transport binding](/protocol/transport/) makes sense, drop an instance manifest at `~/.tesseron/instances/<instanceId>.json`, speak the protocol.\n- **The MCP gateway** - a small Node process (`@tesseron/mcp`) bundled into the Claude Code plugin. Runs on stdio for the agent. Watches `~/.tesseron/instances/` and dials each app it finds via the binding the manifest advertises. The gateway never binds a port of its own.\n- **The agent** - Claude Code, Claude Desktop, Cursor, or any other MCP client. Doesn't know or care about transport bindings - it only sees standard MCP tools.\n\n## Two protocols\n\n| Hop | Protocol | Transport |\n|---|---|---|\n| app ↔ gateway | Tesseron JSON-RPC 2.0 (custom) | WebSocket or UDS (per binding) |\n| gateway ↔ agent | Model Context Protocol | stdio |\n\nThe gateway is the only place that knows both dialects. Everything else is clean on each side: your app speaks one flavour of JSON-RPC, the agent speaks MCP.\n\n## What travels on each hop\n\n**App → Gateway** (you send these):\n- `tesseron/hello` - register app, actions, resources, capabilities.\n- `actions/invoke` response - the return value of an invoked action.\n- `actions/progress` - streaming progress updates.\n- `resources/updated` - push notifications for subscribed resources.\n- `sampling/request`, `elicitation/request` - ask the agent or user something mid-handler.\n\n**Gateway → App** (you handle these):\n- `actions/invoke` - the agent called one of your actions.\n- `actions/cancel` - the agent cancelled a running invocation.\n- `resources/read`, `resources/subscribe`, `resources/unsubscribe` - resource I/O.\n\n**Gateway → Agent** (abstracted - the SDK takes care of MCP framing):\n- `tools/list` with entries named `<app_id>__<action_name>`.\n- `tools/call` results, streamed via `notifications/progress` where available.\n- `resources/list`, `resources/read`, `resources/subscribe`.\n\n## Why an MCP gateway?\n\nBecause MCP doesn't run over WebSocket, and JSON-RPC-over-stdio doesn't work from a browser tab. The gateway reconciles the two, plus:\n\n- **Session claiming.** A 6-character code (`AB3X-7K`) that the user pastes into the agent binds one tab to one agent session. Keeps strangers out.\n- **Local-only.** Apps bind to loopback (TCP `127.0.0.1`) or a private Unix socket; the gateway never binds a port. Nothing leaks off the machine.\n- **Multi-app fan-in.** You can run several apps at once; tools are namespaced by `app.id` so `shop__addItem` and `admin__banUser` never collide.\n\n## Discovery, not binding\n\nThere is exactly one discovery mechanism and it works the same for every runtime:\n\n1. Your app binds an endpoint locally - WebSocket on loopback, or a Unix domain socket. Whichever [binding](/protocol/transport/) fits the runtime.\n2. It writes `~/.tesseron/instances/<instanceId>.json` with a `{ kind, url | path }` spec.\n3. The gateway (watching that directory) picks up the file and dials the advertised endpoint via the matching dialer.\n4. Once connected, the app sends `tesseron/hello` and the normal protocol takes over.\n\nNo fixed ports. No environment variables. No \"which gateway do I connect to\". Just bind, announce, serve. If you want to port Tesseron to a new language, that's the entire runtime contract - everything else is libraries of your language's choosing.\n\nNext: the [5-minute quickstart](/overview/quickstart/) walks through getting this running end-to-end."},{"slug":"overview/quickstart","title":"Quickstart (5 minutes)","description":"Install the plugin, drop the SDK into an app, declare one action, watch Claude call it.","section":"overview","related":["sdk/typescript/index","sdk/typescript/action-builder","overview/architecture","examples/index"],"bodyRaw":"\nimport { Steps, Tabs, TabItem } from '@astrojs/starlight/components';\n\n**Prereqs.** Node ≥ 20. Claude Code installed.\n\n<Steps>\n\n1. **Install the Claude Code plugin.** It bundles the MCP gateway and auto-registers it as an MCP server.\n\n ```text\n /plugin marketplace add BrainBlend-AI/tesseron\n /plugin install tesseron@tesseron\n ```\n\n Restart Claude Code after installation. The gateway now runs whenever the plugin is enabled; no separate process to manage.\n\n2. **Add the SDK to your app.**\n\n <Tabs>\n <TabItem label=\"Vite (Vue / Svelte / vanilla)\">\n ```bash\n pnpm add @tesseron/web zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Vue\">\n ```bash\n pnpm add @tesseron/vue zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Svelte\">\n ```bash\n pnpm add @tesseron/svelte zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"React\">\n ```bash\n pnpm add @tesseron/react zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Node / server\">\n ```bash\n pnpm add @tesseron/server zod\n ```\n </TabItem>\n </Tabs>\n\n3. **Register the Vite plugin** (browser apps only). It exposes `/@tesseron/ws` on the Vite dev server and writes per-tab discovery files the gateway watches.\n\n ```ts title=\"vite.config.ts\"\n import { defineConfig } from 'vite';\n import { tesseron } from '@tesseron/vite';\n\n export default defineConfig({\n plugins: [/* your framework plugin, e.g. vue() or svelte() */, tesseron()],\n });\n ```\n\n Node apps skip this step - `@tesseron/server` binds and announces automatically.\n\n4. **Declare an app and one action.**\n\n ```ts title=\"src/main.ts\"\n import { tesseron } from '@tesseron/web';\n import { z } from 'zod';\n\n tesseron.app({ id: 'notes', name: 'My Notes App' });\n\n tesseron\n .action('createNote')\n .describe('Create a new note with a title and body')\n .input(z.object({\n title: z.string().min(1),\n body: z.string().default(''),\n }))\n .handler(({ title, body }) => {\n const note = { id: crypto.randomUUID(), title, body, createdAt: Date.now() };\n store.add(note); // whatever \"add\" means in your app\n return note;\n });\n\n await tesseron.connect();\n ```\n\n In the browser, `tesseron.connect()` opens a WebSocket to the Vite plugin at `<location.origin>/@tesseron/ws`, which bridges it to the gateway. In Node, `@tesseron/server` binds a loopback WS server (or a Unix domain socket if you pass `{ transport: 'uds' }`) and announces itself via `~/.tesseron/instances/`; the gateway dials in. Either way, `connect()` resolves with a `welcome` that carries the `claimCode`.\n\n5. **Claim the session from Claude.** Open your app - the gateway prints a 6-character claim code to its stderr (and you can surface it in your UI too). Tell Claude:\n\n > \"Claim Tesseron session AB3X-7K\"\n\n Claude calls the built-in `tesseron__claim_session` tool, the gateway marks the session claimed, and a `notifications/tools/list_changed` event fires.\n\n6. **Call your action.** The tool list now contains `notes__createNote`. Ask Claude:\n\n > \"Create a note titled 'Groceries' with body 'eggs, milk, bread'.\"\n\n The handler runs inside your tab. The new note appears in your UI, reactively. Claude sees the returned object as the tool result.\n\n</Steps>\n\n## Next steps\n\n- [Add progress + cancellation](/protocol/progress-cancellation/) - for actions that take more than a beat.\n- [Expose resources](/protocol/resources/) - let Claude read your UI state (current route, selected item, filter settings).\n- [Use sampling](/protocol/sampling/) - let your handler ask the agent's LLM mid-execution.\n- [Pick your framework adapter](/sdk/) - React, Svelte, Vue, Express patterns.\n","bodyText":"**Prereqs.** Node ≥ 20. Claude Code installed.\n\n## Next steps\n\n- [Add progress + cancellation](/protocol/progress-cancellation/) - for actions that take more than a beat.\n- [Expose resources](/protocol/resources/) - let Claude read your UI state (current route, selected item, filter settings).\n- [Use sampling](/protocol/sampling/) - let your handler ask the agent's LLM mid-execution.\n- [Pick your framework adapter](/sdk/) - React, Svelte, Vue, Express patterns."},{"slug":"overview/why","title":"Why Tesseron?","description":"The problem Tesseron solves, and where it fits relative to browser automation, chat widgets, and custom APIs.","section":"overview","related":["overview/architecture","protocol/index"],"bodyRaw":"\nAgents are great at reasoning about what to do. They're bad at reaching into your app to do it.\n\nThere are three common ways to close that gap. Tesseron is a fourth.\n\n## 1. Browser automation (Playwright, Selenium, Computer Use)\n\nThe agent drives a pixel-level browser. Conceptually powerful, practically fragile: every layout tweak breaks selectors, every modal needs bespoke handling, every authentication flow is re-solved from scratch. Token-heavy. Slow.\n\n## 2. Chat widget embedded in the app\n\nYou bolt an AI sidebar into your UI and wire up tool calls manually. The agent can talk to your backend, but it can't touch the running UI state the user is looking at. Two worlds that never meet.\n\n## 3. A bespoke MCP server for your backend\n\nGreat for headless automation. Useless for \"put this in the user's cart on the page they're already viewing.\" The user's session, their open tab, their in-memory draft - all invisible to a backend MCP server.\n\n## 4. Tesseron\n\nThe running app opens a WebSocket to a local MCP gateway and declares its actions:\n\n```ts\ntesseron.action('addToCart').input(...).handler(...);\n```\n\nThe gateway exposes those actions as MCP tools over stdio. Any MCP-capable agent - Claude Code, Cursor, Claude Desktop, any other - sees them and calls them. The handler runs inside the user's real tab, with their real state, their real auth.\n\n## Tradeoffs (be honest)\n\n- **Localhost by default.** Tesseron is a local-first developer tool. Apps bind to `127.0.0.1`; the gateway only dials loopback URLs. Nothing leaks off the machine.\n- **Requires the tab to be open.** If the page is closed, the session is gone. This is a feature - it keeps the agent bound to what the user can see.\n- **Not a replacement for a headless API.** If you need scheduled or unattended automation, you want a server-side MCP. Tesseron complements it - it doesn't replace it.\n\n## When Tesseron is the right fit\n\n- Internal tools where power users want to drive the UI via chat.\n- Complex workflows that already exist as UI actions - search, filter, create, approve - and shouldn't be duplicated on the backend.\n- Product demos and prototypes where \"the agent actually does what the user sees\" is the whole point.\n- Personal dashboards, admin panels, CMS editors, developer tooling.\n\nIf you're shipping one of those, keep reading.\n","bodyText":"Agents are great at reasoning about what to do. They're bad at reaching into your app to do it.\n\nThere are three common ways to close that gap. Tesseron is a fourth.\n\n## 1. Browser automation (Playwright, Selenium, Computer Use)\n\nThe agent drives a pixel-level browser. Conceptually powerful, practically fragile: every layout tweak breaks selectors, every modal needs bespoke handling, every authentication flow is re-solved from scratch. Token-heavy. Slow.\n\n## 2. Chat widget embedded in the app\n\nYou bolt an AI sidebar into your UI and wire up tool calls manually. The agent can talk to your backend, but it can't touch the running UI state the user is looking at. Two worlds that never meet.\n\n## 3. A bespoke MCP server for your backend\n\nGreat for headless automation. Useless for \"put this in the user's cart on the page they're already viewing.\" The user's session, their open tab, their in-memory draft - all invisible to a backend MCP server.\n\n## 4. Tesseron\n\nThe running app opens a WebSocket to a local MCP gateway and declares its actions:\n\n```ts\ntesseron.action('addToCart').input(...).handler(...);\n```\n\nThe gateway exposes those actions as MCP tools over stdio. Any MCP-capable agent - Claude Code, Cursor, Claude Desktop, any other - sees them and calls them. The handler runs inside the user's real tab, with their real state, their real auth.\n\n## Tradeoffs (be honest)\n\n- **Localhost by default.** Tesseron is a local-first developer tool. Apps bind to `127.0.0.1`; the gateway only dials loopback URLs. Nothing leaks off the machine.\n- **Requires the tab to be open.** If the page is closed, the session is gone. This is a feature - it keeps the agent bound to what the user can see.\n- **Not a replacement for a headless API.** If you need scheduled or unattended automation, you want a server-side MCP. Tesseron complements it - it doesn't replace it.\n\n## When Tesseron is the right fit\n\n- Internal tools where power users want to drive the UI via chat.\n- Complex workflows that already exist as UI actions - search, filter, create, approve - and shouldn't be duplicated on the backend.\n- Product demos and prototypes where \"the agent actually does what the user sees\" is the whole point.\n- Personal dashboards, admin panels, CMS editors, developer tooling.\n\nIf you're shipping one of those, keep reading."},{"slug":"protocol/actions","title":"Action model","description":"How actions are declared, namespaced, invoked, validated, and returned.","section":"protocol","related":["sdk/typescript/action-builder","protocol/wire-format","protocol/elicitation","protocol/sampling","protocol/progress-cancellation"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nAn **action** is a named, typed, handler-backed operation that the app exposes to the agent. On the MCP side it looks like a single tool. On the Tesseron side it has a schema, a handler, and a set of per-invocation context helpers.\n\n<Sequence\n caption=\"One invocation from tools/call to tool result - with input validation between.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: \"tools/call { name: 'shop__addItem', arguments }\" },\n { from: 'g', to: 's', label: 'actions/invoke { name, invocationId, input }' },\n { note: 's', label: 'validate input (Standard Schema)' },\n { note: 's', label: 'run handler(input, ctx)' },\n { from: 's', to: 'g', label: \"result { id: 'item_42', ... }\", style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## Declaration\n\n```ts\ntesseron\n .action('addItem') // action name\n .describe('Add an item to the cart')\n .input(z.object({ sku: z.string(), quantity: z.number().int().positive() }))\n .output(z.object({ cartId: z.string(), itemId: z.string() }))\n .annotate({ destructive: false })\n .timeout({ ms: 10_000 })\n .handler(async ({ sku, quantity }, ctx) => {\n const item = await cart.add(sku, quantity);\n return { cartId: cart.id, itemId: item.id };\n });\n```\n\nBuilder steps:\n\n| Step | Purpose | Required? |\n|---|---|---|\n| `.describe(string)` | Human-readable description shown to the agent's LLM. | Recommended |\n| `.input(schema)` | Standard Schema validator for arguments. Becomes JSON Schema on the wire. | Recommended |\n| `.output(schema)` | Validator for the return value. Not enforced by default. | Optional |\n| `.annotate({…})` | Metadata: `readOnly`, `destructive`, `requiresConfirmation`. | Optional |\n| `.timeout(ms)` | Abort the invocation after N ms. Default 60 000. | Optional |\n| `.strictOutput()` | Enforce the output schema. Default is passthrough. | Optional |\n| `.handler(fn)` | The function that runs. Receives `(input, ctx)`. Terminates the builder. | Required |\n\n## Naming and the MCP tool list\n\nThe MCP gateway registers every action as an MCP tool under the name `<app.id>__<action.name>`. For `app.id = \"shop\"` and `action = \"searchProducts\"` the agent sees `shop__searchProducts`. Multiple apps can coexist - see [multi-app coexistence](/protocol/security/#multi-app-coexistence).\n\n## Invocation wire format\n\nRequest from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"method\": \"actions/invoke\",\n \"params\": {\n \"name\": \"addItem\",\n \"invocationId\": \"inv_abc123\",\n \"input\": { \"sku\": \"SKU-1\", \"quantity\": 2 },\n \"client\": { \"route\": \"/cart\" }\n }\n}\n```\n\nThe SDK turns `params` into an `ActionContext` and calls your handler. `ctx.agent`, `ctx.agentCapabilities`, and the rest of `ctx.client` come from the `welcome` the SDK cached at handshake time - they don't ride on every `actions/invoke`. Response:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"result\": { \"cartId\": \"c_1\", \"itemId\": \"i_42\" }\n}\n```\n\nOr, on error:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"error\": { \"code\": -32005, \"message\": \"Cart is locked\", \"data\": { \"cartId\": \"c_1\" } }\n}\n```\n\n## Validation passes\n\n- **Input** is validated **before** the handler runs. Failure → error code `-32004 InputValidation`, handler never fires. Validation `issues` are returned in `error.data`.\n- **Output** is **not** validated by default. Call `.strictOutput()` to enforce - failure becomes `-32005 HandlerError` with issues in `data`. The permissive default is deliberate: output schemas are often loose, and most teams use `.output()` purely for documentation.\n\n## Annotations\n\n| Field | Meaning |\n|---|---|\n| `readOnly` | The action doesn't mutate state. The agent may parallelise or cache. |\n| `destructive` | The action mutates persistent state. Agents SHOULD surface a confirmation UI. |\n| `requiresConfirmation` | The action MUST NOT be called without explicit user confirmation. |\n\nAnnotations are **advisory**. They ride along with the MCP tool descriptor; honouring them is the agent's job.\n\n## What the handler receives\n\n```ts\nhandler: (input: I, ctx: ActionContext) => O | Promise<O>\n```\n\n`ctx` is the per-invocation context. Full reference in [SDK → Context](/sdk/typescript/context/):\n\n| Field | Purpose |\n|---|---|\n| `ctx.signal` | `AbortSignal` fired on timeout or cancel. |\n| `ctx.agent` | `{ id, name }` of the calling agent. |\n| `ctx.agentCapabilities` | What the agent can do. Gate your sampling / elicit calls on this. |\n| `ctx.client` | `{ origin, route?, userAgent? }`. |\n| `ctx.progress(update)` | Emit an `actions/progress` notification. |\n| `ctx.sample(req)` | Re-enter the agent LLM for a reasoning step. |\n| `ctx.confirm(req)` | Ask the user a yes/no question. Returns `false` when the client can't prompt. |\n| `ctx.elicit(req)` | Ask the user for structured content matching a schema. |\n| `ctx.log({ level, message, meta? })` | Structured log forwarded to MCP logging. |\n\nNext: [progress & cancellation](/protocol/progress-cancellation/).\n","bodyText":"An **action** is a named, typed, handler-backed operation that the app exposes to the agent. On the MCP side it looks like a single tool. On the Tesseron side it has a schema, a handler, and a set of per-invocation context helpers.\n\n## Declaration\n\n```ts\ntesseron\n .action('addItem') // action name\n .describe('Add an item to the cart')\n .input(z.object({ sku: z.string(), quantity: z.number().int().positive() }))\n .output(z.object({ cartId: z.string(), itemId: z.string() }))\n .annotate({ destructive: false })\n .timeout({ ms: 10_000 })\n .handler(async ({ sku, quantity }, ctx) => {\n const item = await cart.add(sku, quantity);\n return { cartId: cart.id, itemId: item.id };\n });\n```\n\nBuilder steps:\n\n| Step | Purpose | Required? |\n|---|---|---|\n| `.describe(string)` | Human-readable description shown to the agent's LLM. | Recommended |\n| `.input(schema)` | Standard Schema validator for arguments. Becomes JSON Schema on the wire. | Recommended |\n| `.output(schema)` | Validator for the return value. Not enforced by default. | Optional |\n| `.annotate({…})` | Metadata: `readOnly`, `destructive`, `requiresConfirmation`. | Optional |\n| `.timeout(ms)` | Abort the invocation after N ms. Default 60 000. | Optional |\n| `.strictOutput()` | Enforce the output schema. Default is passthrough. | Optional |\n| `.handler(fn)` | The function that runs. Receives `(input, ctx)`. Terminates the builder. | Required |\n\n## Naming and the MCP tool list\n\nThe MCP gateway registers every action as an MCP tool under the name `<app.id>__<action.name>`. For `app.id = \"shop\"` and `action = \"searchProducts\"` the agent sees `shop__searchProducts`. Multiple apps can coexist - see [multi-app coexistence](/protocol/security/#multi-app-coexistence).\n\n## Invocation wire format\n\nRequest from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"method\": \"actions/invoke\",\n \"params\": {\n \"name\": \"addItem\",\n \"invocationId\": \"inv_abc123\",\n \"input\": { \"sku\": \"SKU-1\", \"quantity\": 2 },\n \"client\": { \"route\": \"/cart\" }\n }\n}\n```\n\nThe SDK turns `params` into an `ActionContext` and calls your handler. `ctx.agent`, `ctx.agentCapabilities`, and the rest of `ctx.client` come from the `welcome` the SDK cached at handshake time - they don't ride on every `actions/invoke`. Response:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"result\": { \"cartId\": \"c_1\", \"itemId\": \"i_42\" }\n}\n```\n\nOr, on error:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"error\": { \"code\": -32005, \"message\": \"Cart is locked\", \"data\": { \"cartId\": \"c_1\" } }\n}\n```\n\n## Validation passes\n\n- **Input** is validated **before** the handler runs. Failure → error code `-32004 InputValidation`, handler never fires. Validation `issues` are returned in `error.data`.\n- **Output** is **not** validated by default. Call `.strictOutput()` to enforce - failure becomes `-32005 HandlerError` with issues in `data`. The permissive default is deliberate: output schemas are often loose, and most teams use `.output()` purely for documentation.\n\n## Annotations\n\n| Field | Meaning |\n|---|---|\n| `readOnly` | The action doesn't mutate state. The agent may parallelise or cache. |\n| `destructive` | The action mutates persistent state. Agents SHOULD surface a confirmation UI. |\n| `requiresConfirmation` | The action MUST NOT be called without explicit user confirmation. |\n\nAnnotations are **advisory**. They ride along with the MCP tool descriptor; honouring them is the agent's job.\n\n## What the handler receives\n\n```ts\nhandler: (input: I, ctx: ActionContext) => O | Promise<O>\n```\n\n`ctx` is the per-invocation context. Full reference in [SDK → Context](/sdk/typescript/context/):\n\n| Field | Purpose |\n|---|---|\n| `ctx.signal` | `AbortSignal` fired on timeout or cancel. |\n| `ctx.agent` | `{ id, name }` of the calling agent. |\n| `ctx.agentCapabilities` | What the agent can do. Gate your sampling / elicit calls on this. |\n| `ctx.client` | `{ origin, route?, userAgent? }`. |\n| `ctx.progress(update)` | Emit an `actions/progress` notification. |\n| `ctx.sample(req)` | Re-enter the agent LLM for a reasoning step. |\n| `ctx.confirm(req)` | Ask the user a yes/no question. Returns `false` when the client can't prompt. |\n| `ctx.elicit(req)` | Ask the user for structured content matching a schema. |\n| `ctx.log({ level, message, meta? })` | Structured log forwarded to MCP logging. |\n\nNext: [progress & cancellation](/protocol/progress-cancellation/)."},{"slug":"protocol/elicitation","title":"Elicitation","description":"Handlers pause to ask the user a question. Two verbs - ctx.confirm for yes/no, ctx.elicit for structured content.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\n**Elicitation** is sampling's human sibling. Instead of the LLM generating the next value, the user is prompted through the agent UI and submits the answer themselves.\n\nTesseron exposes two verbs on `ctx`, mapping onto MCP elicit's two orthogonal return fields (`action`, `content`):\n\n- **`ctx.confirm({ question })`** returns `Promise<boolean>`. For yes/no safety gates. No schema.\n- **`ctx.elicit({ question, schema, jsonSchema? })`** returns `Promise<T | null>`. For structured content.\n\nPick by intent: a destructive-op gate is a confirm; a \"which warehouse?\" is an elicit.\n\n## ctx.confirm - yes/no safety gates\n\n```ts\ntesseron.action('clearCompleted')\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (_input, ctx) => {\n const ok = await ctx.confirm({\n question: 'Remove 5 completed todos? This cannot be undone.',\n });\n if (!ok) return { removed: 0, cancelled: true };\n // ... proceed\n });\n```\n\nReturns `true` only on explicit accept. Decline, cancel, and absence of elicitation capability all collapse to `false` - the safe default for destructive ops. You don't need to guard with `ctx.agentCapabilities.elicitation`; `confirm` returns `false` when the client can't prompt.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties JSON Schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render a pure Accept/Decline prompt with no input field.\n\n## ctx.elicit - structured content\n\n```ts\nimport { z } from 'zod';\n\nconst warehouseSchema = z.object({ warehouseId: z.string() });\n\ntesseron.action('checkStock')\n .handler(async (_input, ctx) => {\n const answer = await ctx.elicit({\n question: 'Which warehouse should I check?',\n schema: warehouseSchema,\n jsonSchema: z.toJSONSchema(warehouseSchema),\n });\n if (answer === null) return { cancelled: true };\n return stock.lookup(answer.warehouseId);\n });\n```\n\nReturns the validated value on accept, `null` on decline or cancel. Throws `ElicitationNotAvailableError` (code `-32007`) when the client didn't advertise elicitation - structured data has no safe default, so the handler must branch explicitly.\n\n`jsonSchema` is technically optional; if you omit it, the SDK sends a permissive text-only fallback (`{ response: string }`), which Claude Code renders as a single text input. For good UX, always derive it from your validator - Zod 4 has `z.toJSONSchema(schema)` built in.\n\nMCP elicit constrains `requestedSchema`:\n\n- Top level must be `{ type: \"object\" }`.\n- Each property must be a primitive type (`string`, `number`, `integer`, `boolean`).\n- No `oneOf` / `anyOf` / `allOf` / `not` at the top level.\n\nThe SDK enforces this on send and surfaces an `InvalidParams` error (code `-32602`) at the `ctx.elicit` call site if you send something else.\n\n<Sequence\n caption=\"ctx.confirm and ctx.elicit share the same wire flow - the difference is the requestedSchema they send.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.confirm / ctx.elicit', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'u', label: 'USER', icon: 'user' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'elicitation/request { question, schema }', accent: true },\n { from: 'g', to: 'a', label: 'MCP elicitation/elicit', accent: true },\n { from: 'a', to: 'u', label: 'shows form or Accept/Decline', style: 'dashed' },\n { from: 'u', to: 'a', label: 'submits or declines', style: 'dashed' },\n { from: 'a', to: 'g', label: 'elicitation result', accent: true },\n { from: 'g', to: 's', label: '{ action, value? }', accent: true },\n ]}\n/>\n\n## Wire format\n\nRequest:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"method\": \"elicitation/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"question\": \"Which warehouse should I check?\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": { \"warehouseId\": { \"type\": \"string\" } },\n \"required\": [\"warehouseId\"]\n }\n }\n}\n```\n\nResponse (accept):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"result\": { \"action\": \"accept\", \"value\": { \"warehouseId\": \"WH-7\" } }\n}\n```\n\nResponse (decline / cancel):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 11, \"result\": { \"action\": \"decline\" } }\n```\n\nThe SDK maps `action: 'accept'` to the validated value, `decline` / `cancel` to `null` (for `ctx.elicit`) or `false` (for `ctx.confirm`).\n\n## Capability gate\n\n`ctx.agentCapabilities.elicitation` reflects what the connected MCP client advertised during `initialize`. Claude Code advertises elicitation; earlier clients may not.\n\n- `ctx.confirm` is safe in any handler: missing capability returns `false`, which destructive-op guards treat correctly.\n- `ctx.elicit` throws `ElicitationNotAvailableError` when capability is missing - catch it or pre-check the flag and provide a non-interactive fallback.\n\n## Design hints\n\n- **One question per call.** Don't pack a wizard into a schema - chain actions instead.\n- **Use annotations in tandem.** `{ destructive: true, requiresConfirmation: true }` tells the agent to warn upfront; `ctx.confirm` is what gates.\n- **Avoid chained elicitations** in one handler - latency accumulates. If you need multi-step input, build a dedicated action per step.\n\nNext: [resources](/protocol/resources/) - state the agent can read and subscribe to.\n","bodyText":"**Elicitation** is sampling's human sibling. Instead of the LLM generating the next value, the user is prompted through the agent UI and submits the answer themselves.\n\nTesseron exposes two verbs on `ctx`, mapping onto MCP elicit's two orthogonal return fields (`action`, `content`):\n\n- **`ctx.confirm({ question })`** returns `Promise<boolean>`. For yes/no safety gates. No schema.\n- **`ctx.elicit({ question, schema, jsonSchema? })`** returns `Promise<T | null>`. For structured content.\n\nPick by intent: a destructive-op gate is a confirm; a \"which warehouse?\" is an elicit.\n\n## ctx.confirm - yes/no safety gates\n\n```ts\ntesseron.action('clearCompleted')\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (_input, ctx) => {\n const ok = await ctx.confirm({\n question: 'Remove 5 completed todos? This cannot be undone.',\n });\n if (!ok) return { removed: 0, cancelled: true };\n // ... proceed\n });\n```\n\nReturns `true` only on explicit accept. Decline, cancel, and absence of elicitation capability all collapse to `false` - the safe default for destructive ops. You don't need to guard with `ctx.agentCapabilities.elicitation`; `confirm` returns `false` when the client can't prompt.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties JSON Schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render a pure Accept/Decline prompt with no input field.\n\n## ctx.elicit - structured content\n\n```ts\n\nconst warehouseSchema = z.object({ warehouseId: z.string() });\n\ntesseron.action('checkStock')\n .handler(async (_input, ctx) => {\n const answer = await ctx.elicit({\n question: 'Which warehouse should I check?',\n schema: warehouseSchema,\n jsonSchema: z.toJSONSchema(warehouseSchema),\n });\n if (answer === null) return { cancelled: true };\n return stock.lookup(answer.warehouseId);\n });\n```\n\nReturns the validated value on accept, `null` on decline or cancel. Throws `ElicitationNotAvailableError` (code `-32007`) when the client didn't advertise elicitation - structured data has no safe default, so the handler must branch explicitly.\n\n`jsonSchema` is technically optional; if you omit it, the SDK sends a permissive text-only fallback (`{ response: string }`), which Claude Code renders as a single text input. For good UX, always derive it from your validator - Zod 4 has `z.toJSONSchema(schema)` built in.\n\nMCP elicit constrains `requestedSchema`:\n\n- Top level must be `{ type: \"object\" }`.\n- Each property must be a primitive type (`string`, `number`, `integer`, `boolean`).\n- No `oneOf` / `anyOf` / `allOf` / `not` at the top level.\n\nThe SDK enforces this on send and surfaces an `InvalidParams` error (code `-32602`) at the `ctx.elicit` call site if you send something else.\n\n## Wire format\n\nRequest:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"method\": \"elicitation/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"question\": \"Which warehouse should I check?\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": { \"warehouseId\": { \"type\": \"string\" } },\n \"required\": [\"warehouseId\"]\n }\n }\n}\n```\n\nResponse (accept):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"result\": { \"action\": \"accept\", \"value\": { \"warehouseId\": \"WH-7\" } }\n}\n```\n\nResponse (decline / cancel):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 11, \"result\": { \"action\": \"decline\" } }\n```\n\nThe SDK maps `action: 'accept'` to the validated value, `decline` / `cancel` to `null` (for `ctx.elicit`) or `false` (for `ctx.confirm`).\n\n## Capability gate\n\n`ctx.agentCapabilities.elicitation` reflects what the connected MCP client advertised during `initialize`. Claude Code advertises elicitation; earlier clients may not.\n\n- `ctx.confirm` is safe in any handler: missing capability returns `false`, which destructive-op guards treat correctly.\n- `ctx.elicit` throws `ElicitationNotAvailableError` when capability is missing - catch it or pre-check the flag and provide a non-interactive fallback.\n\n## Design hints\n\n- **One question per call.** Don't pack a wizard into a schema - chain actions instead.\n- **Use annotations in tandem.** `{ destructive: true, requiresConfirmation: true }` tells the agent to warn upfront; `ctx.confirm` is what gates.\n- **Avoid chained elicitations** in one handler - latency accumulates. If you need multi-step input, build a dedicated action per step.\n\nNext: [resources](/protocol/resources/) - state the agent can read and subscribe to."},{"slug":"protocol/errors","title":"Errors & capabilities","description":"Every error code Tesseron defines, what raises each one, and how capability negotiation shapes handler behaviour.","section":"protocol","related":["protocol/wire-format","protocol/handshake"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nTesseron uses JSON-RPC error codes with a Tesseron-specific extension range.\n\n<Sequence\n caption=\"A validation error path. The handler never runs; the agent gets structured issues it can correct.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call arguments: { query: 42 }' },\n { from: 'g', to: 's', label: 'actions/invoke' },\n { note: 's', label: 'validate input (Standard Schema)', variant: 'danger' },\n { from: 's', to: 'g', label: 'error -32004 InputValidation data: [issues]', danger: true, style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call error (agent can retry with corrected args)', danger: true, style: 'dashed' },\n ]}\n/>\n\n## Error catalog\n\n| Code | Name | Raised when |\n|---:|---|---|\n| `-32700` | `ParseError` | JSON-RPC message failed to parse. Almost always a bug or a non-JSON frame. |\n| `-32600` | `InvalidRequest` | Envelope is well-formed JSON but not a valid JSON-RPC request. |\n| `-32601` | `MethodNotFound` | Method isn't registered. Tesseron's method surface is fixed - this is almost always a version mismatch. |\n| `-32602` | `InvalidParams` | Params don't match the method's expected shape. |\n| `-32603` | `InternalError` | Unhandled exception inside the SDK or gateway. Report it. |\n| `-32000` | `ProtocolMismatch` | `tesseron/hello` sent a `protocolVersion` the gateway doesn't accept. |\n| `-32001` | `Cancelled` | Invocation was cancelled by the agent. |\n| `-32002` | `Timeout` | Invocation exceeded its timeout. |\n| `-32003` | `ActionNotFound` | Agent called an action that isn't registered for this session. |\n| `-32004` | `InputValidation` | Input failed Standard Schema validation. Issues in `error.data`. |\n| `-32005` | `HandlerError` | Handler threw, or output failed strict validation. Message comes from the thrown error. |\n| `-32006` | `SamplingNotAvailable` | Handler called `ctx.sample` but agent didn't advertise sampling. |\n| `-32007` | `ElicitationNotAvailable` | Handler called `ctx.elicit` but agent didn't advertise elicitation. (`ctx.confirm` returns `false` instead of throwing — safe default for destructive gates.) |\n| `-32008` | `SamplingDepthExceeded` | Sampling chain exceeded `maxSamplingDepth` (3). |\n| `-32009` | `Unauthorized` | Wrong claim code, unclaimed session invoking action, or origin not allowlisted. |\n\nErrors carry an optional `data` field. Tesseron uses it to attach:\n\n- **For `-32004` InputValidation**: the `issues` array from Standard Schema.\n- **For `-32005` HandlerError** with strict output: the `issues` for the failed output check.\n- **For `-32008` SamplingDepthExceeded**: `{ depth, max }`.\n\n## Capability negotiation\n\nBoth sides declare capabilities during the handshake. The `welcome` response contains their intersection - that's what your handler should trust.\n\n| Capability | Meaning |\n|---|---|\n| `streaming` | `actions/progress` notifications are allowed. |\n| `subscriptions` | Agent will call `resources/subscribe`. |\n| `sampling` | `ctx.sample` is available. |\n| `elicitation` | `ctx.confirm` and `ctx.elicit` are available. |\n\nYour handler, in general:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n return fallbackResponse();\n}\nconst refined = await ctx.sample({ prompt, schema });\n```\n\nIf you'd rather error out than fall back, just call `ctx.sample` unconditionally - the SDK throws `SamplingNotAvailableError` (code `-32006`) which the agent sees as a structured tool failure.\n\n## Errors are data, not disasters\n\nAgents are good at recovering from structured errors. Prefer returning a well-typed error to throwing a vague one:\n\n- Bad input? Let Standard Schema reject it - the agent gets issues to correct.\n- Impossible state? `throw new Error(\"Cart is locked; ask the user to unlock it\")` surfaces as code `-32005` with a useful message.\n- Need clarification? Use [elicitation](/protocol/elicitation/) instead of failing.\n\nNext: [lifecycle & failure modes](/protocol/lifecycle/).\n","bodyText":"Tesseron uses JSON-RPC error codes with a Tesseron-specific extension range.\n\n## Error catalog\n\n| Code | Name | Raised when |\n|---:|---|---|\n| `-32700` | `ParseError` | JSON-RPC message failed to parse. Almost always a bug or a non-JSON frame. |\n| `-32600` | `InvalidRequest` | Envelope is well-formed JSON but not a valid JSON-RPC request. |\n| `-32601` | `MethodNotFound` | Method isn't registered. Tesseron's method surface is fixed - this is almost always a version mismatch. |\n| `-32602` | `InvalidParams` | Params don't match the method's expected shape. |\n| `-32603` | `InternalError` | Unhandled exception inside the SDK or gateway. Report it. |\n| `-32000` | `ProtocolMismatch` | `tesseron/hello` sent a `protocolVersion` the gateway doesn't accept. |\n| `-32001` | `Cancelled` | Invocation was cancelled by the agent. |\n| `-32002` | `Timeout` | Invocation exceeded its timeout. |\n| `-32003` | `ActionNotFound` | Agent called an action that isn't registered for this session. |\n| `-32004` | `InputValidation` | Input failed Standard Schema validation. Issues in `error.data`. |\n| `-32005` | `HandlerError` | Handler threw, or output failed strict validation. Message comes from the thrown error. |\n| `-32006` | `SamplingNotAvailable` | Handler called `ctx.sample` but agent didn't advertise sampling. |\n| `-32007` | `ElicitationNotAvailable` | Handler called `ctx.elicit` but agent didn't advertise elicitation. (`ctx.confirm` returns `false` instead of throwing — safe default for destructive gates.) |\n| `-32008` | `SamplingDepthExceeded` | Sampling chain exceeded `maxSamplingDepth` (3). |\n| `-32009` | `Unauthorized` | Wrong claim code, unclaimed session invoking action, or origin not allowlisted. |\n\nErrors carry an optional `data` field. Tesseron uses it to attach:\n\n- **For `-32004` InputValidation**: the `issues` array from Standard Schema.\n- **For `-32005` HandlerError** with strict output: the `issues` for the failed output check.\n- **For `-32008` SamplingDepthExceeded**: `{ depth, max }`.\n\n## Capability negotiation\n\nBoth sides declare capabilities during the handshake. The `welcome` response contains their intersection - that's what your handler should trust.\n\n| Capability | Meaning |\n|---|---|\n| `streaming` | `actions/progress` notifications are allowed. |\n| `subscriptions` | Agent will call `resources/subscribe`. |\n| `sampling` | `ctx.sample` is available. |\n| `elicitation` | `ctx.confirm` and `ctx.elicit` are available. |\n\nYour handler, in general:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n return fallbackResponse();\n}\nconst refined = await ctx.sample({ prompt, schema });\n```\n\nIf you'd rather error out than fall back, just call `ctx.sample` unconditionally - the SDK throws `SamplingNotAvailableError` (code `-32006`) which the agent sees as a structured tool failure.\n\n## Errors are data, not disasters\n\nAgents are good at recovering from structured errors. Prefer returning a well-typed error to throwing a vague one:\n\n- Bad input? Let Standard Schema reject it - the agent gets issues to correct.\n- Impossible state? `throw new Error(\"Cart is locked; ask the user to unlock it\")` surfaces as code `-32005` with a useful message.\n- Need clarification? Use [elicitation](/protocol/elicitation/) instead of failing.\n\nNext: [lifecycle & failure modes](/protocol/lifecycle/)."},{"slug":"protocol/handshake","title":"Handshake & claiming","description":"How a WebSocket becomes a bound session - tesseron/hello, welcome, claim code, and tools/list_changed.","section":"protocol","related":["protocol/wire-format","protocol/transport","protocol/security","protocol/lifecycle"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nA Tesseron session goes through three states: **connected**, **awaiting claim**, **claimed**. Only claimed sessions can have their actions invoked.\n\n<Sequence\n caption=\"From page load to first tool call.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 'app', label: 'WEB APP', sub: '@tesseron/web', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: '@tesseron/mcp', icon: 'bridge', variant: 'accent' },\n { id: 'u', label: 'USER', icon: 'user' },\n { id: 'a', label: 'AGENT', sub: 'Claude Code', icon: 'agent' },\n ]}\n steps={[\n { from: 'app', to: 'gw', label: 'tesseron/hello { app, actions, resources, caps }' },\n { from: 'gw', to: 'app', label: \"tesseron/welcome { sessionId, claimCode: 'AB3X-7K' }\", style: 'dashed' },\n { from: 'gw', to: 'u', label: 'claim code (web UI or stdout)', style: 'dashed' },\n { from: 'u', to: 'a', label: 'connect AB3X-7K' },\n { from: 'a', to: 'gw', label: 'tools/call tesseron__claim_session', accent: true },\n { from: 'gw', to: 'a', label: 'notifications/tools/list_changed', style: 'dashed' },\n { from: 'a', to: 'gw', label: 'tools/call shop__searchProducts' },\n { from: 'gw', to: 'app', label: 'actions/invoke { invocationId, input }' },\n { from: 'app', to: 'gw', label: 'result', style: 'dashed' },\n { from: 'gw', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## The `tesseron/hello` request\n\nSent by the app right after the WebSocket opens.\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/hello\",\n \"params\": {\n \"protocolVersion\": \"1.1.0\",\n \"app\": {\n \"id\": \"shop\",\n \"name\": \"Acme Shop\",\n \"description\": \"Product catalog and cart\",\n \"origin\": \"http://localhost:3000\",\n \"version\": \"1.0.0\",\n \"iconUrl\": \"https://shop.example/icon.svg\"\n },\n \"actions\": [\n {\n \"name\": \"searchProducts\",\n \"description\": \"Search the product catalog\",\n \"inputSchema\": { /* JSON Schema */ },\n \"outputSchema\": { /* JSON Schema, optional */ },\n \"annotations\": { \"readOnly\": true },\n \"timeoutMs\": 60000\n }\n ],\n \"resources\": [\n { \"name\": \"currentRoute\", \"description\": \"URL the user is viewing\", \"subscribable\": true }\n ],\n \"capabilities\": {\n \"streaming\": true,\n \"subscriptions\": true,\n \"sampling\": true,\n \"elicitation\": true\n }\n }\n}\n```\n\nRules:\n\n- `app.id` must match `/^[a-z][a-z0-9_]*$/`. It becomes the prefix on every MCP tool this app contributes.\n- `app.origin` is informational; the MCP gateway treats its own origin check (see [Transport](/protocol/transport/)) as authoritative.\n- Action `inputSchema` / `outputSchema` are JSON Schema. The SDK derives them from your Standard Schema validator where possible, or you can pass them explicitly.\n- `capabilities` is what the **app** can do, not what the agent can do - that comes back in `welcome`.\n\n## The `welcome` response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"protocolVersion\": \"1.1.0\",\n \"capabilities\": { \"streaming\": true, \"subscriptions\": true, \"sampling\": true, \"elicitation\": true },\n \"agent\": { \"id\": \"pending\", \"name\": \"Awaiting agent\" },\n \"claimCode\": \"AB3X-7K\"\n }\n}\n```\n\n- `sessionId` is opaque and only meaningful to the gateway - log it for debugging.\n- `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.\n- `agent` stays at `{ id: \"pending\", name: \"Awaiting agent\" }` until a claim happens.\n- `claimCode` is a 6-character human-friendly string like `AB3X-7K`. Alphanumerics minus visually confusing characters.\n\n## Claiming\n\nThe 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:\n\n1. The gateway prints it to stderr (which Claude Code surfaces).\n2. The app is free to render it in its UI - e.g. a \"Connect Claude\" button that reveals the code.\n\nThe user then tells the agent:\n\n> Claim Tesseron session AB3X-7K\n\nThe 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:\n\n- Marks the session `claimed: true`.\n- Sets `agent` on the session to the agent's identity.\n- Emits `notifications/tools/list_changed` so the agent refreshes its tool list.\n- Sends a `tesseron/claimed` notification to the SDK (see below) so the app can clear the spent claim code from its UI.\n- From this point, `tools/call <app_id>__<action>` is allowed.\n\nIf the code doesn't match (expired, wrong app, already used): error `-32009 Unauthorized`.\n\n## The `tesseron/claimed` notification\n\nOnce 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).\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"tesseron/claimed\",\n \"params\": {\n \"agent\": { \"id\": \"claude-code\", \"name\": \"Claude Code\" },\n \"claimedAt\": 1714145210123\n }\n}\n```\n\n`@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.\n\nThe 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.\n\n## Why out-of-band claim?\n\nBecause 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 browser tab 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).\n\n## Multiple gateways on one machine\n\nA 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.\n\n**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](https://github.com/BrainBlend-AI/tesseron/issues/60).\n\n**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.\n\nIn 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:\n\n```json\n{\n \"version\": 1,\n \"code\": \"AB3X-7K\",\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"appId\": \"shop\",\n \"appName\": \"Acme Shop\",\n \"gatewayPid\": 12345,\n \"mintedAt\": 1714145210123\n}\n```\n\nA 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.\n\nThis 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.\n\n## Protocol version mismatch\n\nThe MCP gateway parses `protocolVersion` as `major.minor`:\n\n- **Different major** → hard reject with error code `-32000 ProtocolMismatch` and the WebSocket is closed.\n- **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.\n- **Exact match** → no logging.\n\n```json\n{ \"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.\" } }\n```\n\nNext: the [action model](/protocol/actions/).\n","bodyText":"A Tesseron session goes through three states: **connected**, **awaiting claim**, **claimed**. Only claimed sessions can have their actions invoked.\n\n## The `tesseron/hello` request\n\nSent by the app right after the WebSocket opens.\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/hello\",\n \"params\": {\n \"protocolVersion\": \"1.1.0\",\n \"app\": {\n \"id\": \"shop\",\n \"name\": \"Acme Shop\",\n \"description\": \"Product catalog and cart\",\n \"origin\": \"http://localhost:3000\",\n \"version\": \"1.0.0\",\n \"iconUrl\": \"https://shop.example/icon.svg\"\n },\n \"actions\": [\n {\n \"name\": \"searchProducts\",\n \"description\": \"Search the product catalog\",\n \"inputSchema\": { /* JSON Schema */ },\n \"outputSchema\": { /* JSON Schema, optional */ },\n \"annotations\": { \"readOnly\": true },\n \"timeoutMs\": 60000\n }\n ],\n \"resources\": [\n { \"name\": \"currentRoute\", \"description\": \"URL the user is viewing\", \"subscribable\": true }\n ],\n \"capabilities\": {\n \"streaming\": true,\n \"subscriptions\": true,\n \"sampling\": true,\n \"elicitation\": true\n }\n }\n}\n```\n\nRules:\n\n- `app.id` must match `/^[a-z][a-z0-9_]*$/`. It becomes the prefix on every MCP tool this app contributes.\n- `app.origin` is informational; the MCP gateway treats its own origin check (see [Transport](/protocol/transport/)) as authoritative.\n- Action `inputSchema` / `outputSchema` are JSON Schema. The SDK derives them from your Standard Schema validator where possible, or you can pass them explicitly.\n- `capabilities` is what the **app** can do, not what the agent can do - that comes back in `welcome`.\n\n## The `welcome` response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"protocolVersion\": \"1.1.0\",\n \"capabilities\": { \"streaming\": true, \"subscriptions\": true, \"sampling\": true, \"elicitation\": true },\n \"agent\": { \"id\": \"pending\", \"name\": \"Awaiting agent\" },\n \"claimCode\": \"AB3X-7K\"\n }\n}\n```\n\n- `sessionId` is opaque and only meaningful to the gateway - log it for debugging.\n- `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.\n- `agent` stays at `{ id: \"pending\", name: \"Awaiting agent\" }` until a claim happens.\n- `claimCode` is a 6-character human-friendly string like `AB3X-7K`. Alphanumerics minus visually confusing characters.\n\n## Claiming\n\nThe 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:\n\n1. The gateway prints it to stderr (which Claude Code surfaces).\n2. The app is free to render it in its UI - e.g. a \"Connect Claude\" button that reveals the code.\n\nThe user then tells the agent:\n\n> Claim Tesseron session AB3X-7K\n\nThe 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:\n\n- Marks the session `claimed: true`.\n- Sets `agent` on the session to the agent's identity.\n- Emits `notifications/tools/list_changed` so the agent refreshes its tool list.\n- Sends a `tesseron/claimed` notification to the SDK (see below) so the app can clear the spent claim code from its UI.\n- From this point, `tools/call <app_id>__<action>` is allowed.\n\nIf the code doesn't match (expired, wrong app, already used): error `-32009 Unauthorized`.\n\n## The `tesseron/claimed` notification\n\nOnce 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).\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"tesseron/claimed\",\n \"params\": {\n \"agent\": { \"id\": \"claude-code\", \"name\": \"Claude Code\" },\n \"claimedAt\": 1714145210123\n }\n}\n```\n\n`@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.\n\nThe 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.\n\n## Why out-of-band claim?\n\nBecause 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 browser tab 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).\n\n## Multiple gateways on one machine\n\nA 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.\n\n**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](https://github.com/BrainBlend-AI/tesseron/issues/60).\n\n**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.\n\nIn 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:\n\n```json\n{\n \"version\": 1,\n \"code\": \"AB3X-7K\",\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"appId\": \"shop\",\n \"appName\": \"Acme Shop\",\n \"gatewayPid\": 12345,\n \"mintedAt\": 1714145210123\n}\n```\n\nA 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.\n\nThis 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.\n\n## Protocol version mismatch\n\nThe MCP gateway parses `protocolVersion` as `major.minor`:\n\n- **Different major** → hard reject with error code `-32000 ProtocolMismatch` and the WebSocket is closed.\n- **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.\n- **Exact match** → no logging.\n\n```json\n{ \"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.\" } }\n```\n\nNext: the [action model](/protocol/actions/)."},{"slug":"protocol/index","title":"Protocol overview","description":"The Tesseron protocol in one page - wire format, transport, handshake, action model, MCP capabilities, errors, lifecycle.","section":"protocol","related":["protocol/wire-format","protocol/transport","protocol/handshake","protocol/actions","protocol/errors","protocol/lifecycle"],"bodyRaw":"\nimport { Aside, Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Sequence from '../../../components/Sequence.astro';\n\n<Aside type=\"note\" title=\"Spec license\">\nThe Tesseron protocol specification (every page under `docs/protocol/`) is licensed **CC BY 4.0** — independent from the reference implementation. You are free to build a compatible implementation in any language for any purpose, including commercially, with attribution. See [`LICENSE`](https://github.com/BrainBlend-AI/tesseron/blob/main/docs/src/content/docs/protocol/LICENSE) in the protocol directory.\n</Aside>\n\nTesseron speaks **JSON-RPC 2.0 over a reliable, ordered, duplex channel** between your app and the MCP gateway, then the gateway bridges that to **MCP over stdio** for the agent. One action round-trip crosses both protocols.\n\nThe channel is a **transport binding** - WebSocket by default, Unix domain socket as an opt-in for Node apps that don't need the browser bridge. The protocol is binding-neutral; see [Transport](/protocol/transport/) for the contract every binding satisfies.\n\nThe protocol is at **version `1.1.0`**.\n\n<Sequence\n caption=\"A first-use session, start to finish: from WebSocket open to the first tool-call result returned to the agent.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 'app', label: 'WEB APP', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'u', label: 'USER', icon: 'user' },\n { id: 'a', label: 'AGENT', icon: 'agent' },\n ]}\n steps={[\n { from: 'app', to: 'gw', label: 'tesseron/hello { app, actions, caps }' },\n { from: 'gw', to: 'app', label: 'tesseron/welcome { sessionId, claimCode }', style: 'dashed' },\n { from: 'gw', to: 'u', label: 'claim code (stdout / web UI)', style: 'dashed' },\n { from: 'u', to: 'a', label: 'connect AB3X-7K' },\n { from: 'a', to: 'gw', label: 'tools/call tesseron__claim_session', accent: true },\n { from: 'gw', to: 'a', label: 'notifications/tools/list_changed', style: 'dashed' },\n { from: 'a', to: 'gw', label: 'tools/call shop__searchProducts' },\n { from: 'gw', to: 'app', label: 'actions/invoke { invocationId, input }' },\n { from: 'app', to: 'gw', label: 'result', style: 'dashed' },\n { from: 'gw', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## Read the pages in order\n\n<CardGrid>\n <LinkCard title=\"Wire format (JSON-RPC)\" href=\"./wire-format/\"\n description=\"Envelope shapes, methods, notifications, ID correlation.\" />\n <LinkCard title=\"Transport\" href=\"./transport/\"\n description=\"The binding-neutral channel contract, plus per-binding pages for WebSocket and Unix domain sockets.\" />\n <LinkCard title=\"Handshake & claiming\" href=\"./handshake/\"\n description=\"`tesseron/hello` → `welcome` → claim code → bound session.\" />\n <LinkCard title=\"Action model\" href=\"./actions/\"\n description=\"How actions are declared, invoked, validated, and namespaced.\" />\n <LinkCard title=\"Progress & cancellation\" href=\"./progress-cancellation/\"\n description=\"Streaming updates; `AbortSignal`-based cancellation.\" />\n <LinkCard title=\"Sampling\" href=\"./sampling/\"\n description=\"Handlers re-enter the agent LLM for a reasoning step.\" />\n <LinkCard title=\"Elicitation\" href=\"./elicitation/\"\n description=\"Handlers pause and ask the user a question via the agent UI.\" />\n <LinkCard title=\"Resources\" href=\"./resources/\"\n description=\"Typed, readable, optionally subscribable state projected to the agent.\" />\n <LinkCard title=\"Errors & capabilities\" href=\"./errors/\"\n description=\"Error codes, capability negotiation, not-available paths.\" />\n <LinkCard title=\"Lifecycle & failure modes\" href=\"./lifecycle/\"\n description=\"Disconnect, reconnect, timeout, MCP gateway restart, tab close.\" />\n <LinkCard title=\"Security model\" href=\"./security/\"\n description=\"Origin allowlist, claim codes, multi-app namespacing.\" />\n</CardGrid>\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.1.0` |\n| Discovery directory (v2) | `~/.tesseron/instances/` |\n| Discovery directory (v1, compat) | `~/.tesseron/tabs/` |\n| Manifest version | `2` |\n| WebSocket subprotocol (gateway side) | `tesseron-gateway` |\n| UDS framing | NDJSON (one JSON-RPC message per `\\n`) |\n| Default action timeout | `60_000` ms |\n| Max sampling depth | `3` |\n| Tool name pattern | `<app_id>__<action_name>` |\n| Resource URI pattern | `tesseron://<app_id>/<resource_name>` |\n| `app.id` validator | `/^[a-z][a-z0-9_]*$/` |\n\nAll of these are **observable from the wire** - they're not SDK-specific. A Python or Go SDK implementing Tesseron MUST match them exactly.\n","bodyText":"Tesseron speaks **JSON-RPC 2.0 over a reliable, ordered, duplex channel** between your app and the MCP gateway, then the gateway bridges that to **MCP over stdio** for the agent. One action round-trip crosses both protocols.\n\nThe channel is a **transport binding** - WebSocket by default, Unix domain socket as an opt-in for Node apps that don't need the browser bridge. The protocol is binding-neutral; see [Transport](/protocol/transport/) for the contract every binding satisfies.\n\nThe protocol is at **version `1.1.0`**.\n\n## Read the pages in order\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.1.0` |\n| Discovery directory (v2) | `~/.tesseron/instances/` |\n| Discovery directory (v1, compat) | `~/.tesseron/tabs/` |\n| Manifest version | `2` |\n| WebSocket subprotocol (gateway side) | `tesseron-gateway` |\n| UDS framing | NDJSON (one JSON-RPC message per `\\n`) |\n| Default action timeout | `60_000` ms |\n| Max sampling depth | `3` |\n| Tool name pattern | `<app_id>__<action_name>` |\n| Resource URI pattern | `tesseron://<app_id>/<resource_name>` |\n| `app.id` validator | `/^[a-z][a-z0-9_]*$/` |\n\nAll of these are **observable from the wire** - they're not SDK-specific. A Python or Go SDK implementing Tesseron MUST match them exactly."},{"slug":"protocol/lifecycle","title":"Lifecycle & failure modes","description":"The session state machine, and what happens to pending work at every transition.","section":"protocol","related":["protocol/handshake","protocol/resume","protocol/transport"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nEvery WebSocket connection to the MCP gateway produces a session that walks this state machine:\n\n<Diagram\n caption=\"Five states. Only Claimed can run actions. Every terminal transition aborts in-flight work.\"\n nodeWidth={130}\n nodeHeight={100}\n spacing={50}\n pad={44}\n nodes={[\n { id: 'dc', label: 'DISCONNECTED', icon: 'x' },\n { id: 'hs', label: 'HANDSHAKING', icon: 'arrow' },\n { id: 'aw', label: 'AWAITING', sub: 'claim pending', icon: 'lock' },\n { id: 'cl', label: 'CLAIMED', sub: 'tools exposed', icon: 'check', variant: 'accent' },\n { id: 'end', label: 'CLOSED', icon: 'x', variant: 'danger' },\n ]}\n edges={[\n { from: 'dc', to: 'hs', label: 'ws open' },\n { from: 'hs', to: 'aw', label: 'welcome' },\n { from: 'aw', to: 'cl', label: 'claim ok', accent: true },\n { from: 'aw', to: 'end', label: 'timeout', style: 'dashed', danger: true },\n { from: 'cl', to: 'end', label: 'ws close', style: 'dashed', danger: true },\n ]}\n/>\n\n## States\n\n- **Disconnected** - no WebSocket yet. Tooling surface shows no tools for this app.\n- **Handshaking** - WebSocket open, `tesseron/hello` in flight.\n- **Awaiting claim** - `welcome` sent with a `claimCode`. Actions are registered in the gateway but *not* exposed as MCP tools until claim.\n- **Claimed** - agent has submitted a matching claim. Tool list is published. Actions can be invoked.\n- **Closed** - WebSocket closed. Session forgotten by the gateway.\n\n## Transitions\n\n| From → To | Trigger | Side effects |\n|---|---|---|\n| Disconnected → Handshaking | App opens WebSocket. | `tesseron/hello` sent. |\n| Handshaking → Awaiting claim | MCP gateway returns `welcome`. | Claim code generated + printed to gateway stderr. |\n| Awaiting claim → Claimed | Agent calls `tesseron__claim_session` with matching code. | `notifications/tools/list_changed` fires. |\n| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |\n| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; `tools/list_changed` fires so the agent drops stale tools. |\n\nThe TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.\n\n## What pending work does on close\n\nFrom inside your handler, on any Closed transition:\n\n- `ctx.signal.aborted` becomes `true`.\n- `ctx.progress(…)` after close is silently dropped.\n- `ctx.sample(…)` / `ctx.confirm(…)` / `ctx.elicit(…)` in flight reject with `TransportClosedError`.\n- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.\n\n**Handler best practices:**\n\n```ts\n.handler(async (input, ctx) => {\n const abortable = new AbortController();\n ctx.signal.addEventListener('abort', () => abortable.abort());\n try {\n return await longWork(input, { signal: abortable.signal });\n } finally {\n abortable.abort(); // release any resources even on normal exit\n }\n});\n```\n\n## Reconnection doesn't resume - it starts over\n\n`tesseron.connect()` after a disconnect yields a **new** `sessionId` and **new** `claimCode`. The agent must re-claim. In-flight work from the old session is gone.\n\nWhy not resumable? Two reasons:\n\n1. The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.\n2. Claim is meant to be a user-visible act. An invisible reconnection would bypass the \"human-in-the-loop authorisation\" the claim code represents.\n\n## MCP gateway restart\n\nIf the gateway process dies (plugin disabled, Claude Code restart, crash):\n\n- Every app's WebSocket closes with code 1001 (`Going Away`).\n- Every SDK instance aborts in-flight work and rejects pending requests.\n- Apps are free to `connect()` again when the gateway comes back.\n\nA small \"reconnect\" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.\n\nNext: the [security model](/protocol/security/).\n","bodyText":"Every WebSocket connection to the MCP gateway produces a session that walks this state machine:\n\n## States\n\n- **Disconnected** - no WebSocket yet. Tooling surface shows no tools for this app.\n- **Handshaking** - WebSocket open, `tesseron/hello` in flight.\n- **Awaiting claim** - `welcome` sent with a `claimCode`. Actions are registered in the gateway but *not* exposed as MCP tools until claim.\n- **Claimed** - agent has submitted a matching claim. Tool list is published. Actions can be invoked.\n- **Closed** - WebSocket closed. Session forgotten by the gateway.\n\n## Transitions\n\n| From → To | Trigger | Side effects |\n|---|---|---|\n| Disconnected → Handshaking | App opens WebSocket. | `tesseron/hello` sent. |\n| Handshaking → Awaiting claim | MCP gateway returns `welcome`. | Claim code generated + printed to gateway stderr. |\n| Awaiting claim → Claimed | Agent calls `tesseron__claim_session` with matching code. | `notifications/tools/list_changed` fires. |\n| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |\n| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; `tools/list_changed` fires so the agent drops stale tools. |\n\nThe TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.\n\n## What pending work does on close\n\nFrom inside your handler, on any Closed transition:\n\n- `ctx.signal.aborted` becomes `true`.\n- `ctx.progress(…)` after close is silently dropped.\n- `ctx.sample(…)` / `ctx.confirm(…)` / `ctx.elicit(…)` in flight reject with `TransportClosedError`.\n- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.\n\n**Handler best practices:**\n\n```ts\n.handler(async (input, ctx) => {\n const abortable = new AbortController();\n ctx.signal.addEventListener('abort', () => abortable.abort());\n try {\n return await longWork(input, { signal: abortable.signal });\n } finally {\n abortable.abort(); // release any resources even on normal exit\n }\n});\n```\n\n## Reconnection doesn't resume - it starts over\n\n`tesseron.connect()` after a disconnect yields a **new** `sessionId` and **new** `claimCode`. The agent must re-claim. In-flight work from the old session is gone.\n\nWhy not resumable? Two reasons:\n\n1. The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.\n2. Claim is meant to be a user-visible act. An invisible reconnection would bypass the \"human-in-the-loop authorisation\" the claim code represents.\n\n## MCP gateway restart\n\nIf the gateway process dies (plugin disabled, Claude Code restart, crash):\n\n- Every app's WebSocket closes with code 1001 (`Going Away`).\n- Every SDK instance aborts in-flight work and rejects pending requests.\n- Apps are free to `connect()` again when the gateway comes back.\n\nA small \"reconnect\" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.\n\nNext: the [security model](/protocol/security/)."},{"slug":"protocol/progress-cancellation","title":"Progress & cancellation","description":"Streaming updates via `actions/progress` and AbortSignal-based cancellation via `actions/cancel`.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nLong-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.\n\n## Streaming progress\n\n<Sequence\n caption=\"Progress notifications ride along while the handler runs. The agent's UI typically renders them as an animated status line.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call { ... }' },\n { from: 'g', to: 's', label: 'actions/invoke' },\n { from: 's', to: 'g', label: 'actions/progress { percent: 10 }', style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/progress', style: 'dashed', accent: true },\n { from: 's', to: 'g', label: 'actions/progress { percent: 60 }', style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/progress', style: 'dashed', accent: true },\n { from: 's', to: 'g', label: 'result { ... }', style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n```ts\ntesseron.action('importCsv')\n .input(z.object({ url: z.string().url() }))\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url);\n\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('Cancelled');\n ctx.progress({\n message: `${i}/${rows.length}`,\n percent: 5 + Math.floor((i / rows.length) * 90),\n });\n await importBatch(rows.slice(i, i + 100));\n }\n\n return { imported: rows.length };\n });\n```\n\nWire format - sent by the app as a notification (no response):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"message\": \"500/2000\",\n \"percent\": 27,\n \"data\": { \"etaMs\": 14000 }\n }\n}\n```\n\nAll three payload fields (`message`, `percent`, `data`) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP `notifications/progress`; MCP clients render them at their leisure.\n\n**Guideline:** cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.\n\n## Cancellation\n\n<Sequence\n caption=\"The agent cancels. The gateway translates to actions/cancel. The handler sees ctx.signal.aborted.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call { ... }' },\n { from: 'g', to: 's', label: \"actions/invoke { invocationId: 'inv_1' }\" },\n { note: 's', label: 'handler running (reads ctx.signal)' },\n { from: 'a', to: 'g', label: 'cancel invocation', style: 'dashed', danger: true },\n { from: 'g', to: 's', label: \"actions/cancel { invocationId: 'inv_1' }\", style: 'dashed', danger: true },\n { note: 's', label: 'ctx.signal.aborted = true', variant: 'danger' },\n { from: 's', to: 'g', label: 'error -32001 Cancelled', style: 'dashed', danger: true },\n { from: 'g', to: 'a', label: 'tools/call error', style: 'dashed', danger: true },\n ]}\n/>\n\n```ts\ntesseron.action('generateReport')\n .input(...)\n .handler(async (input, ctx) => {\n const rows = await slowQuery(ctx.signal); // pass signal down\n if (ctx.signal.aborted) throw new Cancelled();\n return formatReport(rows);\n });\n```\n\n- `ctx.signal` is a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Pass it to `fetch`, `setTimeout`, database drivers, or anything else that accepts one.\n- Cancellation fires for **two reasons**: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.\n- After abort, the SDK returns an error response with code `-32001 Cancelled` (explicit) or `-32002 Timeout` (timer).\n- **The wire is freed at the deadline regardless of the handler.** The SDK races the handler against the abort signal, so a handler stuck inside a non-signal-aware promise (`modern-screenshot.domToPng`, `<canvas>.toBlob`, `<img>.decode`, `document.fonts.ready`, `Audio.play`, `MediaRecorder`, ...) doesn't pin the agent's `tools/call` indefinitely - the orphaned handler keeps running, but the agent has already received its error response. To bound an individual stuck call from inside the handler, use [`ctx.withTimeout(p, ms)`](/sdk/typescript/context/#ctxwithtimeout-drop-stuck-inner-promises).\n\nWire format - notification from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/cancel\",\n \"params\": { \"invocationId\": \"inv_abc\" }\n}\n```\n\nThe app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.\n\n## Reading the progress on the agent side\n\nWhen the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as \"best-effort hint\".\n\n## What NOT to use progress for\n\n- Final results. Use the response.\n- Data the next handler needs. Use a return value, a sampling round-trip, or a resource.\n- Error surfacing. Return an error response.\n\nNext: [sampling](/protocol/sampling/) - handlers that call back into the LLM.\n","bodyText":"Long-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.\n\n## Streaming progress\n\n```ts\ntesseron.action('importCsv')\n .input(z.object({ url: z.string().url() }))\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url);\n\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('Cancelled');\n ctx.progress({\n message: `${i}/${rows.length}`,\n percent: 5 + Math.floor((i / rows.length) * 90),\n });\n await importBatch(rows.slice(i, i + 100));\n }\n\n return { imported: rows.length };\n });\n```\n\nWire format - sent by the app as a notification (no response):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"message\": \"500/2000\",\n \"percent\": 27,\n \"data\": { \"etaMs\": 14000 }\n }\n}\n```\n\nAll three payload fields (`message`, `percent`, `data`) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP `notifications/progress`; MCP clients render them at their leisure.\n\n**Guideline:** cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.\n\n## Cancellation\n\n```ts\ntesseron.action('generateReport')\n .input(...)\n .handler(async (input, ctx) => {\n const rows = await slowQuery(ctx.signal); // pass signal down\n if (ctx.signal.aborted) throw new Cancelled();\n return formatReport(rows);\n });\n```\n\n- `ctx.signal` is a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Pass it to `fetch`, `setTimeout`, database drivers, or anything else that accepts one.\n- Cancellation fires for **two reasons**: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.\n- After abort, the SDK returns an error response with code `-32001 Cancelled` (explicit) or `-32002 Timeout` (timer).\n- **The wire is freed at the deadline regardless of the handler.** The SDK races the handler against the abort signal, so a handler stuck inside a non-signal-aware promise (`modern-screenshot.domToPng`, `<canvas>.toBlob`, `<img>.decode`, `document.fonts.ready`, `Audio.play`, `MediaRecorder`, ...) doesn't pin the agent's `tools/call` indefinitely - the orphaned handler keeps running, but the agent has already received its error response. To bound an individual stuck call from inside the handler, use [`ctx.withTimeout(p, ms)`](/sdk/typescript/context/#ctxwithtimeout-drop-stuck-inner-promises).\n\nWire format - notification from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/cancel\",\n \"params\": { \"invocationId\": \"inv_abc\" }\n}\n```\n\nThe app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.\n\n## Reading the progress on the agent side\n\nWhen the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as \"best-effort hint\".\n\n## What NOT to use progress for\n\n- Final results. Use the response.\n- Data the next handler needs. Use a return value, a sampling round-trip, or a resource.\n- Error surfacing. Return an error response.\n\nNext: [sampling](/protocol/sampling/) - handlers that call back into the LLM."},{"slug":"protocol/resources","title":"Resources","description":"Typed, readable, optionally subscribable state your app projects to the agent.","section":"protocol","related":["sdk/typescript/resources","protocol/wire-format"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nA **resource** is a named piece of app state the agent can read - and optionally subscribe to for push updates. Resources complement actions: actions cause changes, resources expose what changed.\n\n<Sequence\n caption=\"Agent reads once, then subscribes. When the app's value changes, the SDK pushes a resources/updated notification.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'RESOURCE HANDLER', icon: 'database' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'resources/read tesseron://shop/route' },\n { from: 'g', to: 's', label: 'resources/read' },\n { from: 's', to: 'g', label: \"{ value: '/cart' }\", style: 'dashed' },\n { from: 'g', to: 'a', label: 'read result', style: 'dashed' },\n { from: 'a', to: 'g', label: 'resources/subscribe', accent: true },\n { from: 'g', to: 's', label: 'resources/subscribe', accent: true },\n { note: 's', label: 'register emit() callback' },\n { from: 's', to: 'g', label: \"resources/updated { value: '/checkout' }\", style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/resources/updated', style: 'dashed', accent: true },\n ]}\n/>\n\n## Declaration\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is currently viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `.read()` is a one-shot getter. Called on every `resources/read` the agent issues.\n- `.subscribe()` is optional. It registers an emitter; return an unsubscribe function so the SDK can clean up when the agent unsubscribes or the session closes.\n\n## URI convention\n\nResources are exposed to the agent with the URI `tesseron://<app_id>/<resource_name>`. For `app.id = \"shop\"` and `resource = \"currentRoute\"`, the agent sees `tesseron://shop/currentRoute`.\n\n## Reading from clients that don't speak MCP resources\n\nSome MCP clients don't surface `resources/read` to their model. The MCP gateway ships a meta-tool fallback:\n\n- **`tesseron__read_resource`** (`{ app_id, name }`) - returns the resource's current value as a tool-call result. Prefer this over the generic `ReadMcpResourceTool` because the agent doesn't have to know how the MCP server is namespaced on the client (e.g. `plugin:tesseron:tesseron` in Claude Code plugin installs vs. `tesseron` in a raw config).\n\n`tesseron__list_actions` enumerates every claimed session's resources and includes both the preferred `tesseron__read_resource` args and the `ReadMcpResourceTool` fallback.\n\n## Wire format\n\n### Read (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"method\": \"resources/read\", \"params\": { \"name\": \"currentRoute\" } }\n```\n\nResponse:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"result\": { \"value\": \"/checkout\" } }\n```\n\n### Subscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"method\": \"resources/subscribe\", \"params\": { \"name\": \"currentRoute\", \"subscriptionId\": \"sub_1\" } }\n```\n\nResponse is empty - the SDK just acknowledges and now holds the emitter callback.\n\n### Update (app → gateway, notification)\n\nEach time the emitter fires:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub_1\", \"value\": \"/cart\" }\n}\n```\n\nThe gateway forwards this as MCP `notifications/resources/updated` to the agent.\n\n### Unsubscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"method\": \"resources/unsubscribe\", \"params\": { \"subscriptionId\": \"sub_1\" } }\n```\n\nThe SDK calls the unsubscribe function returned by your `.subscribe()` handler.\n\n### List changed (app → gateway, notification)\n\nIf your app registers or removes resources after the initial `tesseron/hello`, the SDK emits `resources/list_changed` with the new manifest. The gateway forwards this as MCP `notifications/resources/list_changed` so agents can refetch the list. `actions/list_changed` follows the same pattern for dynamic action sets.\n\n## Patterns\n\n### Read-only projection\n\n```ts\ntesseron.resource('filterState').read(() => ({\n search: state.search,\n onlyDone: state.onlyDone,\n}));\n```\n\nPerfect for letting the agent reason about \"what's the user currently looking at\" before proposing actions.\n\n### Debounced subscription\n\nDon't emit on every keystroke - the agent can't meaningfully react at that rate.\n\n```ts\ntesseron.resource('search')\n .read(() => state.search)\n .subscribe((emit) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const onChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => emit(state.search), 250);\n };\n state.on('change', onChange);\n return () => { if (timer) clearTimeout(timer); state.off('change', onChange); };\n });\n```\n\n### Large or expensive resources\n\nIf the value is expensive to produce, remember that `.read()` runs every time the agent fetches. Cache inside the handler, or use `.subscribe()` as the source of truth and cache the latest emitted value in-memory.\n\n## Capability gate\n\nSubscriptions require `agentCapabilities.subscriptions`. Reads do not. If the agent can't subscribe, it will only call `resources/read` and your `.subscribe()` handler is never invoked.\n\nNext: the full [error catalog and capability negotiation](/protocol/errors/).\n","bodyText":"A **resource** is a named piece of app state the agent can read - and optionally subscribe to for push updates. Resources complement actions: actions cause changes, resources expose what changed.\n\n## Declaration\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is currently viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `.read()` is a one-shot getter. Called on every `resources/read` the agent issues.\n- `.subscribe()` is optional. It registers an emitter; return an unsubscribe function so the SDK can clean up when the agent unsubscribes or the session closes.\n\n## URI convention\n\nResources are exposed to the agent with the URI `tesseron://<app_id>/<resource_name>`. For `app.id = \"shop\"` and `resource = \"currentRoute\"`, the agent sees `tesseron://shop/currentRoute`.\n\n## Reading from clients that don't speak MCP resources\n\nSome MCP clients don't surface `resources/read` to their model. The MCP gateway ships a meta-tool fallback:\n\n- **`tesseron__read_resource`** (`{ app_id, name }`) - returns the resource's current value as a tool-call result. Prefer this over the generic `ReadMcpResourceTool` because the agent doesn't have to know how the MCP server is namespaced on the client (e.g. `plugin:tesseron:tesseron` in Claude Code plugin installs vs. `tesseron` in a raw config).\n\n`tesseron__list_actions` enumerates every claimed session's resources and includes both the preferred `tesseron__read_resource` args and the `ReadMcpResourceTool` fallback.\n\n## Wire format\n\n### Read (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"method\": \"resources/read\", \"params\": { \"name\": \"currentRoute\" } }\n```\n\nResponse:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"result\": { \"value\": \"/checkout\" } }\n```\n\n### Subscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"method\": \"resources/subscribe\", \"params\": { \"name\": \"currentRoute\", \"subscriptionId\": \"sub_1\" } }\n```\n\nResponse is empty - the SDK just acknowledges and now holds the emitter callback.\n\n### Update (app → gateway, notification)\n\nEach time the emitter fires:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub_1\", \"value\": \"/cart\" }\n}\n```\n\nThe gateway forwards this as MCP `notifications/resources/updated` to the agent.\n\n### Unsubscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"method\": \"resources/unsubscribe\", \"params\": { \"subscriptionId\": \"sub_1\" } }\n```\n\nThe SDK calls the unsubscribe function returned by your `.subscribe()` handler.\n\n### List changed (app → gateway, notification)\n\nIf your app registers or removes resources after the initial `tesseron/hello`, the SDK emits `resources/list_changed` with the new manifest. The gateway forwards this as MCP `notifications/resources/list_changed` so agents can refetch the list. `actions/list_changed` follows the same pattern for dynamic action sets.\n\n## Patterns\n\n### Read-only projection\n\n```ts\ntesseron.resource('filterState').read(() => ({\n search: state.search,\n onlyDone: state.onlyDone,\n}));\n```\n\nPerfect for letting the agent reason about \"what's the user currently looking at\" before proposing actions.\n\n### Debounced subscription\n\nDon't emit on every keystroke - the agent can't meaningfully react at that rate.\n\n```ts\ntesseron.resource('search')\n .read(() => state.search)\n .subscribe((emit) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const onChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => emit(state.search), 250);\n };\n state.on('change', onChange);\n return () => { if (timer) clearTimeout(timer); state.off('change', onChange); };\n });\n```\n\n### Large or expensive resources\n\nIf the value is expensive to produce, remember that `.read()` runs every time the agent fetches. Cache inside the handler, or use `.subscribe()` as the source of truth and cache the latest emitted value in-memory.\n\n## Capability gate\n\nSubscriptions require `agentCapabilities.subscriptions`. Reads do not. If the agent can't subscribe, it will only call `resources/read` and your `.subscribe()` handler is never invoked.\n\nNext: the full [error catalog and capability negotiation](/protocol/errors/)."},{"slug":"protocol/resume","title":"Session resume","description":"How a Tesseron app rejoins a previously-claimed session after a transport drop via tesseron/resume - protocol shape, gateway behaviour, and the 4-line localStorage recipe.","section":"protocol","related":["protocol/handshake","protocol/transport","protocol/wire-format","protocol/lifecycle"],"bodyRaw":"\nA Tesseron session lives in the gateway's memory. When the underlying WebSocket drops (tab refresh, window close, network blip, HMR reload), the session normally goes 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.\n\nThe `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.\n\n## Flow\n\n1. On a fresh `tesseron/hello`, the gateway returns a `resumeToken` in the welcome. Stash it alongside the `sessionId` wherever fits your app.\n2. When the transport drops, the gateway keeps the session's metadata as a \"zombie\" for `resumeTtlMs` (default 90 seconds).\n3. 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.\n4. The caller persists the **new** `resumeToken` from the resume response.\n\nResume 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.\n\n## The `tesseron/resume` request\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/resume\",\n \"params\": {\n \"protocolVersion\": \"1.1.0\",\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"resumeToken\": \"Xk9f3nN9kOeGqR7mWpLc2v\",\n \"app\": { \"id\": \"shop\", \"name\": \"Acme Shop\", \"origin\": \"http://localhost:3000\" },\n \"actions\": [ /* same shape as tesseron/hello */ ],\n \"resources\": [ /* same shape as tesseron/hello */ ],\n \"capabilities\": {\n \"streaming\": true,\n \"subscriptions\": true,\n \"sampling\": true,\n \"elicitation\": true\n }\n }\n}\n```\n\n`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.\n\n## The resume response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"protocolVersion\": \"1.1.0\",\n \"capabilities\": { \"streaming\": true, \"subscriptions\": true, \"sampling\": true, \"elicitation\": true },\n \"agent\": { \"id\": \"claude-ai\", \"name\": \"Claude Desktop\" },\n \"resumeToken\": \"NEW_ROTATED_TOKEN_VALUE\"\n }\n}\n```\n\n- `sessionId` matches the one in the request (same session, reattached to the fresh socket).\n- `resumeToken` is rotated. Overwrite whatever you stashed with this new value.\n- `claimCode` is **omitted** - the session is already claimed, no need for a re-pair.\n\n## Failure modes\n\nAll resume failures surface as a `TesseronError` with code `TesseronErrorCode.ResumeFailed` (`-32011`). Callers typically catch the error and fall back to a plain `tesseron/hello`.\n\n| Condition | Message pattern |\n|---|---|\n| Unknown `sessionId` | `No resumable session \"<id>\"` |\n| Cross-app resume | `Session \"<id>\" is owned by app \"<other>\"` |\n| Unclaimed zombie | `<id> was never claimed` |\n| TTL elapsed | Falls under \"no resumable session\" - the zombie was already evicted |\n| Wrong `resumeToken` | `Invalid resumeToken for session \"<id>\"` |\n| Malformed params (missing `app`, non-string `sessionId` / `resumeToken`, missing `actions` / `resources` / `capabilities`) | `Invalid tesseron/resume request: expected { protocolVersion, sessionId, resumeToken, app, actions, resources, capabilities }` |\n| Protocol major-version mismatch | Same rules as `tesseron/hello` - throws `ProtocolMismatch` |\n\nToken comparison uses `crypto.timingSafeEqual` with a length pre-check, so a wildly-wrong token doesn't leak timing information about the correct length.\n\n## Gateway configuration\n\nTwo knobs on `new TesseronGateway({ ... })` shape how aggressive resume is:\n\n```ts\nconst gateway = new TesseronGateway({\n resumeTtlMs: 300_000, // 5 minutes (default: 90_000)\n maxZombies: 500, // cap on zombies held simultaneously (default: 100)\n});\n```\n\n- `resumeTtlMs` — how long a closed session is retained as a resumable zombie. Set to `0` to disable resume entirely: closed sessions drop immediately and any reconnect must start fresh.\n- `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`).\n\n## Idiomatic SDK usage\n\nThe SDK exposes a single `resume` field on `ConnectOptions`. You decide where to stash the token:\n\n```ts\nimport { tesseron } from '@tesseron/web';\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\ntesseron.action('searchProducts').handler(/* ... */);\n\nconst saved = localStorage.getItem('tesseron:shop');\nconst welcome = await tesseron.connect(\n undefined, // defaults to the @tesseron/vite bridge at `<origin>/@tesseron/ws`\n saved ? { resume: JSON.parse(saved) } : undefined,\n);\n\nlocalStorage.setItem('tesseron:shop', JSON.stringify({\n sessionId: welcome.sessionId,\n resumeToken: welcome.resumeToken,\n}));\n```\n\nFour lines. The SDK does not do this for you; `localStorage` is one answer among many. A desktop app might stash the pair in the OS keychain. A server process might put it in a file next to its config. An iframe-embedded app might have CSP reasons not to persist at all.\n\nIf you're using [`@tesseron/react`](/sdk/typescript/react/), the `useTesseronConnection` hook bakes in this recipe behind a `resume` option - pass `resume: true` to persist via `localStorage` automatically, or hand it a `ResumeStorage` object for custom backends.\n\n### Falling back when resume fails\n\n```ts\ntry {\n await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);\n} catch (err) {\n if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {\n localStorage.removeItem('tesseron:shop');\n await tesseron.connect(url); // fresh hello\n } else {\n throw err;\n }\n}\n```\n\n## What resume does **not** do\n\n- 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](/protocol/lifecycle/)) and can retry at its own layer.\n- It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.\n- 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.\n- 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.\n\n## See also\n\n- [Handshake & claiming](/protocol/handshake/) - the `tesseron/hello` flow resume complements.\n- [Lifecycle & failure modes](/protocol/lifecycle/) - how the gateway behaves during drops, retries, and gateway restarts.\n- [Errors & capabilities](/protocol/errors/) - the full `TesseronErrorCode` table including `ResumeFailed`.\n","bodyText":"A Tesseron session lives in the gateway's memory. When the underlying WebSocket drops (tab refresh, window close, network blip, HMR reload), the session normally goes 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.\n\nThe `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.\n\n## Flow\n\n1. On a fresh `tesseron/hello`, the gateway returns a `resumeToken` in the welcome. Stash it alongside the `sessionId` wherever fits your app.\n2. When the transport drops, the gateway keeps the session's metadata as a \"zombie\" for `resumeTtlMs` (default 90 seconds).\n3. 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.\n4. The caller persists the **new** `resumeToken` from the resume response.\n\nResume 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.\n\n## The `tesseron/resume` request\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/resume\",\n \"params\": {\n \"protocolVersion\": \"1.1.0\",\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"resumeToken\": \"Xk9f3nN9kOeGqR7mWpLc2v\",\n \"app\": { \"id\": \"shop\", \"name\": \"Acme Shop\", \"origin\": \"http://localhost:3000\" },\n \"actions\": [ /* same shape as tesseron/hello */ ],\n \"resources\": [ /* same shape as tesseron/hello */ ],\n \"capabilities\": {\n \"streaming\": true,\n \"subscriptions\": true,\n \"sampling\": true,\n \"elicitation\": true\n }\n }\n}\n```\n\n`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.\n\n## The resume response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"protocolVersion\": \"1.1.0\",\n \"capabilities\": { \"streaming\": true, \"subscriptions\": true, \"sampling\": true, \"elicitation\": true },\n \"agent\": { \"id\": \"claude-ai\", \"name\": \"Claude Desktop\" },\n \"resumeToken\": \"NEW_ROTATED_TOKEN_VALUE\"\n }\n}\n```\n\n- `sessionId` matches the one in the request (same session, reattached to the fresh socket).\n- `resumeToken` is rotated. Overwrite whatever you stashed with this new value.\n- `claimCode` is **omitted** - the session is already claimed, no need for a re-pair.\n\n## Failure modes\n\nAll resume failures surface as a `TesseronError` with code `TesseronErrorCode.ResumeFailed` (`-32011`). Callers typically catch the error and fall back to a plain `tesseron/hello`.\n\n| Condition | Message pattern |\n|---|---|\n| Unknown `sessionId` | `No resumable session \"<id>\"` |\n| Cross-app resume | `Session \"<id>\" is owned by app \"<other>\"` |\n| Unclaimed zombie | `<id> was never claimed` |\n| TTL elapsed | Falls under \"no resumable session\" - the zombie was already evicted |\n| Wrong `resumeToken` | `Invalid resumeToken for session \"<id>\"` |\n| Malformed params (missing `app`, non-string `sessionId` / `resumeToken`, missing `actions` / `resources` / `capabilities`) | `Invalid tesseron/resume request: expected { protocolVersion, sessionId, resumeToken, app, actions, resources, capabilities }` |\n| Protocol major-version mismatch | Same rules as `tesseron/hello` - throws `ProtocolMismatch` |\n\nToken comparison uses `crypto.timingSafeEqual` with a length pre-check, so a wildly-wrong token doesn't leak timing information about the correct length.\n\n## Gateway configuration\n\nTwo knobs on `new TesseronGateway({ ... })` shape how aggressive resume is:\n\n```ts\nconst gateway = new TesseronGateway({\n resumeTtlMs: 300_000, // 5 minutes (default: 90_000)\n maxZombies: 500, // cap on zombies held simultaneously (default: 100)\n});\n```\n\n- `resumeTtlMs` — how long a closed session is retained as a resumable zombie. Set to `0` to disable resume entirely: closed sessions drop immediately and any reconnect must start fresh.\n- `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`).\n\n## Idiomatic SDK usage\n\nThe SDK exposes a single `resume` field on `ConnectOptions`. You decide where to stash the token:\n\n```ts\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\ntesseron.action('searchProducts').handler(/* ... */);\n\nconst saved = localStorage.getItem('tesseron:shop');\nconst welcome = await tesseron.connect(\n undefined, // defaults to the @tesseron/vite bridge at `<origin>/@tesseron/ws`\n saved ? { resume: JSON.parse(saved) } : undefined,\n);\n\nlocalStorage.setItem('tesseron:shop', JSON.stringify({\n sessionId: welcome.sessionId,\n resumeToken: welcome.resumeToken,\n}));\n```\n\nFour lines. The SDK does not do this for you; `localStorage` is one answer among many. A desktop app might stash the pair in the OS keychain. A server process might put it in a file next to its config. An iframe-embedded app might have CSP reasons not to persist at all.\n\nIf you're using [`@tesseron/react`](/sdk/typescript/react/), the `useTesseronConnection` hook bakes in this recipe behind a `resume` option - pass `resume: true` to persist via `localStorage` automatically, or hand it a `ResumeStorage` object for custom backends.\n\n### Falling back when resume fails\n\n```ts\ntry {\n await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);\n} catch (err) {\n if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {\n localStorage.removeItem('tesseron:shop');\n await tesseron.connect(url); // fresh hello\n } else {\n throw err;\n }\n}\n```\n\n## What resume does **not** do\n\n- 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](/protocol/lifecycle/)) and can retry at its own layer.\n- It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.\n- 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.\n- 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.\n\n## See also\n\n- [Handshake & claiming](/protocol/handshake/) - the `tesseron/hello` flow resume complements.\n- [Lifecycle & failure modes](/protocol/lifecycle/) - how the gateway behaves during drops, retries, and gateway restarts.\n- [Errors & capabilities](/protocol/errors/) - the full `TesseronErrorCode` table including `ResumeFailed`."},{"slug":"protocol/sampling","title":"Sampling","description":"How a handler re-enters the agent's LLM for a reasoning step, and what the schema contract looks like.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\n**Sampling** lets an action handler ask the agent's LLM to produce a response mid-handler. The LLM is the agent's - not your own - so sampling doesn't require an API key from your side, and it counts against the user's agent budget.\n\n<Sequence\n caption=\"The handler re-enters the agent loop. The agent's LLM replies. The handler then validates the result against your schema.\"\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.sample(...)', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT + LLM', icon: 'agent' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'sampling/request { prompt, schema, maxTokens }' },\n { from: 'g', to: 'a', label: 'MCP sampling/createMessage' },\n { note: 'a', label: 'LLM generates response' },\n { from: 'a', to: 'g', label: 'sampling result', style: 'dashed' },\n { from: 'g', to: 's', label: '{ content }', style: 'dashed' },\n { note: 's', label: 'validate against schema -> parsed value', variant: 'accent' },\n ]}\n/>\n\n## When to use it\n\n- **Natural-language reformatting** - turn a list of rows into a readable summary.\n- **Classification** - given a free-text comment, pick a category from an enum.\n- **Structured extraction** - pull the fields your action needs out of a fuzzy input.\n\nDon't use sampling for:\n\n- Raw chatbot replies. Your action should have a clear return type.\n- Very long generations. Sampling is subject to depth limits (max 3 by default) and counts against the agent budget - keep it targeted.\n\n## Calling sample\n\n```ts\nimport { z } from 'zod';\n\ntesseron.action('classifyComment')\n .input(z.object({ text: z.string() }))\n .output(z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), confidence: z.number() }))\n .handler(async ({ text }, ctx) => {\n const result = await ctx.sample({\n prompt: `Classify the sentiment of this comment: \"\"\"${text}\"\"\"`,\n schema: z.object({\n sentiment: z.enum(['positive', 'neutral', 'negative']),\n confidence: z.number().min(0).max(1),\n }),\n maxTokens: 80,\n });\n return result;\n });\n```\n\n## Wire format\n\nRequest, app → gateway:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"method\": \"sampling/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"prompt\": \"Classify the sentiment …\",\n \"schema\": { \"type\": \"object\", \"properties\": { \"sentiment\": { \"enum\": [\"positive\", \"neutral\", \"negative\"] }, \"confidence\": { \"type\": \"number\" } } },\n \"maxTokens\": 80\n }\n}\n```\n\nResponse, gateway → app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"result\": { \"content\": { \"sentiment\": \"positive\", \"confidence\": 0.82 } }\n}\n```\n\nIf you passed a `schema`, the SDK validates `result.content` against it before returning from `ctx.sample`. If the model's response doesn't parse, you get a validation error and can retry.\n\n## Depth limit\n\nSampling is recursive by construction: the agent is a Claude session that called your action, and you're asking that same Claude to think again. Without a cap, a malicious or buggy chain could spiral.\n\nThe MCP gateway enforces `maxSamplingDepth = 3`. Each request from a handler that was itself invoked via sampling increments the counter. Exceeded → error `-32008 SamplingDepthExceeded`.\n\n## Capability gate\n\nNot every MCP client supports sampling. Before calling `ctx.sample`, check the capability:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n // Fall back: return something useful without the LLM.\n}\nconst result = await ctx.sample({ /* ... */ });\n```\n\nOr let the SDK throw `SamplingNotAvailableError` (error code `-32006`) and catch it. Pick whichever fits your UX.\n\n### Client compatibility\n\nSampling only works when the connected MCP client advertises `capabilities.sampling` during the MCP `initialize` handshake. Tesseron captures the client's capabilities at that point and flows them to every SDK session as `ctx.agentCapabilities.sampling` — so a handler always sees the real answer, even when a particular client (for example, Claude Code as of this writing) has not yet implemented `sampling/createMessage`. If a handler calls `ctx.sample()` anyway on such a client, the SDK throws a structured `SamplingNotAvailableError` including the client name (when available) instead of a raw JSON-RPC `-32601 Method not found`, so callers can branch on `error instanceof SamplingNotAvailableError` and return a graceful fallback.\n\nNext: [elicitation](/protocol/elicitation/) - same shape, but with the user instead of the model.\n","bodyText":"**Sampling** lets an action handler ask the agent's LLM to produce a response mid-handler. The LLM is the agent's - not your own - so sampling doesn't require an API key from your side, and it counts against the user's agent budget.\n\n<Sequence\n caption=\"The handler re-enters the agent loop. The agent's LLM replies. The handler then validates the result against your schema.\"\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.sample(...)', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT + LLM', icon: 'agent' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'sampling/request { prompt, schema, maxTokens }' },\n { from: 'g', to: 'a', label: 'MCP sampling/createMessage' },\n { note: 'a', label: 'LLM generates response' },\n { from: 'a', to: 'g', label: 'sampling result', style: 'dashed' },\n { from: 'g', to: 's', label: '{ content }', style: 'dashed' },\n { note: 's', label: 'validate against schema -> parsed value', variant: 'accent' },\n ]}\n/>\n\n## When to use it\n\n- **Natural-language reformatting** - turn a list of rows into a readable summary.\n- **Classification** - given a free-text comment, pick a category from an enum.\n- **Structured extraction** - pull the fields your action needs out of a fuzzy input.\n\nDon't use sampling for:\n\n- Raw chatbot replies. Your action should have a clear return type.\n- Very long generations. Sampling is subject to depth limits (max 3 by default) and counts against the agent budget - keep it targeted.\n\n## Calling sample\n\n```ts\n\ntesseron.action('classifyComment')\n .input(z.object({ text: z.string() }))\n .output(z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), confidence: z.number() }))\n .handler(async ({ text }, ctx) => {\n const result = await ctx.sample({\n prompt: `Classify the sentiment of this comment: \"\"\"${text}\"\"\"`,\n schema: z.object({\n sentiment: z.enum(['positive', 'neutral', 'negative']),\n confidence: z.number().min(0).max(1),\n }),\n maxTokens: 80,\n });\n return result;\n });\n```\n\n## Wire format\n\nRequest, app → gateway:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"method\": \"sampling/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"prompt\": \"Classify the sentiment …\",\n \"schema\": { \"type\": \"object\", \"properties\": { \"sentiment\": { \"enum\": [\"positive\", \"neutral\", \"negative\"] }, \"confidence\": { \"type\": \"number\" } } },\n \"maxTokens\": 80\n }\n}\n```\n\nResponse, gateway → app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"result\": { \"content\": { \"sentiment\": \"positive\", \"confidence\": 0.82 } }\n}\n```\n\nIf you passed a `schema`, the SDK validates `result.content` against it before returning from `ctx.sample`. If the model's response doesn't parse, you get a validation error and can retry.\n\n## Depth limit\n\nSampling is recursive by construction: the agent is a Claude session that called your action, and you're asking that same Claude to think again. Without a cap, a malicious or buggy chain could spiral.\n\nThe MCP gateway enforces `maxSamplingDepth = 3`. Each request from a handler that was itself invoked via sampling increments the counter. Exceeded → error `-32008 SamplingDepthExceeded`.\n\n## Capability gate\n\nNot every MCP client supports sampling. Before calling `ctx.sample`, check the capability:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n // Fall back: return something useful without the LLM.\n}\nconst result = await ctx.sample({ /* ... */ });\n```\n\nOr let the SDK throw `SamplingNotAvailableError` (error code `-32006`) and catch it. Pick whichever fits your UX.\n\n### Client compatibility\n\nSampling only works when the connected MCP client advertises `capabilities.sampling` during the MCP `initialize` handshake. Tesseron captures the client's capabilities at that point and flows them to every SDK session as `ctx.agentCapabilities.sampling` — so a handler always sees the real answer, even when a particular client (for example, Claude Code as of this writing) has not yet implemented `sampling/createMessage`. If a handler calls `ctx.sample()` anyway on such a client, the SDK throws a structured `SamplingNotAvailableError` including the client name (when available) instead of a raw JSON-RPC `-32601 Method not found`, so callers can branch on `error instanceof SamplingNotAvailableError` and return a graceful fallback.\n\nNext: [elicitation](/protocol/elicitation/) - same shape, but with the user instead of the model."},{"slug":"protocol/security","title":"Security model","description":"Origin allowlist, claim codes, multi-app namespacing, and the threats Tesseron does and does not defend against.","section":"protocol","related":["protocol/handshake","protocol/transport"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nTesseron's security model is **local-first, user-authorised**. The MCP gateway binds to localhost and won't expose any action until a human types a short code out-of-band. These are the two gates.\n\n## Gate 1 - loopback-only discovery\n\n<Diagram\n caption=\"Apps announce loopback URLs. The gateway only dials loopback. Nothing off the machine gets a connection.\"\n nodeWidth={200}\n spacing={80}\n nodes={[\n { id: 'app', label: 'YOUR APP', sub: 'binds loopback only', icon: 'window' },\n { id: 'file', label: 'INSTANCE', sub: '~/.tesseron/instances/', icon: 'bridge' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'transport client', icon: 'shield', variant: 'accent' },\n { id: 'evil', label: 'ATTACKER', sub: 'remote host', icon: 'x', variant: 'danger' },\n ]}\n edges={[\n { from: 'app', to: 'file', label: 'writes manifest', accent: true },\n { from: 'gw', to: 'file', label: 'reads', style: 'dashed' },\n { from: 'gw', to: 'app', label: 'dials advertised binding', accent: true },\n { from: 'evil', to: 'gw', label: 'no inbound port', danger: true, style: 'dashed' },\n ]}\n/>\n\nApps bind locally only - WebSocket servers on `127.0.0.1`, Unix domain sockets in private temp dirs. The gateway refuses non-loopback URLs read from `~/.tesseron/instances/` and rejects UDS paths it can't `connect()` to as the running user. The gateway itself binds no ports, so a remote attacker has nothing to dial. Every hop is on the machine.\n\nThis is defence-in-depth. A drive-by page on `evil.com` can't reach your app's server - it would have to resolve `127.0.0.1` from the browser's origin, which the Same-Origin Policy blocks by default, and even a successful connection attempt would still face the claim-code gate below.\n\n## Gate 2 - the claim code\n\nEven from a permitted origin, the session is inert until claimed. The flow:\n\n1. App connects and sends `tesseron/hello`.\n2. MCP gateway generates a random 6-char code (format `XXXX-YY`, excludes visually confusing characters) and returns it in `welcome`.\n3. The code is displayed out-of-band: gateway stderr, and wherever your app chooses to render it.\n4. The user types the code into the agent (\"connect Tesseron session AB3X-7K\").\n5. Agent calls `tesseron__claim_session`. If the code matches, the session transitions to `Claimed` and `tools/list_changed` fires.\n\nThe claim code is **never sent on the WebSocket from the gateway to the agent** - the user carries it across. That's the whole point: it's a human-performed authorisation gesture, not an electronic one.\n\nStrength: ~1.5 billion possible codes (6 positions × ~32 unambiguous alphanumerics), drawn uniformly from the platform CSPRNG (`crypto.getRandomValues`) with rejection sampling — not `Math.random()`, which is not cryptographically secure. A brute-force attacker would need ~750M tries for a 50% hit rate. Codes are single-use; a failed match does not retry.\n\n## Multi-app coexistence\n\n<Diagram\n caption=\"Two apps, one gateway. Tools are namespaced by app.id; actions route back to the declaring session.\"\n nodeWidth={200}\n spacing={80}\n nodes={[\n { id: 'appA', label: 'SHOP APP', sub: [\"app.id = 'shop'\", 'searchProducts, addItem'], icon: 'window' },\n { id: 'appB', label: 'ADMIN APP', sub: [\"app.id = 'admin'\", 'listUsers, banUser'], icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'routes by prefix', icon: 'bridge', variant: 'accent' },\n { id: 'agent', label: 'AGENT', sub: 'shop__* | admin__*', icon: 'agent' },\n ]}\n edges={[\n { from: 'appA', to: 'gw', label: 'hello [shop actions]' },\n { from: 'appB', to: 'gw', label: 'hello [admin actions]' },\n { from: 'gw', to: 'agent', label: 'tools/list', style: 'dashed', accent: true },\n { from: 'agent', to: 'gw', label: 'tools/call shop__addItem' },\n { from: 'gw', to: 'appA', label: 'actions/invoke' },\n { from: 'agent', to: 'gw', label: 'tools/call admin__banUser' },\n { from: 'gw', to: 'appB', label: 'actions/invoke' },\n ]}\n/>\n\nMultiple apps can be connected at once. Every MCP tool is prefixed with the `app.id`, so `shop__addItem` and `admin__banUser` never collide. Internally the gateway routes `tools/call name=shop__addItem` to the session whose `app.id === \"shop\"` - if that session has disconnected, the call errors with `-32003 ActionNotFound`.\n\nThis means you can, without coordinating, keep your dashboard and your customer app both connected to the same agent. Each has its own claim code, its own origin check, its own session.\n\n## What Tesseron does NOT defend against\n\n- **Malicious code running in your app's process.** If an attacker already executes JS in your tab or on your Node server, they can call your SDK and declare whatever actions they want. Tesseron is no worse - and no better - than the process it's embedded in.\n- **Malicious MCP clients on the same machine.** Any local process running as the same user can read `~/.tesseron/instances/` and dial one of the advertised endpoints. They would still need to send a valid `tesseron/hello` and convince the user to type the claim code into their agent. The claim code is the second gate, and it requires human cooperation - but it's the only thing stopping a rogue process from attaching to your session.\n- **Prompt injection.** If your handler's `description` or inputs are attacker-controlled, they can manipulate the agent's plans. Sanitise descriptions you show to the agent the same way you would sanitise HTML you show to users.\n- **Exfiltration via resources.** Anything you expose as a resource is readable by the claimed agent. Don't expose credentials, session tokens, or PII you haven't decided the user is okay sharing with Claude.\n\n## Operational tips\n\n- **Bind to `127.0.0.1`, never `0.0.0.0`.** The default in `@tesseron/server` and `@tesseron/vite` is loopback-only; don't override it unless you know exactly why. UDS hosts go through `os.tmpdir()` with a private (mode 0700) directory.\n- **`~/.tesseron/` files are written private (mode 0600 inside a 0700 directory).** Instance manifests and claim breadcrumbs are owner-only on POSIX. Sibling processes running as the same user can still open them — same-UID enforcement is the OS's job — but cross-user enumeration is closed. On Windows POSIX modes are advisory; the OS user model is the gate, same caveat as the UDS binding spec.\n- **Treat the claim code as short-lived.** Don't render it persistently in the UI after the session is claimed.\n- **Clean up instance manifests on app exit.** The built-in SDKs do this for you; if you port to a new runtime, handle the shutdown path so stale files don't pile up.\n- **Log the `agent.id` that claimed the session** - useful for auditing which agent actually ran which action.\n- **For production tools, use per-user app IDs.** `shop_kenny` vs `shop_sarah` prevents one user's agent from driving another user's tab, even if both are on the same machine.\n\nThat's the end of the Protocol section. The [SDK section](/sdk/) picks up from here - how to speak this protocol from TypeScript today and from other languages later.\n","bodyText":"Tesseron's security model is **local-first, user-authorised**. The MCP gateway binds to localhost and won't expose any action until a human types a short code out-of-band. These are the two gates.\n\n## Gate 1 - loopback-only discovery\n\nApps bind locally only - WebSocket servers on `127.0.0.1`, Unix domain sockets in private temp dirs. The gateway refuses non-loopback URLs read from `~/.tesseron/instances/` and rejects UDS paths it can't `connect()` to as the running user. The gateway itself binds no ports, so a remote attacker has nothing to dial. Every hop is on the machine.\n\nThis is defence-in-depth. A drive-by page on `evil.com` can't reach your app's server - it would have to resolve `127.0.0.1` from the browser's origin, which the Same-Origin Policy blocks by default, and even a successful connection attempt would still face the claim-code gate below.\n\n## Gate 2 - the claim code\n\nEven from a permitted origin, the session is inert until claimed. The flow:\n\n1. App connects and sends `tesseron/hello`.\n2. MCP gateway generates a random 6-char code (format `XXXX-YY`, excludes visually confusing characters) and returns it in `welcome`.\n3. The code is displayed out-of-band: gateway stderr, and wherever your app chooses to render it.\n4. The user types the code into the agent (\"connect Tesseron session AB3X-7K\").\n5. Agent calls `tesseron__claim_session`. If the code matches, the session transitions to `Claimed` and `tools/list_changed` fires.\n\nThe claim code is **never sent on the WebSocket from the gateway to the agent** - the user carries it across. That's the whole point: it's a human-performed authorisation gesture, not an electronic one.\n\nStrength: ~1.5 billion possible codes (6 positions × ~32 unambiguous alphanumerics), drawn uniformly from the platform CSPRNG (`crypto.getRandomValues`) with rejection sampling — not `Math.random()`, which is not cryptographically secure. A brute-force attacker would need ~750M tries for a 50% hit rate. Codes are single-use; a failed match does not retry.\n\n## Multi-app coexistence\n\nMultiple apps can be connected at once. Every MCP tool is prefixed with the `app.id`, so `shop__addItem` and `admin__banUser` never collide. Internally the gateway routes `tools/call name=shop__addItem` to the session whose `app.id === \"shop\"` - if that session has disconnected, the call errors with `-32003 ActionNotFound`.\n\nThis means you can, without coordinating, keep your dashboard and your customer app both connected to the same agent. Each has its own claim code, its own origin check, its own session.\n\n## What Tesseron does NOT defend against\n\n- **Malicious code running in your app's process.** If an attacker already executes JS in your tab or on your Node server, they can call your SDK and declare whatever actions they want. Tesseron is no worse - and no better - than the process it's embedded in.\n- **Malicious MCP clients on the same machine.** Any local process running as the same user can read `~/.tesseron/instances/` and dial one of the advertised endpoints. They would still need to send a valid `tesseron/hello` and convince the user to type the claim code into their agent. The claim code is the second gate, and it requires human cooperation - but it's the only thing stopping a rogue process from attaching to your session.\n- **Prompt injection.** If your handler's `description` or inputs are attacker-controlled, they can manipulate the agent's plans. Sanitise descriptions you show to the agent the same way you would sanitise HTML you show to users.\n- **Exfiltration via resources.** Anything you expose as a resource is readable by the claimed agent. Don't expose credentials, session tokens, or PII you haven't decided the user is okay sharing with Claude.\n\n## Operational tips\n\n- **Bind to `127.0.0.1`, never `0.0.0.0`.** The default in `@tesseron/server` and `@tesseron/vite` is loopback-only; don't override it unless you know exactly why. UDS hosts go through `os.tmpdir()` with a private (mode 0700) directory.\n- **`~/.tesseron/` files are written private (mode 0600 inside a 0700 directory).** Instance manifests and claim breadcrumbs are owner-only on POSIX. Sibling processes running as the same user can still open them — same-UID enforcement is the OS's job — but cross-user enumeration is closed. On Windows POSIX modes are advisory; the OS user model is the gate, same caveat as the UDS binding spec.\n- **Treat the claim code as short-lived.** Don't render it persistently in the UI after the session is claimed.\n- **Clean up instance manifests on app exit.** The built-in SDKs do this for you; if you port to a new runtime, handle the shutdown path so stale files don't pile up.\n- **Log the `agent.id` that claimed the session** - useful for auditing which agent actually ran which action.\n- **For production tools, use per-user app IDs.** `shop_kenny` vs `shop_sarah` prevents one user's agent from driving another user's tab, even if both are on the same machine.\n\nThat's the end of the Protocol section. The [SDK section](/sdk/) picks up from here - how to speak this protocol from TypeScript today and from other languages later."},{"slug":"protocol/transport","title":"Transport","description":"Tesseron speaks JSON-RPC 2.0 over any reliable, ordered, duplex channel. Bindings spec out concrete realisations.","section":"protocol","related":["protocol/transport-bindings/ws","protocol/transport-bindings/uds","protocol/handshake","protocol/wire-format","sdk/typescript/mcp"],"bodyRaw":"\n## What \"transport\" means here\n\nTesseron speaks **JSON-RPC 2.0 over a reliable, ordered, bidirectional channel**. That's the protocol-level commitment. Anything below it - WebSocket frames, Unix domain sockets, named pipes, in-memory pairs - is a **binding** the implementer picks. The MCP gateway dispatches to the right binding based on what the running app advertises in its instance manifest.\n\nThis page describes the contract every binding has to honour. The per-binding pages spec the wire details:\n\n- [WebSocket binding](/protocol/transport-bindings/ws/) - the default; browser apps use this via `@tesseron/vite`, Node apps use it via `@tesseron/server`'s `NodeWebSocketServerTransport`.\n- [Unix domain socket binding](/protocol/transport-bindings/uds/) - lower-overhead local IPC for Node apps that don't need a browser bridge. Linux + macOS in 1.1; Windows tracked separately.\n\nA new binding is a new instance-manifest discriminant plus a gateway dialer plus an SDK-side host transport. See [Port Tesseron to your language](/sdk/porting/) for the full conformance checklist.\n\n## Who binds, who dials\n\nApps bind. The gateway dials.\n\nEvery Tesseron app hosts its own endpoint - whatever shape the binding requires - and announces it by writing `~/.tesseron/instances/<instanceId>.json`:\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-mocythay-v0hh50\",\n \"appName\": \"node-prompts\",\n \"addedAt\": 1777038462692,\n \"pid\": 24837,\n \"transport\":\n | { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n | { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\nThe gateway watches that directory, reads each new file, picks the dialer matching `transport.kind`, and connects. The app accepts the one inbound connection; the standard handshake follows.\n\n`pid` is optional and identifies the SDK-side process that owns the instance. Gateways probe it with `process.kill(pid, 0)` and tombstone (unlink) manifests whose owner is gone, so a Vite dev server killed without a clean `httpServer.close` doesn't leave a corpse the gateway re-dials every poll tick. Manifests written by older SDKs (no `pid`) are still trusted.\n\nThere is no fixed gateway port. There is no `DEFAULT_GATEWAY_URL` apps dial out to. The gateway itself binds nothing.\n\n## What every binding has to do\n\nThe session/handshake/action layer cannot tell which binding it's running on. Every binding **must** preserve:\n\n- **Reliable, ordered delivery.** No best-effort, no reorderings, no gaps inside a session. TCP-ish guarantees.\n- **One JSON-RPC envelope per logical message.** No batching, no fragmentation visible to the protocol layer.\n- **Symmetric duplex.** Either side can send a request or a notification at any time; there is no fixed direction.\n- **Single connection per session.** `tesseron/hello` opens; close terminates the session (or zombifies it for [resume](/protocol/resume/)).\n- **Same-process / same-user threat model.** The binding is local IPC. Authentication is the [claim code](/protocol/handshake/) plus the OS's own user-isolation guarantees - origin enforcement on WS, file-mode-based UID gating on UDS.\n\nIf a binding can satisfy those, the rest of the protocol composes on top unchanged.\n\n## Compat: pre-1.1 `tabs/` directory\n\nApps built against TS SDKs at 1.0.x wrote v1 manifests to `~/.tesseron/tabs/<tabId>.json`:\n\n```json\n{ \"version\": 1, \"tabId\": \"tab-...\", \"appName\": \"...\", \"wsUrl\": \"ws://...\", \"addedAt\": 1777038462692 }\n```\n\nThe gateway at 1.1+ reads both `instances/` (v2) and `tabs/` (v1) for one minor version. v1 manifests are coerced to `{ kind: 'ws', url: <wsUrl> }` and dispatched to the WS dialer. New SDKs only ever write `instances/`. Drop scheduled for 2.0.\n\n## Heartbeat\n\nThere is no application-level ping. The protocol relies on the underlying binding (TCP keep-alive on WS, kernel-level UDS lifecycle) and per-action timeouts (60 s default) to detect dead peers. If your handler legitimately takes longer than 60 s, extend the timeout on the builder:\n\n```ts\ntesseron.action('bigReport').timeout({ ms: 300_000 }).input(...).handler(...);\n```\n\n## Reconnection\n\n**Reconnection is the app's responsibility, not the SDK's.** On transport close:\n\n- The SDK marks every pending request as failed with `TransportClosedError`.\n- Active invocations have their `AbortSignal` aborted.\n- Subscriptions are dropped.\n- The `sessionId` the gateway issued is gone unless the SDK resumes via [`tesseron/resume`](/protocol/resume/) inside the zombie TTL.\n\nTo recover: re-bind, write a fresh manifest, wait for the gateway to dial again. You will get a **new** `sessionId` and a **new** `claimCode` - the previous claim does not carry over unless you successfully resume.\n\n## Failure matrix\n\n| Event | App sees | MCP gateway does | Agent sees |\n|---|---|---|---|\n| Gateway shuts down cleanly | Channel close (binding-specific code) | Tears down outbound connections. | `tools/list_changed` drops those tools. |\n| Tab closes / app exits | - | Session removed, in-flight invocations cancelled, manifest cleaned up by the app. | `tools/list_changed`. |\n| Action timeout | `AbortSignal` fires with `TimeoutError`. | Error `-32002` returned. | Tool call errors with `-32002`. |\n| Agent cancels | `AbortSignal` fires. | Forwards `actions/cancel`. | Receives error `-32001`. |\n| Binding rejects connect | Bind/upgrade fails. | Gives up on this manifest (may retry on next watcher event). | N/A - session never existed. |\n\nNext: dig into a specific binding ([WebSocket](/protocol/transport-bindings/ws/), [UDS](/protocol/transport-bindings/uds/)) or read the [handshake and claim flow](/protocol/handshake/).\n","bodyText":"## What \"transport\" means here\n\nTesseron speaks **JSON-RPC 2.0 over a reliable, ordered, bidirectional channel**. That's the protocol-level commitment. Anything below it - WebSocket frames, Unix domain sockets, named pipes, in-memory pairs - is a **binding** the implementer picks. The MCP gateway dispatches to the right binding based on what the running app advertises in its instance manifest.\n\nThis page describes the contract every binding has to honour. The per-binding pages spec the wire details:\n\n- [WebSocket binding](/protocol/transport-bindings/ws/) - the default; browser apps use this via `@tesseron/vite`, Node apps use it via `@tesseron/server`'s `NodeWebSocketServerTransport`.\n- [Unix domain socket binding](/protocol/transport-bindings/uds/) - lower-overhead local IPC for Node apps that don't need a browser bridge. Linux + macOS in 1.1; Windows tracked separately.\n\nA new binding is a new instance-manifest discriminant plus a gateway dialer plus an SDK-side host transport. See [Port Tesseron to your language](/sdk/porting/) for the full conformance checklist.\n\n## Who binds, who dials\n\nApps bind. The gateway dials.\n\nEvery Tesseron app hosts its own endpoint - whatever shape the binding requires - and announces it by writing `~/.tesseron/instances/<instanceId>.json`:\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-mocythay-v0hh50\",\n \"appName\": \"node-prompts\",\n \"addedAt\": 1777038462692,\n \"pid\": 24837,\n \"transport\":\n | { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n | { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\nThe gateway watches that directory, reads each new file, picks the dialer matching `transport.kind`, and connects. The app accepts the one inbound connection; the standard handshake follows.\n\n`pid` is optional and identifies the SDK-side process that owns the instance. Gateways probe it with `process.kill(pid, 0)` and tombstone (unlink) manifests whose owner is gone, so a Vite dev server killed without a clean `httpServer.close` doesn't leave a corpse the gateway re-dials every poll tick. Manifests written by older SDKs (no `pid`) are still trusted.\n\nThere is no fixed gateway port. There is no `DEFAULT_GATEWAY_URL` apps dial out to. The gateway itself binds nothing.\n\n## What every binding has to do\n\nThe session/handshake/action layer cannot tell which binding it's running on. Every binding **must** preserve:\n\n- **Reliable, ordered delivery.** No best-effort, no reorderings, no gaps inside a session. TCP-ish guarantees.\n- **One JSON-RPC envelope per logical message.** No batching, no fragmentation visible to the protocol layer.\n- **Symmetric duplex.** Either side can send a request or a notification at any time; there is no fixed direction.\n- **Single connection per session.** `tesseron/hello` opens; close terminates the session (or zombifies it for [resume](/protocol/resume/)).\n- **Same-process / same-user threat model.** The binding is local IPC. Authentication is the [claim code](/protocol/handshake/) plus the OS's own user-isolation guarantees - origin enforcement on WS, file-mode-based UID gating on UDS.\n\nIf a binding can satisfy those, the rest of the protocol composes on top unchanged.\n\n## Compat: pre-1.1 `tabs/` directory\n\nApps built against TS SDKs at 1.0.x wrote v1 manifests to `~/.tesseron/tabs/<tabId>.json`:\n\n```json\n{ \"version\": 1, \"tabId\": \"tab-...\", \"appName\": \"...\", \"wsUrl\": \"ws://...\", \"addedAt\": 1777038462692 }\n```\n\nThe gateway at 1.1+ reads both `instances/` (v2) and `tabs/` (v1) for one minor version. v1 manifests are coerced to `{ kind: 'ws', url: <wsUrl> }` and dispatched to the WS dialer. New SDKs only ever write `instances/`. Drop scheduled for 2.0.\n\n## Heartbeat\n\nThere is no application-level ping. The protocol relies on the underlying binding (TCP keep-alive on WS, kernel-level UDS lifecycle) and per-action timeouts (60 s default) to detect dead peers. If your handler legitimately takes longer than 60 s, extend the timeout on the builder:\n\n```ts\ntesseron.action('bigReport').timeout({ ms: 300_000 }).input(...).handler(...);\n```\n\n## Reconnection\n\n**Reconnection is the app's responsibility, not the SDK's.** On transport close:\n\n- The SDK marks every pending request as failed with `TransportClosedError`.\n- Active invocations have their `AbortSignal` aborted.\n- Subscriptions are dropped.\n- The `sessionId` the gateway issued is gone unless the SDK resumes via [`tesseron/resume`](/protocol/resume/) inside the zombie TTL.\n\nTo recover: re-bind, write a fresh manifest, wait for the gateway to dial again. You will get a **new** `sessionId` and a **new** `claimCode` - the previous claim does not carry over unless you successfully resume.\n\n## Failure matrix\n\n| Event | App sees | MCP gateway does | Agent sees |\n|---|---|---|---|\n| Gateway shuts down cleanly | Channel close (binding-specific code) | Tears down outbound connections. | `tools/list_changed` drops those tools. |\n| Tab closes / app exits | - | Session removed, in-flight invocations cancelled, manifest cleaned up by the app. | `tools/list_changed`. |\n| Action timeout | `AbortSignal` fires with `TimeoutError`. | Error `-32002` returned. | Tool call errors with `-32002`. |\n| Agent cancels | `AbortSignal` fires. | Forwards `actions/cancel`. | Receives error `-32001`. |\n| Binding rejects connect | Bind/upgrade fails. | Gives up on this manifest (may retry on next watcher event). | N/A - session never existed. |\n\nNext: dig into a specific binding ([WebSocket](/protocol/transport-bindings/ws/), [UDS](/protocol/transport-bindings/uds/)) or read the [handshake and claim flow](/protocol/handshake/)."},{"slug":"protocol/transport-bindings/uds","title":"Unix domain socket binding","description":"NDJSON framing, file-mode-based UID enforcement, and lifecycle for the UDS transport binding.","section":"protocol","related":["protocol/transport","protocol/transport-bindings/ws","protocol/handshake","sdk/typescript/server"],"bodyRaw":"\nThe UDS binding speaks Tesseron over a Unix domain socket on the local filesystem. Lower per-message overhead than WebSocket and avoids the loopback TCP stack entirely. Available on Linux and macOS in 1.1; Windows tracks separately (see [Windows](#windows-known-limitations) below).\n\nThis page is the wire spec for that binding. The [transport overview](/protocol/transport/) covers the binding-neutral contract this binding satisfies.\n\n## Manifest discriminant\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-...\",\n \"appName\": \"...\",\n \"addedAt\": 1777038462692,\n \"transport\": { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\nThe `path` is the absolute filesystem path the gateway connects to. Apps SHOULD put the socket inside a per-process directory under `os.tmpdir()` (the reference SDK creates a `mkdtemp`-style 0700 dir, then binds `<dir>/sock` inside it). The directory mode is what gates same-UID access.\n\n## Framing\n\nNDJSON: one JSON-RPC envelope per **`\\n`-terminated line**.\n\n- Compact `JSON.stringify` never emits a raw `\\n` (newlines inside strings are escaped as `\\\\n`), so a line splitter recovers messages losslessly.\n- `JSON.stringify(msg) + '\\n'` on send.\n- Buffer inbound bytes and split on `\\n` on receive. Empty lines are ignored.\n- No batching, no fragmentation, no compression.\n\nThere is **no** subprotocol negotiation - bytes start flowing the moment `connect()` succeeds. The gateway sends `tesseron/hello` (or `tesseron/resume`) as its first message and the app responds.\n\n## Origin / access control\n\nApps **MUST** restrict the socket file so only the same UID can `connect()`. Two complementary mechanisms, both supported on Linux and macOS:\n\n1. **Parent directory mode `0700`.** Put the socket inside a private dir; the kernel's directory permission check rejects `connect()` from any other UID before it ever reaches the socket inode. The reference SDK does this via `mkdtemp` + `chmod 0700`.\n2. **Socket file mode `0600`.** Apply `chmod 0600` to the socket file itself after `bind()`. Belt-and-suspenders against directory misconfiguration; some kernels (macOS pre-10.10, Linux pre-3.9) ignore the socket-file mode and rely solely on the parent dir.\n\nThe threat model is identical to loopback WS plus the [claim code](/protocol/handshake/): any process running as the same OS user can connect; the [claim code](/protocol/handshake/) is what gates the privilege escalation from \"can talk to the socket\" to \"is bound to a session\". Cross-UID isolation is the OS's job.\n\n## Lifecycle\n\n- App creates a 0700 temp dir under `os.tmpdir()` (or wherever the OS lets the user write privately), `bind()`s a socket inside, optionally `chmod 0600`s the socket file.\n- App writes `~/.tesseron/instances/<instanceId>.json` with the path.\n- Gateway watches `~/.tesseron/instances/`, picks the manifest up, dials.\n- App accepts exactly one connection - the first peer wins; subsequent connect attempts are closed immediately.\n- On session close, the app deletes its manifest and the socket file, and removes the temp dir.\n\n## Failure matrix (UDS-specific)\n\n| Event | What you see | Notes |\n|---|---|---|\n| Same-host other-UID connect attempt | `EACCES` from `connect()` | The kernel rejects before any byte is exchanged. |\n| Stale socket file from prior run | `EADDRINUSE` on bind | Apps SHOULD `unlink` before `bind` if they pin a path. |\n| App crashes without cleanup | Stale manifest + stale socket file | Gateway dial hits `ECONNREFUSED`; manifest is harmless until manually swept. |\n| Gateway disconnects | `'close'` on the app side, no code | Treat as session end; rebind + re-announce to recover. |\n\n## Windows: known limitations\n\nWindows ≥ 1803 has an AF_UNIX implementation, but Node's `net.listen({ path })` on Windows actually creates a **named pipe** under the hood, not a filesystem socket. The path semantics differ (`\\\\.\\pipe\\<name>` instead of arbitrary filesystem paths) and the file-mode-based UID enforcement does not apply - Windows uses ACLs.\n\nThe 1.1 reference SDK skips the UDS binding on Windows. A separate `pipe` binding is tracked as follow-up work; until then, Windows apps should use the [WebSocket binding](./ws/).\n\n## SDK-side reference implementation\n\n- [`@tesseron/server` `UnixSocketServerTransport`](/sdk/typescript/server/) - select with `tesseron.connect({ transport: 'uds' })`.\n\n## Porting another language?\n\nImplement a UDS server that:\n\n1. Creates a private (mode `0700`) directory under `os.tmpdir()` (or equivalent), binds a socket inside.\n2. `chmod 0600`s the socket file after bind.\n3. Writes `~/.tesseron/instances/<instanceId>.json` with `{ kind: 'uds', path }`.\n4. Accepts exactly one connection; rejects subsequent connect attempts.\n5. Serialises outgoing JSON-RPC envelopes with `\\n` terminator; splits incoming bytes on `\\n`.\n6. Deletes its manifest, the socket file, and the temp dir on close.\n\nThe full conformance checklist lives in [Port Tesseron to your language](/sdk/porting/).\n","bodyText":"The UDS binding speaks Tesseron over a Unix domain socket on the local filesystem. Lower per-message overhead than WebSocket and avoids the loopback TCP stack entirely. Available on Linux and macOS in 1.1; Windows tracks separately (see [Windows](#windows-known-limitations) below).\n\nThis page is the wire spec for that binding. The [transport overview](/protocol/transport/) covers the binding-neutral contract this binding satisfies.\n\n## Manifest discriminant\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-...\",\n \"appName\": \"...\",\n \"addedAt\": 1777038462692,\n \"transport\": { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\nThe `path` is the absolute filesystem path the gateway connects to. Apps SHOULD put the socket inside a per-process directory under `os.tmpdir()` (the reference SDK creates a `mkdtemp`-style 0700 dir, then binds `<dir>/sock` inside it). The directory mode is what gates same-UID access.\n\n## Framing\n\nNDJSON: one JSON-RPC envelope per **`\\n`-terminated line**.\n\n- Compact `JSON.stringify` never emits a raw `\\n` (newlines inside strings are escaped as `\\\\n`), so a line splitter recovers messages losslessly.\n- `JSON.stringify(msg) + '\\n'` on send.\n- Buffer inbound bytes and split on `\\n` on receive. Empty lines are ignored.\n- No batching, no fragmentation, no compression.\n\nThere is **no** subprotocol negotiation - bytes start flowing the moment `connect()` succeeds. The gateway sends `tesseron/hello` (or `tesseron/resume`) as its first message and the app responds.\n\n## Origin / access control\n\nApps **MUST** restrict the socket file so only the same UID can `connect()`. Two complementary mechanisms, both supported on Linux and macOS:\n\n1. **Parent directory mode `0700`.** Put the socket inside a private dir; the kernel's directory permission check rejects `connect()` from any other UID before it ever reaches the socket inode. The reference SDK does this via `mkdtemp` + `chmod 0700`.\n2. **Socket file mode `0600`.** Apply `chmod 0600` to the socket file itself after `bind()`. Belt-and-suspenders against directory misconfiguration; some kernels (macOS pre-10.10, Linux pre-3.9) ignore the socket-file mode and rely solely on the parent dir.\n\nThe threat model is identical to loopback WS plus the [claim code](/protocol/handshake/): any process running as the same OS user can connect; the [claim code](/protocol/handshake/) is what gates the privilege escalation from \"can talk to the socket\" to \"is bound to a session\". Cross-UID isolation is the OS's job.\n\n## Lifecycle\n\n- App creates a 0700 temp dir under `os.tmpdir()` (or wherever the OS lets the user write privately), `bind()`s a socket inside, optionally `chmod 0600`s the socket file.\n- App writes `~/.tesseron/instances/<instanceId>.json` with the path.\n- Gateway watches `~/.tesseron/instances/`, picks the manifest up, dials.\n- App accepts exactly one connection - the first peer wins; subsequent connect attempts are closed immediately.\n- On session close, the app deletes its manifest and the socket file, and removes the temp dir.\n\n## Failure matrix (UDS-specific)\n\n| Event | What you see | Notes |\n|---|---|---|\n| Same-host other-UID connect attempt | `EACCES` from `connect()` | The kernel rejects before any byte is exchanged. |\n| Stale socket file from prior run | `EADDRINUSE` on bind | Apps SHOULD `unlink` before `bind` if they pin a path. |\n| App crashes without cleanup | Stale manifest + stale socket file | Gateway dial hits `ECONNREFUSED`; manifest is harmless until manually swept. |\n| Gateway disconnects | `'close'` on the app side, no code | Treat as session end; rebind + re-announce to recover. |\n\n## Windows: known limitations\n\nWindows ≥ 1803 has an AF_UNIX implementation, but Node's `net.listen({ path })` on Windows actually creates a **named pipe** under the hood, not a filesystem socket. The path semantics differ (`\\\\.\\pipe\\<name>` instead of arbitrary filesystem paths) and the file-mode-based UID enforcement does not apply - Windows uses ACLs.\n\nThe 1.1 reference SDK skips the UDS binding on Windows. A separate `pipe` binding is tracked as follow-up work; until then, Windows apps should use the [WebSocket binding](./ws/).\n\n## SDK-side reference implementation\n\n- [`@tesseron/server` `UnixSocketServerTransport`](/sdk/typescript/server/) - select with `tesseron.connect({ transport: 'uds' })`.\n\n## Porting another language?\n\nImplement a UDS server that:\n\n1. Creates a private (mode `0700`) directory under `os.tmpdir()` (or equivalent), binds a socket inside.\n2. `chmod 0600`s the socket file after bind.\n3. Writes `~/.tesseron/instances/<instanceId>.json` with `{ kind: 'uds', path }`.\n4. Accepts exactly one connection; rejects subsequent connect attempts.\n5. Serialises outgoing JSON-RPC envelopes with `\\n` terminator; splits incoming bytes on `\\n`.\n6. Deletes its manifest, the socket file, and the temp dir on close.\n\nThe full conformance checklist lives in [Port Tesseron to your language](/sdk/porting/)."},{"slug":"protocol/transport-bindings/ws","title":"WebSocket binding","description":"URL, framing, subprotocol, origin enforcement, and reconnection rules for the WebSocket transport binding.","section":"protocol","related":["protocol/transport","protocol/transport-bindings/uds","protocol/handshake","protocol/wire-format","sdk/typescript/server","sdk/typescript/web"],"bodyRaw":"\nThe WebSocket binding is the default Tesseron transport. Browser apps use it via the `@tesseron/vite` plugin, Node apps via `@tesseron/server`'s `NodeWebSocketServerTransport`. The MCP gateway dials with the `tesseron-gateway` subprotocol.\n\nThis page is the wire spec for that binding. The [transport overview](/protocol/transport/) covers the binding-neutral contract - reliable, ordered, single-connection-per-session, etc. - that this binding satisfies.\n\n## Manifest discriminant\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-...\",\n \"appName\": \"...\",\n \"addedAt\": 1777038462692,\n \"transport\": { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n}\n```\n\nThe `url` is what the gateway dials. Apps **MUST** bind to loopback (`127.0.0.1` or `::1`) - the threat model assumes same-host-same-user access.\n\n## Framing\n\n- One JSON-RPC envelope per WebSocket text frame.\n- `JSON.stringify` on send, `JSON.parse` on receive.\n- Binary frames are coerced to UTF-8 text and parsed anyway (defensive — gateway compatibility with non-conforming relays).\n- No fragmentation, no batching, no compression.\n\n## Subprotocol handshake\n\nThe gateway sends `Sec-WebSocket-Protocol: tesseron-gateway` on its upgrade request. Apps that host a Tesseron WS server **MUST** advertise this subprotocol in their handshake response and **MUST** reject upgrade requests that don't carry it - the app's WebSocket endpoint is only for the gateway, not for arbitrary clients.\n\nThe Vite plugin is the documented exception: it accepts plain (no-subprotocol) connections from the browser tab AND a separate `tesseron-gateway` connection from the gateway, and bridges them.\n\n## Origin enforcement\n\nWS upgrades carry an `Origin` header. The gateway treats whatever the upgrade request advertised as the authoritative origin for the lifetime of the session. SDK-declared `app.origin` values that disagree are overwritten with the upgrade-time value at `tesseron/hello` and `tesseron/resume`.\n\nApps that want stronger gating can install an `origin allowlist` in their HTTP server before the WS upgrade fires. The reference SDK leaves this to the app.\n\n## Reconnection\n\nSame as the binding-neutral [transport rules](/protocol/transport/#reconnection): close kills the session, the SDK rejects pending requests with `TransportClosedError`, and reconnection is the app's job. Use [`tesseron/resume`](/protocol/resume/) to rejoin a zombified session within its TTL.\n\n## Failure matrix (WS-specific)\n\n| Event | Code observed | Notes |\n|---|---|---|\n| Gateway shuts down cleanly | `1001 Going Away` | Standard WS close code. |\n| Bad subprotocol | Upgrade fails before WS open | Gateway gives up on this manifest until next watcher event. |\n| App rejects gateway origin | App's choice — typically 4xx | Any non-101 response means no session. |\n| Browser tab close (Vite) | Plugin tears down both sides | Manifest deleted, gateway sees normal `close`. |\n\n## SDK-side reference implementations\n\n- [`@tesseron/server` `NodeWebSocketServerTransport`](/sdk/typescript/server/) - Node apps host a loopback `ws://...` and write `instances/`.\n- [`@tesseron/web` `BrowserWebSocketTransport`](/sdk/typescript/web/) - browser apps dial `/@tesseron/ws` (served by `@tesseron/vite`).\n- [`@tesseron/vite`](/sdk/typescript/vite/) - dev-server bridge between the browser tab and the gateway.\n\n## Porting another language?\n\nImplement a WS server that:\n\n1. Binds loopback on an OS-picked port (or a pinned port if your runtime requires it).\n2. Writes `~/.tesseron/instances/<instanceId>.json` with `{ kind: 'ws', url }`.\n3. Accepts exactly one upgrade carrying the `tesseron-gateway` subprotocol; rejects every other upgrade.\n4. Serialises outgoing JSON-RPC envelopes as text frames; parses incoming text frames.\n5. Deletes its manifest on close.\n\nThe full conformance checklist lives in [Port Tesseron to your language](/sdk/porting/).\n","bodyText":"The WebSocket binding is the default Tesseron transport. Browser apps use it via the `@tesseron/vite` plugin, Node apps via `@tesseron/server`'s `NodeWebSocketServerTransport`. The MCP gateway dials with the `tesseron-gateway` subprotocol.\n\nThis page is the wire spec for that binding. The [transport overview](/protocol/transport/) covers the binding-neutral contract - reliable, ordered, single-connection-per-session, etc. - that this binding satisfies.\n\n## Manifest discriminant\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-...\",\n \"appName\": \"...\",\n \"addedAt\": 1777038462692,\n \"transport\": { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n}\n```\n\nThe `url` is what the gateway dials. Apps **MUST** bind to loopback (`127.0.0.1` or `::1`) - the threat model assumes same-host-same-user access.\n\n## Framing\n\n- One JSON-RPC envelope per WebSocket text frame.\n- `JSON.stringify` on send, `JSON.parse` on receive.\n- Binary frames are coerced to UTF-8 text and parsed anyway (defensive — gateway compatibility with non-conforming relays).\n- No fragmentation, no batching, no compression.\n\n## Subprotocol handshake\n\nThe gateway sends `Sec-WebSocket-Protocol: tesseron-gateway` on its upgrade request. Apps that host a Tesseron WS server **MUST** advertise this subprotocol in their handshake response and **MUST** reject upgrade requests that don't carry it - the app's WebSocket endpoint is only for the gateway, not for arbitrary clients.\n\nThe Vite plugin is the documented exception: it accepts plain (no-subprotocol) connections from the browser tab AND a separate `tesseron-gateway` connection from the gateway, and bridges them.\n\n## Origin enforcement\n\nWS upgrades carry an `Origin` header. The gateway treats whatever the upgrade request advertised as the authoritative origin for the lifetime of the session. SDK-declared `app.origin` values that disagree are overwritten with the upgrade-time value at `tesseron/hello` and `tesseron/resume`.\n\nApps that want stronger gating can install an `origin allowlist` in their HTTP server before the WS upgrade fires. The reference SDK leaves this to the app.\n\n## Reconnection\n\nSame as the binding-neutral [transport rules](/protocol/transport/#reconnection): close kills the session, the SDK rejects pending requests with `TransportClosedError`, and reconnection is the app's job. Use [`tesseron/resume`](/protocol/resume/) to rejoin a zombified session within its TTL.\n\n## Failure matrix (WS-specific)\n\n| Event | Code observed | Notes |\n|---|---|---|\n| Gateway shuts down cleanly | `1001 Going Away` | Standard WS close code. |\n| Bad subprotocol | Upgrade fails before WS open | Gateway gives up on this manifest until next watcher event. |\n| App rejects gateway origin | App's choice — typically 4xx | Any non-101 response means no session. |\n| Browser tab close (Vite) | Plugin tears down both sides | Manifest deleted, gateway sees normal `close`. |\n\n## SDK-side reference implementations\n\n- [`@tesseron/server` `NodeWebSocketServerTransport`](/sdk/typescript/server/) - Node apps host a loopback `ws://...` and write `instances/`.\n- [`@tesseron/web` `BrowserWebSocketTransport`](/sdk/typescript/web/) - browser apps dial `/@tesseron/ws` (served by `@tesseron/vite`).\n- [`@tesseron/vite`](/sdk/typescript/vite/) - dev-server bridge between the browser tab and the gateway.\n\n## Porting another language?\n\nImplement a WS server that:\n\n1. Binds loopback on an OS-picked port (or a pinned port if your runtime requires it).\n2. Writes `~/.tesseron/instances/<instanceId>.json` with `{ kind: 'ws', url }`.\n3. Accepts exactly one upgrade carrying the `tesseron-gateway` subprotocol; rejects every other upgrade.\n4. Serialises outgoing JSON-RPC envelopes as text frames; parses incoming text frames.\n5. Deletes its manifest on close.\n\nThe full conformance checklist lives in [Port Tesseron to your language](/sdk/porting/)."},{"slug":"protocol/wire-format","title":"Wire format (JSON-RPC)","description":"The envelope shapes Tesseron uses, the full method surface in both directions, and the ID-correlation rules.","section":"protocol","related":["protocol/transport","protocol/handshake","protocol/errors","protocol/actions"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nTesseron's app ↔ MCP gateway hop is **JSON-RPC 2.0**, one JSON object per WebSocket text frame. No batching, no binary, no compression - one message, one parse.\n\n<Diagram\n caption=\"Four envelope shapes. Every message on the wire is exactly one of these.\"\n nodeWidth={190}\n nodeHeight={150}\n spacing={50}\n nodes={[\n { id: 'req', label: 'REQUEST', sub: ['id + method', 'expects response'], code: 'jsonrpc: \"2.0\"', icon: 'arrow' },\n { id: 'ntf', label: 'NOTIFICATION', sub: ['method only', 'fire-and-forget'], code: 'no id', icon: 'arrow' },\n { id: 'ok', label: 'SUCCESS', sub: ['echoes request id', 'carries result'], code: 'result: R', icon: 'check', variant: 'accent' },\n { id: 'err', label: 'ERROR', sub: ['echoes request id', 'carries error object'], code: 'error: {...}', icon: 'x', variant: 'danger' },\n ]}\n edges={[]}\n/>\n\n## Envelope shapes\n\n### Request (expects a response)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"method\": \"actions/invoke\",\n \"params\": { /* method-specific */ }\n}\n```\n\n### Notification (fire-and-forget)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": { \"invocationId\": \"inv_1\", \"percent\": 40 }\n}\n```\n\n### Success response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"result\": { /* method-specific payload */ }\n}\n```\n\n### Error response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"error\": { \"code\": -32004, \"message\": \"Invalid input\", \"data\": [/* issues */] }\n}\n```\n\n`id` can be a string, number, or `null`. The SDK uses monotonically incrementing integers per connection; any JSON-RPC-compliant peer is welcome to do otherwise.\n\n## Method surface\n\n### App → Gateway (you send)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `tesseron/hello` | request | Register app, actions, resources, capabilities. First message. |\n| `actions/progress` | notification | Streaming update during an invocation. |\n| `actions/list_changed` | notification | App (re)registered / removed an action after hello. |\n| `resources/updated` | notification | Push a new value to a subscriber. |\n| `resources/list_changed` | notification | App (re)registered / removed a resource after hello. |\n| `sampling/request` | request | Ask the agent to run an LLM step. |\n| `elicitation/request` | request | Ask the user (confirm or elicit) via the agent UI. |\n| `log` | notification | Structured log forwarded to MCP logging. |\n\nPlus: the **response** for any `actions/invoke`, `resources/read`, `resources/subscribe`, `resources/unsubscribe` the gateway sent you.\n\n### Gateway → App (you handle)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `actions/invoke` | request | Agent called an action. Respond with `result` or `error`. |\n| `actions/cancel` | notification | Agent cancelled an in-flight invocation. |\n| `resources/read` | request | Agent requested current resource value. |\n| `resources/subscribe` | request | Agent subscribed to future updates. |\n| `resources/unsubscribe` | request | Agent unsubscribed. |\n\nAnd the **response** to the `tesseron/hello` you sent.\n\n## ID correlation\n\n- A peer that issues a request assigns the `id`. The other peer echoes the exact same `id` in the response.\n- The SDK keeps a `Map<id, { resolve, reject, timeoutHandle }>` of pending outbound requests. On response it looks up the id, clears the timer, and settles the promise.\n- On transport close, **every pending request is rejected** with `TransportClosedError`. There is no resumable queue; reconnect means re-send.\n- Notifications have no `id` - they never fail visibly and never get a response. Don't send data you care about as a notification.\n\n## Framing\n\n- Each JSON-RPC object is serialized with `JSON.stringify` and sent as **one text frame**.\n- Binary frames sent by the peer are coerced to text and parsed - tolerated but not idiomatic.\n- There is **no length prefix and no framing header**. WebSocket gives us message boundaries for free.\n- There is **no batching**. Every message is self-contained.\n\n## Versioning\n\n`tesseron/hello` includes `protocolVersion: \"1.1.0\"`. The gateway parses it as `major.minor`: a major mismatch is rejected with `-32000 ProtocolMismatch` and the channel is closed, a minor mismatch is accepted with a stderr warning (newer fields may be silently dropped), an exact match is silent. See [Handshake](/protocol/handshake/#protocol-version-mismatch).\n\nNext: how that WebSocket gets established - [Transport](/protocol/transport/).\n","bodyText":"Tesseron's app ↔ MCP gateway hop is **JSON-RPC 2.0**, one JSON object per WebSocket text frame. No batching, no binary, no compression - one message, one parse.\n\n## Envelope shapes\n\n### Request (expects a response)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"method\": \"actions/invoke\",\n \"params\": { /* method-specific */ }\n}\n```\n\n### Notification (fire-and-forget)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": { \"invocationId\": \"inv_1\", \"percent\": 40 }\n}\n```\n\n### Success response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"result\": { /* method-specific payload */ }\n}\n```\n\n### Error response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"error\": { \"code\": -32004, \"message\": \"Invalid input\", \"data\": [/* issues */] }\n}\n```\n\n`id` can be a string, number, or `null`. The SDK uses monotonically incrementing integers per connection; any JSON-RPC-compliant peer is welcome to do otherwise.\n\n## Method surface\n\n### App → Gateway (you send)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `tesseron/hello` | request | Register app, actions, resources, capabilities. First message. |\n| `actions/progress` | notification | Streaming update during an invocation. |\n| `actions/list_changed` | notification | App (re)registered / removed an action after hello. |\n| `resources/updated` | notification | Push a new value to a subscriber. |\n| `resources/list_changed` | notification | App (re)registered / removed a resource after hello. |\n| `sampling/request` | request | Ask the agent to run an LLM step. |\n| `elicitation/request` | request | Ask the user (confirm or elicit) via the agent UI. |\n| `log` | notification | Structured log forwarded to MCP logging. |\n\nPlus: the **response** for any `actions/invoke`, `resources/read`, `resources/subscribe`, `resources/unsubscribe` the gateway sent you.\n\n### Gateway → App (you handle)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `actions/invoke` | request | Agent called an action. Respond with `result` or `error`. |\n| `actions/cancel` | notification | Agent cancelled an in-flight invocation. |\n| `resources/read` | request | Agent requested current resource value. |\n| `resources/subscribe` | request | Agent subscribed to future updates. |\n| `resources/unsubscribe` | request | Agent unsubscribed. |\n\nAnd the **response** to the `tesseron/hello` you sent.\n\n## ID correlation\n\n- A peer that issues a request assigns the `id`. The other peer echoes the exact same `id` in the response.\n- The SDK keeps a `Map<id, { resolve, reject, timeoutHandle }>` of pending outbound requests. On response it looks up the id, clears the timer, and settles the promise.\n- On transport close, **every pending request is rejected** with `TransportClosedError`. There is no resumable queue; reconnect means re-send.\n- Notifications have no `id` - they never fail visibly and never get a response. Don't send data you care about as a notification.\n\n## Framing\n\n- Each JSON-RPC object is serialized with `JSON.stringify` and sent as **one text frame**.\n- Binary frames sent by the peer are coerced to text and parsed - tolerated but not idiomatic.\n- There is **no length prefix and no framing header**. WebSocket gives us message boundaries for free.\n- There is **no batching**. Every message is self-contained.\n\n## Versioning\n\n`tesseron/hello` includes `protocolVersion: \"1.1.0\"`. The gateway parses it as `major.minor`: a major mismatch is rejected with `-32000 ProtocolMismatch` and the channel is closed, a minor mismatch is accepted with a stderr warning (newer fields may be silently dropped), an exact match is silent. See [Handshake](/protocol/handshake/#protocol-version-mismatch).\n\nNext: how that WebSocket gets established - [Transport](/protocol/transport/)."},{"slug":"sdk/index","title":"SDK overview","description":"What a Tesseron SDK has to expose - in TypeScript today, in any other language tomorrow.","section":"sdk","related":["sdk/porting","sdk/typescript/index","protocol/index"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Mermaid from '../../../components/Mermaid.astro';\n\nAn SDK is the part of Tesseron that lives in **your** process. It serialises outgoing JSON-RPC, dispatches incoming method calls into your handlers, and wraps the per-invocation protocol glue (progress, cancel, sample, elicit) in a shape that feels natural in the host language.\n\nToday we ship five TypeScript packages. The surface they expose - the **SDK contract** - is the portable part. A Python or Go implementation reuses the same concepts.\n\n## The shipped TypeScript SDK\n\n<Mermaid\n caption=\"Five packages. core owns the protocol types and builder; the others wrap transport and framework integration.\"\n code={`\nflowchart LR\n core[\"@tesseron/core<br/>action & resource builders<br/>JSON-RPC dispatcher<br/>protocol types\"]\n web[\"@tesseron/web<br/>browser client<br/>WebSocket transport\"]\n server[\"@tesseron/server<br/>Node client<br/>ws transport\"]\n react[\"@tesseron/react<br/>useTesseronAction<br/>useTesseronResource<br/>useTesseronConnection\"]\n mcp[\"@tesseron/mcp<br/>MCP gateway<br/>MCP stdio bridge\"]\n web -- \"re-exports\" --> core\n server -- \"re-exports\" --> core\n react -- \"wraps\" --> web\n mcp -. \"shared types\" .-> core\n`}\n/>\n\n<CardGrid>\n <LinkCard title=\"Quickstart\" href=\"./typescript/\"\n description=\"Install one package, declare one action, connect.\" />\n <LinkCard title=\"@tesseron/core\" href=\"./typescript/core/\"\n description=\"Action & resource builders, JSON-RPC dispatcher, protocol types. Zero runtime deps beyond Standard Schema.\" />\n <LinkCard title=\"@tesseron/web\" href=\"./typescript/web/\"\n description=\"Browser WebSocket transport + singleton client.\" />\n <LinkCard title=\"@tesseron/server\" href=\"./typescript/server/\"\n description=\"Node `ws`-backed transport + singleton client.\" />\n <LinkCard title=\"@tesseron/react\" href=\"./typescript/react/\"\n description=\"`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`.\" />\n <LinkCard title=\"@tesseron/mcp\" href=\"./typescript/mcp/\"\n description=\"The MCP gateway itself. CLI, bundled into the Claude Code plugin.\" />\n</CardGrid>\n\n## The portable SDK contract\n\nWhatever language you implement Tesseron in, the SDK has to expose these primitives. They correspond 1:1 with the [protocol](/protocol/).\n\n| Primitive | In TypeScript | Covers |\n|---|---|---|\n| **Client lifecycle** | `tesseron.app({ id, name, … })` + `tesseron.connect()` | Handshake, session ID, claim code. |\n| **Action builder** | `.action(name).describe(…).input(…).output(…).handler(fn)` | Declaring a named, typed, handler-backed action. |\n| **Resource builder** | `.resource(name).read(fn).subscribe(emitter)` | Declaring readable + optionally subscribable state. |\n| **Standard Schema bridge** | Accepts any `StandardSchemaV1<T>` validator (Zod, Valibot, ArkType, …) | Input / output / sampling / elicitation validation. |\n| **Invocation context** | `(input, ctx)` passed to every handler | `ctx.signal`, `ctx.progress`, `ctx.sample`, `ctx.confirm`, `ctx.elicit`, `ctx.log`, `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`. |\n| **Transport abstraction** | `Transport { send, onMessage, onClose, close }` | WebSocket in practice, but the protocol is transport-agnostic. |\n| **JSON-RPC dispatcher** | `JsonRpcDispatcher` | Request/notification handling, ID correlation, timeout, error mapping. |\n| **Structured error model** | `TesseronError(code, message, data?)` | Mapping to / from JSON-RPC error objects with the error codes in the [catalog](/protocol/errors/). |\n\n## Other SDKs\n\n<CardGrid>\n <LinkCard title=\"Python SDK (planned)\" href=\"./python/\"\n description=\"Status, intended shape, timeline.\" />\n <LinkCard title=\"Port Tesseron to your language\" href=\"./porting/\"\n description=\"Step-by-step guide, protocol conformance checklist, test strategy.\" />\n</CardGrid>\n","bodyText":"An SDK is the part of Tesseron that lives in **your** process. It serialises outgoing JSON-RPC, dispatches incoming method calls into your handlers, and wraps the per-invocation protocol glue (progress, cancel, sample, elicit) in a shape that feels natural in the host language.\n\nToday we ship five TypeScript packages. The surface they expose - the **SDK contract** - is the portable part. A Python or Go implementation reuses the same concepts.\n\n## The shipped TypeScript SDK\n\naction & resource builders<br/>JSON-RPC dispatcher<br/>protocol types\"]\n web[\"@tesseron/web<br/>browser client<br/>WebSocket transport\"]\n server[\"@tesseron/server<br/>Node client<br/>ws transport\"]\n react[\"@tesseron/react<br/>useTesseronAction<br/>useTesseronResource<br/>useTesseronConnection\"]\n mcp[\"@tesseron/mcp<br/>MCP gateway<br/>MCP stdio bridge\"]\n web -- \"re-exports\" --> core\n server -- \"re-exports\" --> core\n react -- \"wraps\" --> web\n mcp -. \"shared types\" .-> core\n`}\n/>\n\n## The portable SDK contract\n\nWhatever language you implement Tesseron in, the SDK has to expose these primitives. They correspond 1:1 with the [protocol](/protocol/).\n\n| Primitive | In TypeScript | Covers |\n|---|---|---|\n| **Client lifecycle** | `tesseron.app({ id, name, … })` + `tesseron.connect()` | Handshake, session ID, claim code. |\n| **Action builder** | `.action(name).describe(…).input(…).output(…).handler(fn)` | Declaring a named, typed, handler-backed action. |\n| **Resource builder** | `.resource(name).read(fn).subscribe(emitter)` | Declaring readable + optionally subscribable state. |\n| **Standard Schema bridge** | Accepts any `StandardSchemaV1<T>` validator (Zod, Valibot, ArkType, …) | Input / output / sampling / elicitation validation. |\n| **Invocation context** | `(input, ctx)` passed to every handler | `ctx.signal`, `ctx.progress`, `ctx.sample`, `ctx.confirm`, `ctx.elicit`, `ctx.log`, `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`. |\n| **Transport abstraction** | `Transport { send, onMessage, onClose, close }` | WebSocket in practice, but the protocol is transport-agnostic. |\n| **JSON-RPC dispatcher** | `JsonRpcDispatcher` | Request/notification handling, ID correlation, timeout, error mapping. |\n| **Structured error model** | `TesseronError(code, message, data?)` | Mapping to / from JSON-RPC error objects with the error codes in the [catalog](/protocol/errors/). |\n\n## Other SDKs"},{"slug":"sdk/porting","title":"Port Tesseron to your language","description":"Step-by-step guide to writing a new Tesseron SDK and a conformance checklist for testing it.","section":"sdk","related":["sdk/index","protocol/index","protocol/wire-format","sdk/typescript/core"],"bodyRaw":"\nTesseron's wire protocol is small enough that a competent engineer can implement an SDK for a new language in a couple of days. This page is your map.\n\n## What you're actually building\n\nA Tesseron SDK is three things glued together:\n\n1. **A WebSocket client** that speaks JSON-RPC 2.0.\n2. **A builder DSL** for declaring actions and resources.\n3. **A bridge** between the JSON-RPC dispatcher and the builder's registered handlers.\n\nThe full [protocol spec](/protocol/) is the source of truth. If anything on this page contradicts it, the protocol wins.\n\n## Step 1 - pick a runtime model\n\nTwo choices decide the shape of everything that follows.\n\n- **Async model.** Native `async`/`await` / futures / goroutines - whatever your language uses for concurrency. All SDK methods that may block (connect, sample, elicit, progress-waiting action handlers) should be async. Synchronous handlers are fine, but the client's I/O loop must not be.\n- **Schema library.** You need a way for users to express typed inputs. Pick one well-known library (Pydantic in Python, `go-playground/validator` in Go, Serde+schemars in Rust), and accept any user-provided schema that can round-trip to JSON Schema.\n\n## Step 2 - model the protocol types\n\nPort these from the [wire format page](/protocol/wire-format/):\n\n- JSON-RPC 2.0 request / notification / success / error envelopes.\n- `HelloParams`, `WelcomeResult`, `ActionAnnotations`, `InvokeParams`, `ProgressParams`, `CancelParams`, `SampleParams`, `ElicitParams`, `ReadParams`, `SubscribeParams`, `UpdatedParams`.\n- The error-code enum from the [errors page](/protocol/errors/).\n\nGive the error codes first-class names. Don't pass bare integers around - they accumulate magic.\n\n## Step 3 - write the dispatcher\n\nA bidirectional JSON-RPC dispatcher with:\n\n- `on(method, handler)` - respond to incoming requests.\n- `onNotification(method, handler)` - respond to incoming notifications.\n- `request(method, params, { timeoutMs })` - send a request, await the response. ID generation, timeout handling, rejection on close.\n- `notify(method, params)` - send a fire-and-forget notification.\n- `receive(message)` - given a parsed JSON-RPC envelope, dispatch to a handler or resolve a pending request.\n\nTest this in isolation against a pair of in-memory dispatchers. No networking yet.\n\n## Step 4 - write the transport\n\nYou're picking a **binding** (which wire format) and writing the SDK-side host. Tesseron's protocol layer is binding-neutral; pick from the [documented bindings](/protocol/transport/) or design a new one.\n\nFor a WebSocket binding:\n\n- Bind `127.0.0.1` on an OS-picked port.\n- Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'ws', url } }` where `url` is the URL just bound.\n- Accept exactly one upgrade request that advertises the `tesseron-gateway` WebSocket subprotocol; reject every other attempt.\n- Serialise outgoing objects with the language's standard JSON library and parse incoming text frames as JSON.\n- Delete the manifest on close.\n\nFor a UDS binding (Linux / macOS):\n\n- Create a private (mode `0700`) directory under `os.tmpdir()`-equivalent. Bind a socket inside it, `chmod 0600` the socket file.\n- Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'uds', path } }`.\n- Accept exactly one connection; reject subsequent connect attempts.\n- Frame messages as NDJSON (`JSON.stringify(msg) + '\\n'`); split incoming bytes on `\\n`.\n- Delete the manifest, the socket file, and the temp dir on close.\n\nThe gateway is always the **client** - it watches `~/.tesseron/instances/`, picks a dialer matching `transport.kind`, and connects. Your runtime never opens an outbound connection; it binds, announces, and waits.\n\nTo add a binding the gateway doesn't yet know about, you also need to ship a `GatewayDialer` for the new `kind` (in TypeScript: `packages/mcp/src/dialer.ts`) and document the wire format under `/protocol/transport-bindings/<kind>/`.\n\nDon't reinvent backoff or reconnect inside the transport - that's the user's job.\n\n## Step 5 - builder DSL\n\nWhatever shape is idiomatic. In TypeScript we use a fluent builder (`action(...).describe(...).input(...).handler(...)`). In Python, decorators. In Rust, probably a struct with a method-chain pattern. What matters is that it ultimately produces an `ActionDefinition`:\n\n```\nActionDefinition {\n name: string;\n description?: string;\n inputSchema?: StandardJsonSchema;\n outputSchema?: StandardJsonSchema;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput: boolean;\n handler: (input, ctx) => output;\n}\n```\n\nSame for `ResourceDefinition`.\n\n## Step 6 - bind it together\n\n```\nclass TesseronClient {\n constructor(transport, dispatcher) { … }\n app(info) { … } // records app manifest for hello\n action(name) { return new Builder(this, name) }\n resource(name) { return new ResBuilder(this, name) }\n async connect() {\n await transport.open();\n dispatcher.on('actions/invoke', this._onInvoke);\n dispatcher.onNotification('actions/cancel', this._onCancel);\n dispatcher.on('resources/read', this._onRead);\n dispatcher.on('resources/subscribe', this._onSub);\n dispatcher.on('resources/unsubscribe', this._onUnsub);\n return await dispatcher.request('tesseron/hello', this._manifest());\n }\n}\n```\n\nEach `on(...)` handler maps to the corresponding builder. Implement progress / sample / elicit / log on the `ActionContext` the same way.\n\n## Step 7 - conformance checklist\n\nBefore you ship, make sure the SDK passes every line of this list. An SDK that fails any line is not Tesseron-compliant.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after the binding's connection becomes ready.\n- [ ] Sends `protocolVersion = \"1.1.0\"` exactly.\n- [ ] Sends `app.id` that matches `/^[a-z][a-z0-9_]*$/`.\n- [ ] Surfaces `welcome.claimCode` to the caller (stdout, event, return value - your choice).\n- [ ] Surfaces `welcome.capabilities` as the authoritative agent capability set to handlers.\n\n**Actions**\n- [ ] Validates action input against the Standard-Schema-equivalent schema before the handler runs.\n- [ ] Returns `-32004 InputValidation` with issues on failure.\n- [ ] Passes output through unchanged by default; validates and returns `-32005` when strict output is enabled and validation fails.\n- [ ] Supports per-invocation timeouts, default 60 000 ms, configurable per action.\n- [ ] Aborts via idiomatic cancellation primitive when the MCP gateway sends `actions/cancel`.\n- [ ] Returns `-32001 Cancelled` on explicit cancel; `-32002 Timeout` on timer.\n- [ ] Emits `actions/progress` notifications from `ctx.progress(...)`.\n\n**Sampling / Confirmation / Elicitation**\n- [ ] Sends `sampling/request` / `elicitation/request` as requests (not notifications).\n- [ ] `ctx.confirm` sends an elicit with an empty-properties object schema and collapses decline / cancel / missing-capability to `false`.\n- [ ] `ctx.elicit` validates the response against the supplied Standard Schema and returns `null` on decline / cancel.\n- [ ] Raises a typed error (`SamplingNotAvailable`, `ElicitationNotAvailable`) when capabilities don't include them - except `ctx.confirm`, which swallows missing elicitation and returns `false`.\n- [ ] Rejects top-level non-object / `oneOf` / `anyOf` / nested-object elicit schemas with `-32602 InvalidParams` at the call site.\n- [ ] Caps sampling depth at 3 (or honours the gateway's cap).\n\n**Resources**\n- [ ] Responds to `resources/read` with `{ value }`.\n- [ ] Accepts `resources/subscribe` and returns the emitter callback's unsubscribe.\n- [ ] Sends `resources/updated` notifications on change.\n- [ ] Cleans up subscriptions on `resources/unsubscribe` and on transport close.\n\n**Lifecycle**\n- [ ] On transport close: rejects all pending outbound requests, aborts all in-flight invocations, clears all subscriptions.\n- [ ] Does not auto-reconnect silently.\n\n**Error model**\n- [ ] Uses exactly the Tesseron error codes from [the errors catalog](/protocol/errors/).\n- [ ] Preserves `error.data` verbatim when surfacing errors to handlers / users.\n\n**Interop**\n- [ ] Round-trips with the reference `@tesseron/mcp` gateway against at least one real MCP client (Claude Code, Cursor, Claude Desktop).\n\n## Step 8 - publish + link\n\nOpen a PR against the main Tesseron repo adding your SDK to the README. Add a page to this docs site under `/sdk/<your-language>/` mirroring the Python skeleton.\n\nOnce your SDK has shipped a 1.0 that passes the checklist on real agents, we'll happily link it as a first-class implementation.\n","bodyText":"Tesseron's wire protocol is small enough that a competent engineer can implement an SDK for a new language in a couple of days. This page is your map.\n\n## What you're actually building\n\nA Tesseron SDK is three things glued together:\n\n1. **A WebSocket client** that speaks JSON-RPC 2.0.\n2. **A builder DSL** for declaring actions and resources.\n3. **A bridge** between the JSON-RPC dispatcher and the builder's registered handlers.\n\nThe full [protocol spec](/protocol/) is the source of truth. If anything on this page contradicts it, the protocol wins.\n\n## Step 1 - pick a runtime model\n\nTwo choices decide the shape of everything that follows.\n\n- **Async model.** Native `async`/`await` / futures / goroutines - whatever your language uses for concurrency. All SDK methods that may block (connect, sample, elicit, progress-waiting action handlers) should be async. Synchronous handlers are fine, but the client's I/O loop must not be.\n- **Schema library.** You need a way for users to express typed inputs. Pick one well-known library (Pydantic in Python, `go-playground/validator` in Go, Serde+schemars in Rust), and accept any user-provided schema that can round-trip to JSON Schema.\n\n## Step 2 - model the protocol types\n\nPort these from the [wire format page](/protocol/wire-format/):\n\n- JSON-RPC 2.0 request / notification / success / error envelopes.\n- `HelloParams`, `WelcomeResult`, `ActionAnnotations`, `InvokeParams`, `ProgressParams`, `CancelParams`, `SampleParams`, `ElicitParams`, `ReadParams`, `SubscribeParams`, `UpdatedParams`.\n- The error-code enum from the [errors page](/protocol/errors/).\n\nGive the error codes first-class names. Don't pass bare integers around - they accumulate magic.\n\n## Step 3 - write the dispatcher\n\nA bidirectional JSON-RPC dispatcher with:\n\n- `on(method, handler)` - respond to incoming requests.\n- `onNotification(method, handler)` - respond to incoming notifications.\n- `request(method, params, { timeoutMs })` - send a request, await the response. ID generation, timeout handling, rejection on close.\n- `notify(method, params)` - send a fire-and-forget notification.\n- `receive(message)` - given a parsed JSON-RPC envelope, dispatch to a handler or resolve a pending request.\n\nTest this in isolation against a pair of in-memory dispatchers. No networking yet.\n\n## Step 4 - write the transport\n\nYou're picking a **binding** (which wire format) and writing the SDK-side host. Tesseron's protocol layer is binding-neutral; pick from the [documented bindings](/protocol/transport/) or design a new one.\n\nFor a WebSocket binding:\n\n- Bind `127.0.0.1` on an OS-picked port.\n- Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'ws', url } }` where `url` is the URL just bound.\n- Accept exactly one upgrade request that advertises the `tesseron-gateway` WebSocket subprotocol; reject every other attempt.\n- Serialise outgoing objects with the language's standard JSON library and parse incoming text frames as JSON.\n- Delete the manifest on close.\n\nFor a UDS binding (Linux / macOS):\n\n- Create a private (mode `0700`) directory under `os.tmpdir()`-equivalent. Bind a socket inside it, `chmod 0600` the socket file.\n- Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'uds', path } }`.\n- Accept exactly one connection; reject subsequent connect attempts.\n- Frame messages as NDJSON (`JSON.stringify(msg) + '\\n'`); split incoming bytes on `\\n`.\n- Delete the manifest, the socket file, and the temp dir on close.\n\nThe gateway is always the **client** - it watches `~/.tesseron/instances/`, picks a dialer matching `transport.kind`, and connects. Your runtime never opens an outbound connection; it binds, announces, and waits.\n\nTo add a binding the gateway doesn't yet know about, you also need to ship a `GatewayDialer` for the new `kind` (in TypeScript: `packages/mcp/src/dialer.ts`) and document the wire format under `/protocol/transport-bindings/<kind>/`.\n\nDon't reinvent backoff or reconnect inside the transport - that's the user's job.\n\n## Step 5 - builder DSL\n\nWhatever shape is idiomatic. In TypeScript we use a fluent builder (`action(...).describe(...).input(...).handler(...)`). In Python, decorators. In Rust, probably a struct with a method-chain pattern. What matters is that it ultimately produces an `ActionDefinition`:\n\n```\nActionDefinition {\n name: string;\n description?: string;\n inputSchema?: StandardJsonSchema;\n outputSchema?: StandardJsonSchema;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput: boolean;\n handler: (input, ctx) => output;\n}\n```\n\nSame for `ResourceDefinition`.\n\n## Step 6 - bind it together\n\n```\nclass TesseronClient {\n constructor(transport, dispatcher) { … }\n app(info) { … } // records app manifest for hello\n action(name) { return new Builder(this, name) }\n resource(name) { return new ResBuilder(this, name) }\n async connect() {\n await transport.open();\n dispatcher.on('actions/invoke', this._onInvoke);\n dispatcher.onNotification('actions/cancel', this._onCancel);\n dispatcher.on('resources/read', this._onRead);\n dispatcher.on('resources/subscribe', this._onSub);\n dispatcher.on('resources/unsubscribe', this._onUnsub);\n return await dispatcher.request('tesseron/hello', this._manifest());\n }\n}\n```\n\nEach `on(...)` handler maps to the corresponding builder. Implement progress / sample / elicit / log on the `ActionContext` the same way.\n\n## Step 7 - conformance checklist\n\nBefore you ship, make sure the SDK passes every line of this list. An SDK that fails any line is not Tesseron-compliant.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after the binding's connection becomes ready.\n- [ ] Sends `protocolVersion = \"1.1.0\"` exactly.\n- [ ] Sends `app.id` that matches `/^[a-z][a-z0-9_]*$/`.\n- [ ] Surfaces `welcome.claimCode` to the caller (stdout, event, return value - your choice).\n- [ ] Surfaces `welcome.capabilities` as the authoritative agent capability set to handlers.\n\n**Actions**\n- [ ] Validates action input against the Standard-Schema-equivalent schema before the handler runs.\n- [ ] Returns `-32004 InputValidation` with issues on failure.\n- [ ] Passes output through unchanged by default; validates and returns `-32005` when strict output is enabled and validation fails.\n- [ ] Supports per-invocation timeouts, default 60 000 ms, configurable per action.\n- [ ] Aborts via idiomatic cancellation primitive when the MCP gateway sends `actions/cancel`.\n- [ ] Returns `-32001 Cancelled` on explicit cancel; `-32002 Timeout` on timer.\n- [ ] Emits `actions/progress` notifications from `ctx.progress(...)`.\n\n**Sampling / Confirmation / Elicitation**\n- [ ] Sends `sampling/request` / `elicitation/request` as requests (not notifications).\n- [ ] `ctx.confirm` sends an elicit with an empty-properties object schema and collapses decline / cancel / missing-capability to `false`.\n- [ ] `ctx.elicit` validates the response against the supplied Standard Schema and returns `null` on decline / cancel.\n- [ ] Raises a typed error (`SamplingNotAvailable`, `ElicitationNotAvailable`) when capabilities don't include them - except `ctx.confirm`, which swallows missing elicitation and returns `false`.\n- [ ] Rejects top-level non-object / `oneOf` / `anyOf` / nested-object elicit schemas with `-32602 InvalidParams` at the call site.\n- [ ] Caps sampling depth at 3 (or honours the gateway's cap).\n\n**Resources**\n- [ ] Responds to `resources/read` with `{ value }`.\n- [ ] Accepts `resources/subscribe` and returns the emitter callback's unsubscribe.\n- [ ] Sends `resources/updated` notifications on change.\n- [ ] Cleans up subscriptions on `resources/unsubscribe` and on transport close.\n\n**Lifecycle**\n- [ ] On transport close: rejects all pending outbound requests, aborts all in-flight invocations, clears all subscriptions.\n- [ ] Does not auto-reconnect silently.\n\n**Error model**\n- [ ] Uses exactly the Tesseron error codes from [the errors catalog](/protocol/errors/).\n- [ ] Preserves `error.data` verbatim when surfacing errors to handlers / users.\n\n**Interop**\n- [ ] Round-trips with the reference `@tesseron/mcp` gateway against at least one real MCP client (Claude Code, Cursor, Claude Desktop).\n\n## Step 8 - publish + link\n\nOpen a PR against the main Tesseron repo adding your SDK to the README. Add a page to this docs site under `/sdk/<your-language>/` mirroring the Python skeleton.\n\nOnce your SDK has shipped a 1.0 that passes the checklist on real agents, we'll happily link it as a first-class implementation."},{"slug":"sdk/python/index","title":"Python SDK (planned)","description":"Status and intended shape of a Python implementation of the Tesseron SDK.","section":"sdk","related":["sdk/index","sdk/porting"],"bodyRaw":"\nA Python SDK is on the roadmap but **not yet shipped**.\n\nWhen it lands, it will implement the same [portable SDK contract](/sdk/#the-portable-sdk-contract) as `@tesseron/core`:\n\n- An action builder that accepts any Python validator (Pydantic v2, `msgspec`, `attrs`+`cattrs`) and produces JSON Schema.\n- An invocation context object with `progress`, `sample`, `confirm`, `elicit`, `log`, and an `asyncio.CancelledError`-based cancellation contract.\n- A resource builder with `.read()` and `.subscribe()`.\n- A WebSocket transport using `websockets` or `aiohttp`.\n- A CLI and an optional `FastAPI` integration helper.\n\n## Why Python at all\n\nTwo use cases we hear most:\n\n1. **Backend services already written in Python.** You have a Flask / FastAPI / Django app and don't want to proxy everything through a Node service just to expose it to Claude.\n2. **Local Python tooling.** Jupyter notebooks, data-analysis scripts, personal CLIs - all things where exposing half a dozen actions to Claude adds real leverage.\n\nBoth are better served by a native Python SDK than by shelling out to Node.\n\n## Design notes\n\nRough shape, subject to change:\n\n```python\nfrom tesseron import Tesseron\nfrom pydantic import BaseModel\n\ntesseron = Tesseron(app={\"id\": \"notes\", \"name\": \"Notes\"})\n\nclass CreateNoteInput(BaseModel):\n title: str\n body: str = \"\"\n\n@tesseron.action(\"createNote\", input=CreateNoteInput)\nasync def create_note(input: CreateNoteInput, ctx):\n note = {\"id\": new_id(), \"title\": input.title, \"body\": input.body}\n store.add(note)\n ctx.progress(message=\"saved\", percent=100)\n return note\n\nawait tesseron.connect()\n```\n\nDecorator-flavoured where it fits the ecosystem better than the fluent builder. The wire contract is identical - any Tesseron SDK must produce the same `tesseron/hello` envelope and respond to the same `actions/invoke` request.\n\n## Roadmap\n\n- Early spike: TBD, tracked in the [Tesseron repo](https://github.com/BrainBlend-AI/tesseron).\n- 1.0 target: feature-parity with `@tesseron/core` + `@tesseron/server`.\n\nIf you want to contribute or help shape the API, open a discussion on GitHub.\n","bodyText":"A Python SDK is on the roadmap but **not yet shipped**.\n\nWhen it lands, it will implement the same [portable SDK contract](/sdk/#the-portable-sdk-contract) as `@tesseron/core`:\n\n- An action builder that accepts any Python validator (Pydantic v2, `msgspec`, `attrs`+`cattrs`) and produces JSON Schema.\n- An invocation context object with `progress`, `sample`, `confirm`, `elicit`, `log`, and an `asyncio.CancelledError`-based cancellation contract.\n- A resource builder with `.read()` and `.subscribe()`.\n- A WebSocket transport using `websockets` or `aiohttp`.\n- A CLI and an optional `FastAPI` integration helper.\n\n## Why Python at all\n\nTwo use cases we hear most:\n\n1. **Backend services already written in Python.** You have a Flask / FastAPI / Django app and don't want to proxy everything through a Node service just to expose it to Claude.\n2. **Local Python tooling.** Jupyter notebooks, data-analysis scripts, personal CLIs - all things where exposing half a dozen actions to Claude adds real leverage.\n\nBoth are better served by a native Python SDK than by shelling out to Node.\n\n## Design notes\n\nRough shape, subject to change:\n\n```python\nfrom tesseron import Tesseron\nfrom pydantic import BaseModel\n\ntesseron = Tesseron(app={\"id\": \"notes\", \"name\": \"Notes\"})\n\nclass CreateNoteInput(BaseModel):\n title: str\n body: str = \"\"\n\n@tesseron.action(\"createNote\", input=CreateNoteInput)\nasync def create_note(input: CreateNoteInput, ctx):\n note = {\"id\": new_id(), \"title\": input.title, \"body\": input.body}\n store.add(note)\n ctx.progress(message=\"saved\", percent=100)\n return note\n\nawait tesseron.connect()\n```\n\nDecorator-flavoured where it fits the ecosystem better than the fluent builder. The wire contract is identical - any Tesseron SDK must produce the same `tesseron/hello` envelope and respond to the same `actions/invoke` request.\n\n## Roadmap\n\n- Early spike: TBD, tracked in the [Tesseron repo](https://github.com/BrainBlend-AI/tesseron).\n- 1.0 target: feature-parity with `@tesseron/core` + `@tesseron/server`.\n\nIf you want to contribute or help shape the API, open a discussion on GitHub."},{"slug":"sdk/typescript/action-builder","title":"Action builder","description":"Every step of the fluent builder, what it does, and when to use it.","section":"sdk","related":["protocol/actions","sdk/typescript/standard-schema","sdk/typescript/context"],"bodyRaw":"\nThe action builder is the fluent API on `tesseron.action(name)`. It chains until `.handler(fn)` terminates it with an `ActionDefinition<I, O>`.\n\n## Signature\n\n```ts\ninterface ActionBuilder<I = unknown, O = unknown> {\n describe(description: string): ActionBuilder<I, O>;\n input<NewI>(schema: StandardSchemaV1<NewI>, jsonSchema?: unknown): ActionBuilder<NewI, O>;\n output<NewO>(schema: StandardSchemaV1<NewO>, jsonSchema?: unknown): ActionBuilder<I, NewO>;\n annotate(annotations: ActionAnnotations): ActionBuilder<I, O>;\n timeout(options: { ms: number }): ActionBuilder<I, O>;\n strictOutput(): ActionBuilder<I, O>;\n handler(fn: (input: I, ctx: ActionContext) => O | Promise<O>): ActionDefinition<I, O>;\n}\n```\n\n## `.describe(string)`\n\nHuman-readable description. Shown to the agent's LLM verbatim as the MCP tool description. This is the single biggest lever for getting the agent to call your action correctly; write it as you would write a function docstring for a teammate.\n\n```ts\ntesseron.action('searchProducts')\n .describe(\n 'Search the product catalog. Returns up to `limit` products ordered by ' +\n 'relevance. Use when the user is trying to find items to buy.'\n );\n```\n\n## `.input(schema)` and `.input(schema, jsonSchema)`\n\nBind a Standard Schema validator for input. The schema is used for:\n\n1. **Runtime validation** - invalid input fails with code `-32004` before the handler runs.\n2. **Type inference** - `I` in `handler: (input: I, ctx) => …`.\n3. **JSON Schema export** - for the MCP tool's `inputSchema`.\n\nMost Standard Schema libraries expose JSON-Schema conversion utilities; the SDK uses whatever your validator provides. If the conversion is missing or inadequate, pass a hand-written JSON Schema as the second argument:\n\n```ts\n.input(\n z.object({ sku: z.string(), qty: z.number().int().positive() }),\n { type: 'object', properties: { sku: { type: 'string' }, qty: { type: 'integer', minimum: 1 } }, required: ['sku', 'qty'] },\n)\n```\n\n## `.output(schema)` / `.output(schema, jsonSchema)`\n\nBind a Standard Schema for the return value. By default **this is informational** - the value is passed through unchanged. Call `.strictOutput()` to enforce.\n\n```ts\n.output(z.object({ id: z.string(), itemId: z.string() }))\n```\n\n## `.annotate({…})`\n\nAdvisory metadata surfaced to the agent.\n\n```ts\ninterface ActionAnnotations {\n readOnly?: boolean;\n destructive?: boolean;\n requiresConfirmation?: boolean;\n}\n```\n\n| Field | Use for |\n|---|---|\n| `readOnly: true` | Pure reads. Agent may parallelise. |\n| `destructive: true` | Mutates persistent state. Agent SHOULD warn the user. |\n| `requiresConfirmation: true` | Agent MUST NOT call without explicit user confirmation. Often paired with `ctx.confirm` inside the handler as a second gate. |\n\n## `.timeout({ ms })`\n\nPer-invocation timeout. Default 60 000 ms. When exceeded, the handler's `ctx.signal` aborts and the invocation returns error `-32002 Timeout`.\n\n```ts\n.timeout({ ms: 5 * 60 * 1000 }) // big report, 5 minutes\n```\n\nThe SDK races the handler against the deadline, so the agent receives the timeout response even if the handler is stuck in a non-`AbortSignal`-aware promise. To bound a single inner call from inside the handler, use [`ctx.withTimeout(p, ms)`](/sdk/typescript/context/#ctxwithtimeout-drop-stuck-inner-promises).\n\n## `.strictOutput()`\n\nTurns `.output(schema)` from documentation into enforcement. Validation failure becomes `-32005 HandlerError` with `issues` in `error.data`.\n\n```ts\n.output(z.object({ id: z.string() }))\n.strictOutput()\n```\n\n## `.handler(fn)`\n\nThe actual function. Terminates the builder. Returns an `ActionDefinition<I, O>` that you normally discard - the SDK keeps a reference internally.\n\n```ts\n.handler(async ({ sku, qty }, ctx) => {\n ctx.progress({ message: 'adding', percent: 50 });\n const item = await cart.add(sku, qty);\n return { id: cart.id, itemId: item.id };\n});\n```\n\nThe handler receives `(input: I, ctx: ActionContext)`. See [context API](/sdk/typescript/context/) for what's on `ctx`.\n\n## Full example\n\n```ts\ntesseron\n .action('importCsv')\n .describe('Import products from a remote CSV. Emits progress updates while running.')\n .input(z.object({ url: z.string().url() }))\n .output(z.object({ imported: z.number().int().nonnegative() }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .timeout({ ms: 5 * 60 * 1000 })\n .strictOutput()\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url, { signal: ctx.signal });\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('cancelled');\n ctx.progress({ message: `${i}/${rows.length}`, percent: 5 + Math.floor(i / rows.length * 90) });\n await importBatch(rows.slice(i, i + 100));\n }\n return { imported: rows.length };\n });\n```\n","bodyText":"The action builder is the fluent API on `tesseron.action(name)`. It chains until `.handler(fn)` terminates it with an `ActionDefinition<I, O>`.\n\n## Signature\n\n```ts\ninterface ActionBuilder<I = unknown, O = unknown> {\n describe(description: string): ActionBuilder<I, O>;\n input<NewI>(schema: StandardSchemaV1<NewI>, jsonSchema?: unknown): ActionBuilder<NewI, O>;\n output<NewO>(schema: StandardSchemaV1<NewO>, jsonSchema?: unknown): ActionBuilder<I, NewO>;\n annotate(annotations: ActionAnnotations): ActionBuilder<I, O>;\n timeout(options: { ms: number }): ActionBuilder<I, O>;\n strictOutput(): ActionBuilder<I, O>;\n handler(fn: (input: I, ctx: ActionContext) => O | Promise<O>): ActionDefinition<I, O>;\n}\n```\n\n## `.describe(string)`\n\nHuman-readable description. Shown to the agent's LLM verbatim as the MCP tool description. This is the single biggest lever for getting the agent to call your action correctly; write it as you would write a function docstring for a teammate.\n\n```ts\ntesseron.action('searchProducts')\n .describe(\n 'Search the product catalog. Returns up to `limit` products ordered by ' +\n 'relevance. Use when the user is trying to find items to buy.'\n );\n```\n\n## `.input(schema)` and `.input(schema, jsonSchema)`\n\nBind a Standard Schema validator for input. The schema is used for:\n\n1. **Runtime validation** - invalid input fails with code `-32004` before the handler runs.\n2. **Type inference** - `I` in `handler: (input: I, ctx) => …`.\n3. **JSON Schema export** - for the MCP tool's `inputSchema`.\n\nMost Standard Schema libraries expose JSON-Schema conversion utilities; the SDK uses whatever your validator provides. If the conversion is missing or inadequate, pass a hand-written JSON Schema as the second argument:\n\n```ts\n.input(\n z.object({ sku: z.string(), qty: z.number().int().positive() }),\n { type: 'object', properties: { sku: { type: 'string' }, qty: { type: 'integer', minimum: 1 } }, required: ['sku', 'qty'] },\n)\n```\n\n## `.output(schema)` / `.output(schema, jsonSchema)`\n\nBind a Standard Schema for the return value. By default **this is informational** - the value is passed through unchanged. Call `.strictOutput()` to enforce.\n\n```ts\n.output(z.object({ id: z.string(), itemId: z.string() }))\n```\n\n## `.annotate({…})`\n\nAdvisory metadata surfaced to the agent.\n\n```ts\ninterface ActionAnnotations {\n readOnly?: boolean;\n destructive?: boolean;\n requiresConfirmation?: boolean;\n}\n```\n\n| Field | Use for |\n|---|---|\n| `readOnly: true` | Pure reads. Agent may parallelise. |\n| `destructive: true` | Mutates persistent state. Agent SHOULD warn the user. |\n| `requiresConfirmation: true` | Agent MUST NOT call without explicit user confirmation. Often paired with `ctx.confirm` inside the handler as a second gate. |\n\n## `.timeout({ ms })`\n\nPer-invocation timeout. Default 60 000 ms. When exceeded, the handler's `ctx.signal` aborts and the invocation returns error `-32002 Timeout`.\n\n```ts\n.timeout({ ms: 5 * 60 * 1000 }) // big report, 5 minutes\n```\n\nThe SDK races the handler against the deadline, so the agent receives the timeout response even if the handler is stuck in a non-`AbortSignal`-aware promise. To bound a single inner call from inside the handler, use [`ctx.withTimeout(p, ms)`](/sdk/typescript/context/#ctxwithtimeout-drop-stuck-inner-promises).\n\n## `.strictOutput()`\n\nTurns `.output(schema)` from documentation into enforcement. Validation failure becomes `-32005 HandlerError` with `issues` in `error.data`.\n\n```ts\n.output(z.object({ id: z.string() }))\n.strictOutput()\n```\n\n## `.handler(fn)`\n\nThe actual function. Terminates the builder. Returns an `ActionDefinition<I, O>` that you normally discard - the SDK keeps a reference internally.\n\n```ts\n.handler(async ({ sku, qty }, ctx) => {\n ctx.progress({ message: 'adding', percent: 50 });\n const item = await cart.add(sku, qty);\n return { id: cart.id, itemId: item.id };\n});\n```\n\nThe handler receives `(input: I, ctx: ActionContext)`. See [context API](/sdk/typescript/context/) for what's on `ctx`.\n\n## Full example\n\n```ts\ntesseron\n .action('importCsv')\n .describe('Import products from a remote CSV. Emits progress updates while running.')\n .input(z.object({ url: z.string().url() }))\n .output(z.object({ imported: z.number().int().nonnegative() }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .timeout({ ms: 5 * 60 * 1000 })\n .strictOutput()\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url, { signal: ctx.signal });\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('cancelled');\n ctx.progress({ message: `${i}/${rows.length}`, percent: 5 + Math.floor(i / rows.length * 90) });\n await importBatch(rows.slice(i, i + 100));\n }\n return { imported: rows.length };\n });\n```"},{"slug":"sdk/typescript/context","title":"Context API (progress, sampling, elicit)","description":"Everything available on the `ctx` argument of an action handler.","section":"sdk","related":["protocol/elicitation","protocol/sampling","protocol/progress-cancellation"],"bodyRaw":"\nEvery action handler receives `(input, ctx)`. `ctx: ActionContext` is where the protocol-level capabilities are exposed as methods.\n\n## Shape\n\n```ts\ninterface ActionContext {\n // Identity\n readonly agent: { id: string; name: string };\n readonly agentCapabilities: {\n sampling: boolean;\n elicitation: boolean;\n subscriptions: boolean;\n };\n readonly client: { origin: string; route?: string; userAgent?: string };\n\n // Lifecycle\n readonly signal: AbortSignal;\n withTimeout<T>(value: Promise<T> | T, ms: number): Promise<T>;\n\n // Messaging\n progress(update: { message?: string; percent?: number; data?: unknown }): void;\n sample<T>(req: { prompt: string; schema?: StandardSchemaV1<T>; maxTokens?: number }): Promise<T>;\n confirm(req: { question: string }): Promise<boolean>;\n elicit<T>(req: {\n question: string;\n schema: StandardSchemaV1<T>;\n jsonSchema?: unknown;\n }): Promise<T | null>;\n log(level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: Record<string, unknown>): void;\n}\n```\n\n## `ctx.signal` - cancel & timeout\n\nStandard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Fires when the agent cancels the invocation or the action's timeout expires. The two cases are indistinguishable from the handler - cleanup and yield either way.\n\n```ts\n.handler(async (input, ctx) => {\n const res = await fetch(url, { signal: ctx.signal });\n if (ctx.signal.aborted) throw new Error('cancelled');\n return await res.json();\n});\n```\n\nPass `ctx.signal` to everything that accepts one: `fetch`, `setTimeout`, database drivers, nested `ctx.sample` calls.\n\nThe SDK guarantees the wire is freed at the deadline regardless of whether the handler observes `ctx.signal`. Once the timer fires, the agent receives `-32002 Timeout` (or `-32001 Cancelled` on agent cancellation) immediately. A handler stuck in a non-signal-aware promise keeps running orphaned — that's the app's problem to clean up, but the agent isn't held hostage. See [`ctx.withTimeout`](#ctxwithtimeout-drop-stuck-inner-promises) for the in-handler companion.\n\n## `ctx.withTimeout(value, ms)` - drop stuck inner promises\n\nA small race helper for handlers that wrap browser APIs which don't accept an `AbortSignal` — `modern-screenshot.domToPng`, `<canvas>.toBlob`, `<img>.decode`, `document.fonts.ready`, `Audio.play`, `MediaRecorder`. Resolves with `value` if it settles within `ms`, otherwise rejects with `TimeoutError`. Also rejects if `ctx.signal` aborts first (with the abort reason — `TimeoutError` or `CancelledError`).\n\n```ts\n.handler(async (_input, ctx) => {\n const dataUrl = await ctx.withTimeout(domToPng(document.body), 8_000);\n return { dataUrl };\n});\n```\n\nThe original promise keeps running orphaned; the handler moves on. Use this to bound a single problematic call without giving the whole action a tighter `.timeout({ ms })` than it actually needs.\n\n## `ctx.progress(update)` - streaming updates\n\nFire-and-forget notification. Any combination of the three payload fields works:\n\n```ts\nctx.progress({ message: 'searching' });\nctx.progress({ percent: 40 });\nctx.progress({ message: 'merging results', percent: 80, data: { batchesDone: 3 } });\n```\n\n`ctx.progress` is a fire-and-forget JSON-RPC notification; it never throws. Safe to call unconditionally - when no one is observing (agent didn't supply a `progressToken`, or the MCP client drops them), the MCP gateway just doesn't forward the notification.\n\nKeep the rate reasonable (≤ 2/sec). Progress is rendered in the agent UI; faster rates spam without adding information.\n\n## `ctx.sample(req)` - ask the LLM\n\nRe-enter the agent's LLM for a reasoning step.\n\n```ts\nconst { summary } = await ctx.sample({\n prompt: `Summarise these bug reports in one sentence each:\\n${JSON.stringify(bugs)}`,\n schema: z.object({ summary: z.array(z.string()) }),\n maxTokens: 400,\n});\n```\n\n- `schema` is optional. Without it, you get `string`. With it, the SDK validates and returns the parsed `T`.\n- `maxTokens` is a hint to the agent; honoured at its discretion.\n- Throws `SamplingNotAvailableError` (code `-32006`) if `agentCapabilities.sampling` is false.\n- Throws `SamplingDepthExceededError` (code `-32008`) if you've nested past `maxSamplingDepth` (3).\n\nSee [the sampling protocol page](/protocol/sampling/) for wire format.\n\n## `ctx.confirm(req)` - ask the user yes/no\n\nFor safety gates on destructive actions. Returns `true` only on explicit accept; decline, cancel, and missing elicitation capability all collapse to `false`.\n\n```ts\nconst ok = await ctx.confirm({\n question: `Delete order ${order.number}? This cannot be undone.`,\n});\nif (!ok) return { cancelled: true };\nawait orders.delete(order.id);\n```\n\n- No schema - the Accept/Decline action is the answer.\n- Safe to call unconditionally: when the connected MCP client doesn't advertise elicitation, `confirm` returns `false` (the safe default for destructive gates). You don't need to guard on `ctx.agentCapabilities.elicitation`.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render pure Accept/Decline without an input field.\n\n## `ctx.elicit(req)` - ask the user for structured content\n\nWhen you need a value from the user - a warehouse ID, a new filename, a grace-period choice. The agent renders a form; you get the typed value back.\n\n```ts\nimport { z } from 'zod';\n\nconst nameSchema = z.object({ newName: z.string().min(1) });\n\nconst answer = await ctx.elicit({\n question: `Rename \"${file.name}\" to?`,\n schema: nameSchema,\n jsonSchema: z.toJSONSchema(nameSchema),\n});\nif (answer === null) return { cancelled: true };\nawait file.rename(answer.newName);\n```\n\n- `schema` is the runtime validator (any Standard Schema v1 - Zod, Valibot, ArkType, ...).\n- `jsonSchema` is what the MCP client renders. Optional; if omitted, a permissive single-text-input fallback is sent. For real UX always derive it from your validator (Zod 4: `z.toJSONSchema(schema)`).\n- Returns the validated value on accept, `null` on decline or cancel.\n- Throws `ElicitationNotAvailableError` (code `-32007`) if the agent doesn't support elicitation - structured data has no safe default.\n\nMCP elicit requires the `requestedSchema` to be a flat object of primitive-typed leaves (`string`, `number`, `integer`, `boolean`). The SDK asserts this at the call site and surfaces a clear `InvalidParams` (code `-32602`) error if you send a nested object, array, or `oneOf` / `anyOf` at the top level.\n\n### Which to pick\n\n- Yes/no on a destructive op → `ctx.confirm`.\n- \"Which of these?\" / \"What's the new name?\" → `ctx.elicit` with a schema.\n- Multi-step wizards → separate actions, one question each.\n\n## `ctx.log({ level, message, meta? })` - structured logs\n\n```ts\nctx.log({ level: 'info', message: 'imported CSV', meta: { rows: 1200, durationMs: 4830 } });\nctx.log({ level: 'warn', message: 'column name mismatch, falling back', meta: { column: 'sku_new' } });\nctx.log({ level: 'error', message: 'remote returned 500', meta: { url, status: 500 } });\n```\n\nForwarded to MCP `sendLoggingMessage` with `logger: <app_id>`. Useful because:\n\n- The user sees them in the agent's log panel - helpful context when the invocation succeeds but something went sideways.\n- They're notifications, not requests - never back-pressure the handler.\n\n## `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`\n\nRead-only identity + capability info.\n\n- `ctx.agent.id` - one of `claude-code`, `claude-desktop`, `cursor`, an agent-provided identifier.\n- `ctx.client.origin` - the origin of the app. On the server SDK this is typically a fabricated identifier; on the web SDK it's `window.location.origin`.\n- `ctx.client.route` - the app's current route, if set at `app({})`-time. Useful for routing context into the handler.\n\nGuard feature calls on these before using them:\n\n```ts\nif (ctx.agentCapabilities.sampling) {\n const extracted = await ctx.sample({ prompt, schema });\n return { items: extracted };\n}\nreturn { items: await fallbackSearch(...) };\n```\n","bodyText":"Every action handler receives `(input, ctx)`. `ctx: ActionContext` is where the protocol-level capabilities are exposed as methods.\n\n## Shape\n\n```ts\ninterface ActionContext {\n // Identity\n readonly agent: { id: string; name: string };\n readonly agentCapabilities: {\n sampling: boolean;\n elicitation: boolean;\n subscriptions: boolean;\n };\n readonly client: { origin: string; route?: string; userAgent?: string };\n\n // Lifecycle\n readonly signal: AbortSignal;\n withTimeout<T>(value: Promise<T> | T, ms: number): Promise<T>;\n\n // Messaging\n progress(update: { message?: string; percent?: number; data?: unknown }): void;\n sample<T>(req: { prompt: string; schema?: StandardSchemaV1<T>; maxTokens?: number }): Promise<T>;\n confirm(req: { question: string }): Promise<boolean>;\n elicit<T>(req: {\n question: string;\n schema: StandardSchemaV1<T>;\n jsonSchema?: unknown;\n }): Promise<T | null>;\n log(level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: Record<string, unknown>): void;\n}\n```\n\n## `ctx.signal` - cancel & timeout\n\nStandard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Fires when the agent cancels the invocation or the action's timeout expires. The two cases are indistinguishable from the handler - cleanup and yield either way.\n\n```ts\n.handler(async (input, ctx) => {\n const res = await fetch(url, { signal: ctx.signal });\n if (ctx.signal.aborted) throw new Error('cancelled');\n return await res.json();\n});\n```\n\nPass `ctx.signal` to everything that accepts one: `fetch`, `setTimeout`, database drivers, nested `ctx.sample` calls.\n\nThe SDK guarantees the wire is freed at the deadline regardless of whether the handler observes `ctx.signal`. Once the timer fires, the agent receives `-32002 Timeout` (or `-32001 Cancelled` on agent cancellation) immediately. A handler stuck in a non-signal-aware promise keeps running orphaned — that's the app's problem to clean up, but the agent isn't held hostage. See [`ctx.withTimeout`](#ctxwithtimeout-drop-stuck-inner-promises) for the in-handler companion.\n\n## `ctx.withTimeout(value, ms)` - drop stuck inner promises\n\nA small race helper for handlers that wrap browser APIs which don't accept an `AbortSignal` — `modern-screenshot.domToPng`, `<canvas>.toBlob`, `<img>.decode`, `document.fonts.ready`, `Audio.play`, `MediaRecorder`. Resolves with `value` if it settles within `ms`, otherwise rejects with `TimeoutError`. Also rejects if `ctx.signal` aborts first (with the abort reason — `TimeoutError` or `CancelledError`).\n\n```ts\n.handler(async (_input, ctx) => {\n const dataUrl = await ctx.withTimeout(domToPng(document.body), 8_000);\n return { dataUrl };\n});\n```\n\nThe original promise keeps running orphaned; the handler moves on. Use this to bound a single problematic call without giving the whole action a tighter `.timeout({ ms })` than it actually needs.\n\n## `ctx.progress(update)` - streaming updates\n\nFire-and-forget notification. Any combination of the three payload fields works:\n\n```ts\nctx.progress({ message: 'searching' });\nctx.progress({ percent: 40 });\nctx.progress({ message: 'merging results', percent: 80, data: { batchesDone: 3 } });\n```\n\n`ctx.progress` is a fire-and-forget JSON-RPC notification; it never throws. Safe to call unconditionally - when no one is observing (agent didn't supply a `progressToken`, or the MCP client drops them), the MCP gateway just doesn't forward the notification.\n\nKeep the rate reasonable (≤ 2/sec). Progress is rendered in the agent UI; faster rates spam without adding information.\n\n## `ctx.sample(req)` - ask the LLM\n\nRe-enter the agent's LLM for a reasoning step.\n\n```ts\nconst { summary } = await ctx.sample({\n prompt: `Summarise these bug reports in one sentence each:\\n${JSON.stringify(bugs)}`,\n schema: z.object({ summary: z.array(z.string()) }),\n maxTokens: 400,\n});\n```\n\n- `schema` is optional. Without it, you get `string`. With it, the SDK validates and returns the parsed `T`.\n- `maxTokens` is a hint to the agent; honoured at its discretion.\n- Throws `SamplingNotAvailableError` (code `-32006`) if `agentCapabilities.sampling` is false.\n- Throws `SamplingDepthExceededError` (code `-32008`) if you've nested past `maxSamplingDepth` (3).\n\nSee [the sampling protocol page](/protocol/sampling/) for wire format.\n\n## `ctx.confirm(req)` - ask the user yes/no\n\nFor safety gates on destructive actions. Returns `true` only on explicit accept; decline, cancel, and missing elicitation capability all collapse to `false`.\n\n```ts\nconst ok = await ctx.confirm({\n question: `Delete order ${order.number}? This cannot be undone.`,\n});\nif (!ok) return { cancelled: true };\nawait orders.delete(order.id);\n```\n\n- No schema - the Accept/Decline action is the answer.\n- Safe to call unconditionally: when the connected MCP client doesn't advertise elicitation, `confirm` returns `false` (the safe default for destructive gates). You don't need to guard on `ctx.agentCapabilities.elicitation`.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render pure Accept/Decline without an input field.\n\n## `ctx.elicit(req)` - ask the user for structured content\n\nWhen you need a value from the user - a warehouse ID, a new filename, a grace-period choice. The agent renders a form; you get the typed value back.\n\n```ts\n\nconst nameSchema = z.object({ newName: z.string().min(1) });\n\nconst answer = await ctx.elicit({\n question: `Rename \"${file.name}\" to?`,\n schema: nameSchema,\n jsonSchema: z.toJSONSchema(nameSchema),\n});\nif (answer === null) return { cancelled: true };\nawait file.rename(answer.newName);\n```\n\n- `schema` is the runtime validator (any Standard Schema v1 - Zod, Valibot, ArkType, ...).\n- `jsonSchema` is what the MCP client renders. Optional; if omitted, a permissive single-text-input fallback is sent. For real UX always derive it from your validator (Zod 4: `z.toJSONSchema(schema)`).\n- Returns the validated value on accept, `null` on decline or cancel.\n- Throws `ElicitationNotAvailableError` (code `-32007`) if the agent doesn't support elicitation - structured data has no safe default.\n\nMCP elicit requires the `requestedSchema` to be a flat object of primitive-typed leaves (`string`, `number`, `integer`, `boolean`). The SDK asserts this at the call site and surfaces a clear `InvalidParams` (code `-32602`) error if you send a nested object, array, or `oneOf` / `anyOf` at the top level.\n\n### Which to pick\n\n- Yes/no on a destructive op → `ctx.confirm`.\n- \"Which of these?\" / \"What's the new name?\" → `ctx.elicit` with a schema.\n- Multi-step wizards → separate actions, one question each.\n\n## `ctx.log({ level, message, meta? })` - structured logs\n\n```ts\nctx.log({ level: 'info', message: 'imported CSV', meta: { rows: 1200, durationMs: 4830 } });\nctx.log({ level: 'warn', message: 'column name mismatch, falling back', meta: { column: 'sku_new' } });\nctx.log({ level: 'error', message: 'remote returned 500', meta: { url, status: 500 } });\n```\n\nForwarded to MCP `sendLoggingMessage` with `logger: <app_id>`. Useful because:\n\n- The user sees them in the agent's log panel - helpful context when the invocation succeeds but something went sideways.\n- They're notifications, not requests - never back-pressure the handler.\n\n## `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`\n\nRead-only identity + capability info.\n\n- `ctx.agent.id` - one of `claude-code`, `claude-desktop`, `cursor`, an agent-provided identifier.\n- `ctx.client.origin` - the origin of the app. On the server SDK this is typically a fabricated identifier; on the web SDK it's `window.location.origin`.\n- `ctx.client.route` - the app's current route, if set at `app({})`-time. Useful for routing context into the handler.\n\nGuard feature calls on these before using them:\n\n```ts\nif (ctx.agentCapabilities.sampling) {\n const extracted = await ctx.sample({ prompt, schema });\n return { items: extracted };\n}\nreturn { items: await fallbackSearch(...) };\n```"},{"slug":"sdk/typescript/core","title":"@tesseron/core","description":"The protocol types, builder, JSON-RPC dispatcher, and abstract client that every runtime adapter extends.","section":"sdk","related":["protocol/wire-format","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/core` is the runtime-independent layer. It has **zero runtime dependencies beyond Standard Schema spec types**. If you're writing a custom transport - Bun, Deno, a browser extension background worker, a native WebSocket implementation - you extend `core` directly.\n\nMost consumers don't need this package; they use `@tesseron/web`, `/server`, or `/react`. Use `core` when those don't fit.\n\n## Exports\n\n```ts\nimport {\n // The abstract client (extended by @tesseron/web and @tesseron/server).\n TesseronClient,\n // Builders.\n ActionBuilder, ActionDefinition, ActionHandler,\n ResourceBuilder, ResourceDefinition, ResourceReader, ResourceSubscriber,\n TimeoutOptions,\n // Per-invocation context.\n ActionContext, AgentCapabilities, InvokingAgent, ClientContext,\n ProgressUpdate, SampleRequest, ConfirmRequest, ElicitRequest, LogEntry,\n // Transport contract.\n Transport, TransportClosedError,\n // Wire envelope (JSON-RPC).\n JsonRpcRequest, JsonRpcNotification, JsonRpcResponse, JsonRpcErrorPayload,\n // Error model.\n TesseronError,\n SamplingNotAvailableError, ElicitationNotAvailableError, SamplingDepthExceededError,\n CancelledError, TimeoutError,\n TesseronErrorCode, // numeric enum: InputValidation = -32004, etc.\n // Protocol constants & types.\n PROTOCOL_VERSION, // '1.1.0'\n HelloParams, WelcomeResult, TesseronCapabilities,\n AppMetadata, AgentIdentity, ActionAnnotations,\n ActionInvokeParams, ActionProgressParams, ActionCancelParams,\n ResourceReadParams, ResourceSubscribeParams, ResourceUpdatedParams,\n // v1.1 multi-binding additions.\n TransportSpec, InstanceManifest,\n} from '@tesseron/core';\n```\n\nSibling-package helpers (`JsonRpcDispatcher`, `SDK_CAPABILITIES`, schema helpers, builder implementation classes) live under `@tesseron/core/internal`. They are deliberately excluded from the main entry point and are **not** part of the v1.0 semver contract — treat them as subject to change. Only the `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, and `@tesseron/mcp` packages should import from that subpath.\n\n## `TesseronClient` (abstract)\n\n`@tesseron/web` and `@tesseron/server` each extend this with a transport. The base class's `connect(transport)` takes a concrete `Transport`. The web / server subclasses override it to accept `Transport | string | undefined` so users can pass a URL (or nothing) and get a default WebSocket transport. The subclassing contract:\n\n```ts\nclass MyTesseronClient extends TesseronClient {\n override async connect(target?: Transport | string): Promise<WelcomeResult> {\n if (target && typeof target !== 'string') return super.connect(target);\n const transport = new MyTransport(target ?? DEFAULT_GATEWAY_URL);\n await transport.ready();\n return super.connect(transport);\n }\n}\n```\n\n`super.connect(transport)` wires the dispatcher, sends `tesseron/hello`, handles `actions/invoke`, and returns the `welcome` result.\n\n## `Transport`\n\n```ts\ninterface Transport {\n send(message: unknown): void;\n onMessage(handler: (message: unknown) => void): void;\n onClose(handler: (reason?: string) => void): void;\n close(reason?: string): void;\n}\n```\n\nThe core client assumes the transport passes objects (not strings). If your transport is string-oriented, JSON.parse / stringify at the boundary. WebSocket-based transports in `@tesseron/web` and `@tesseron/server` already do this.\n\n## `JsonRpcDispatcher`\n\nLow-level bidirectional JSON-RPC router:\n\n```ts\ninterface JsonRpcDispatcher {\n on<M>(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void;\n onNotification<N>(method: string, handler: (params: unknown) => void): void;\n request<R>(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise<R>;\n notify(method: string, params?: unknown): void;\n receive(message: unknown): void;\n}\n```\n\nYou typically only use this directly when implementing extension methods. Day-to-day use of Tesseron goes through the builder, not the dispatcher.\n\n## `TesseronError`\n\n```ts\nclass TesseronError extends Error {\n readonly code: number;\n readonly data?: unknown;\n constructor(code: number, message: string, data?: unknown);\n}\n```\n\nThe dispatcher maps it to / from the `{ code, message, data }` JSON-RPC error object automatically. Throw it from handlers to produce a specific JSON-RPC error:\n\n```ts\nimport { TesseronError, TesseronErrorCode } from '@tesseron/core';\n\n.handler(async ({ orderId }, ctx) => {\n const order = await orders.find(orderId);\n if (!order) throw new TesseronError(TesseronErrorCode.ActionNotFound, `no order ${orderId}`, { orderId });\n // …\n});\n```\n\nCatching `TesseronError` is also useful around `ctx.sample` / `ctx.elicit` to pivot on capability errors (note: `ctx.confirm` doesn't throw — it returns `false` when elicitation isn't available, which is the safe default for destructive gates):\n\n```ts\nimport { SamplingNotAvailableError, TesseronError, TesseronErrorCode } from '@tesseron/core';\n\ntry {\n const r = await ctx.sample({ prompt });\n} catch (err) {\n if (err instanceof SamplingNotAvailableError) return fallback();\n // equivalent by code:\n if (err instanceof TesseronError && err.code === TesseronErrorCode.SamplingNotAvailable) {\n return fallback();\n }\n throw err;\n}\n```\n\n## Bringing your own transport\n\nA minimal example, for clarity - a loopback transport pair for tests:\n\n```ts\nimport { Transport, TesseronClient } from '@tesseron/core';\n\nfunction pair(): [Transport, Transport] {\n const aInbox: Array<(m: unknown) => void> = [];\n const bInbox: Array<(m: unknown) => void> = [];\n const a: Transport = {\n send: (m) => bInbox.forEach((h) => h(m)),\n onMessage: (h) => aInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n const b: Transport = {\n send: (m) => aInbox.forEach((h) => h(m)),\n onMessage: (h) => bInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n return [a, b];\n}\n```\n\nYou can attach a `TesseronClient` subclass to one side and a mock gateway to the other. Both `@tesseron/mcp` and the SDK test suites rely on patterns like this.\n","bodyText":"`@tesseron/core` is the runtime-independent layer. It has **zero runtime dependencies beyond Standard Schema spec types**. If you're writing a custom transport - Bun, Deno, a browser extension background worker, a native WebSocket implementation - you extend `core` directly.\n\nMost consumers don't need this package; they use `@tesseron/web`, `/server`, or `/react`. Use `core` when those don't fit.\n\n## Exports\n\n```ts\n\n // The abstract client (extended by @tesseron/web and @tesseron/server).\n TesseronClient,\n // Builders.\n ActionBuilder, ActionDefinition, ActionHandler,\n ResourceBuilder, ResourceDefinition, ResourceReader, ResourceSubscriber,\n TimeoutOptions,\n // Per-invocation context.\n ActionContext, AgentCapabilities, InvokingAgent, ClientContext,\n ProgressUpdate, SampleRequest, ConfirmRequest, ElicitRequest, LogEntry,\n // Transport contract.\n Transport, TransportClosedError,\n // Wire envelope (JSON-RPC).\n JsonRpcRequest, JsonRpcNotification, JsonRpcResponse, JsonRpcErrorPayload,\n // Error model.\n TesseronError,\n SamplingNotAvailableError, ElicitationNotAvailableError, SamplingDepthExceededError,\n CancelledError, TimeoutError,\n TesseronErrorCode, // numeric enum: InputValidation = -32004, etc.\n // Protocol constants & types.\n PROTOCOL_VERSION, // '1.1.0'\n HelloParams, WelcomeResult, TesseronCapabilities,\n AppMetadata, AgentIdentity, ActionAnnotations,\n ActionInvokeParams, ActionProgressParams, ActionCancelParams,\n ResourceReadParams, ResourceSubscribeParams, ResourceUpdatedParams,\n // v1.1 multi-binding additions.\n TransportSpec, InstanceManifest,\n} from '@tesseron/core';\n```\n\nSibling-package helpers (`JsonRpcDispatcher`, `SDK_CAPABILITIES`, schema helpers, builder implementation classes) live under `@tesseron/core/internal`. They are deliberately excluded from the main entry point and are **not** part of the v1.0 semver contract — treat them as subject to change. Only the `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, and `@tesseron/mcp` packages should import from that subpath.\n\n## `TesseronClient` (abstract)\n\n`@tesseron/web` and `@tesseron/server` each extend this with a transport. The base class's `connect(transport)` takes a concrete `Transport`. The web / server subclasses override it to accept `Transport | string | undefined` so users can pass a URL (or nothing) and get a default WebSocket transport. The subclassing contract:\n\n```ts\nclass MyTesseronClient extends TesseronClient {\n override async connect(target?: Transport | string): Promise<WelcomeResult> {\n if (target && typeof target !== 'string') return super.connect(target);\n const transport = new MyTransport(target ?? DEFAULT_GATEWAY_URL);\n await transport.ready();\n return super.connect(transport);\n }\n}\n```\n\n`super.connect(transport)` wires the dispatcher, sends `tesseron/hello`, handles `actions/invoke`, and returns the `welcome` result.\n\n## `Transport`\n\n```ts\ninterface Transport {\n send(message: unknown): void;\n onMessage(handler: (message: unknown) => void): void;\n onClose(handler: (reason?: string) => void): void;\n close(reason?: string): void;\n}\n```\n\nThe core client assumes the transport passes objects (not strings). If your transport is string-oriented, JSON.parse / stringify at the boundary. WebSocket-based transports in `@tesseron/web` and `@tesseron/server` already do this.\n\n## `JsonRpcDispatcher`\n\nLow-level bidirectional JSON-RPC router:\n\n```ts\ninterface JsonRpcDispatcher {\n on<M>(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void;\n onNotification<N>(method: string, handler: (params: unknown) => void): void;\n request<R>(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise<R>;\n notify(method: string, params?: unknown): void;\n receive(message: unknown): void;\n}\n```\n\nYou typically only use this directly when implementing extension methods. Day-to-day use of Tesseron goes through the builder, not the dispatcher.\n\n## `TesseronError`\n\n```ts\nclass TesseronError extends Error {\n readonly code: number;\n readonly data?: unknown;\n constructor(code: number, message: string, data?: unknown);\n}\n```\n\nThe dispatcher maps it to / from the `{ code, message, data }` JSON-RPC error object automatically. Throw it from handlers to produce a specific JSON-RPC error:\n\n```ts\n\n.handler(async ({ orderId }, ctx) => {\n const order = await orders.find(orderId);\n if (!order) throw new TesseronError(TesseronErrorCode.ActionNotFound, `no order ${orderId}`, { orderId });\n // …\n});\n```\n\nCatching `TesseronError` is also useful around `ctx.sample` / `ctx.elicit` to pivot on capability errors (note: `ctx.confirm` doesn't throw — it returns `false` when elicitation isn't available, which is the safe default for destructive gates):\n\n```ts\n\ntry {\n const r = await ctx.sample({ prompt });\n} catch (err) {\n if (err instanceof SamplingNotAvailableError) return fallback();\n // equivalent by code:\n if (err instanceof TesseronError && err.code === TesseronErrorCode.SamplingNotAvailable) {\n return fallback();\n }\n throw err;\n}\n```\n\n## Bringing your own transport\n\nA minimal example, for clarity - a loopback transport pair for tests:\n\n```ts\n\nfunction pair(): [Transport, Transport] {\n const aInbox: Array<(m: unknown) => void> = [];\n const bInbox: Array<(m: unknown) => void> = [];\n const a: Transport = {\n send: (m) => bInbox.forEach((h) => h(m)),\n onMessage: (h) => aInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n const b: Transport = {\n send: (m) => aInbox.forEach((h) => h(m)),\n onMessage: (h) => bInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n return [a, b];\n}\n```\n\nYou can attach a `TesseronClient` subclass to one side and a mock gateway to the other. Both `@tesseron/mcp` and the SDK test suites rely on patterns like this."},{"slug":"sdk/typescript/index","title":"Install & first action","description":"The minimum code to go from zero to a working Tesseron integration in TypeScript.","section":"sdk","related":["sdk/typescript/action-builder","sdk/typescript/web","sdk/typescript/standard-schema","overview/quickstart"],"bodyRaw":"\nimport { Tabs, TabItem, Steps } from '@astrojs/starlight/components';\n\n<Steps>\n\n1. **Install the package that matches your runtime.**\n\n <Tabs>\n <TabItem label=\"Browser (Vite)\">\n ```bash\n pnpm add @tesseron/web zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Vue\">\n ```bash\n pnpm add @tesseron/vue zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Svelte\">\n ```bash\n pnpm add @tesseron/svelte zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"React\">\n ```bash\n pnpm add @tesseron/react zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Node (server, worker)\">\n ```bash\n pnpm add @tesseron/server zod\n ```\n </TabItem>\n <TabItem label=\"Bring-your-own transport\">\n ```bash\n pnpm add @tesseron/core zod\n ```\n </TabItem>\n </Tabs>\n\n Browser apps also need the [`@tesseron/vite`](/sdk/typescript/vite/) plugin registered in `vite.config.ts` to serve `/@tesseron/ws`. Node apps don't - `@tesseron/server` binds and announces itself automatically.\n\n `zod` is used in the examples here; swap it for any [Standard Schema](https://standardschema.dev)-compatible validator - Valibot, ArkType, Effect Schema.\n\n2. **Name your app.** `app.id` is what the agent's tool names get prefixed with. It must match `/^[a-z][a-z0-9_]*$/`.\n\n ```ts\n import { tesseron } from '@tesseron/web';\n\n tesseron.app({\n id: 'notes',\n name: 'My Notes App',\n description: 'Create and organise notes',\n });\n ```\n\n3. **Declare an action.**\n\n ```ts\n import { z } from 'zod';\n\n tesseron\n .action('createNote')\n .describe('Create a new note')\n .input(z.object({ title: z.string().min(1), body: z.string().default('') }))\n .handler(({ title, body }) => {\n const note = { id: crypto.randomUUID(), title, body, createdAt: Date.now() };\n store.add(note);\n return note;\n });\n ```\n\n4. **Connect.**\n\n ```ts\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n ```\n\n The MCP gateway prints the claim code to its stderr; you can also surface it in your UI.\n\n5. **Claim from the agent.** In Claude Code: *\"Claim Tesseron session &lt;code&gt;.\"* Once claimed, `notes__createNote` appears in the tool list.\n\n</Steps>\n\n## Typical entry-point layouts\n\n<Tabs>\n <TabItem label=\"Browser SPA\">\n ```ts title=\"src/tesseron.ts\"\n import { tesseron } from '@tesseron/web';\n import { z } from 'zod';\n\n tesseron.app({ id: 'notes', name: 'Notes' });\n\n tesseron.action('createNote')\n .input(z.object({ title: z.string(), body: z.string() }))\n .handler(({ title, body }) => notesStore.add({ title, body }));\n\n export const connect = () => tesseron.connect();\n ```\n\n ```ts title=\"src/main.ts\"\n import { connect } from './tesseron';\n connect(); // fire and forget; retry in UI if you like.\n ```\n </TabItem>\n\n <TabItem label=\"React\">\n ```tsx title=\"src/app.tsx\"\n import { useTesseronAction, useTesseronConnection } from '@tesseron/react';\n import { z } from 'zod';\n\n export function App() {\n const { claimCode, status } = useTesseronConnection();\n\n useTesseronAction('createNote', {\n input: z.object({ title: z.string(), body: z.string() }),\n handler: ({ title, body }) => notesStore.add({ title, body }),\n });\n\n return (\n <div>\n {status === 'open' && claimCode && <ClaimCodeBanner code={claimCode} />}\n <Notes />\n </div>\n );\n }\n ```\n </TabItem>\n\n <TabItem label=\"Node server\">\n ```ts title=\"src/index.ts\"\n import { tesseron } from '@tesseron/server';\n import { z } from 'zod';\n\n tesseron.app({ id: 'notes_api', name: 'Notes API' });\n\n tesseron.action('createNote')\n .input(z.object({ title: z.string(), body: z.string() }))\n .handler(async ({ title, body }) => db.notes.insert({ title, body }));\n\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n\n process.on('SIGINT', async () => {\n await tesseron.disconnect();\n process.exit(0);\n });\n ```\n </TabItem>\n</Tabs>\n\n## Where to go next\n\n- The [action builder](/sdk/typescript/action-builder/) in full - chaining, output validation, annotations, timeouts.\n- [Standard Schema validators](/sdk/typescript/standard-schema/) - Zod vs Valibot vs ArkType, and JSON Schema interop.\n- The [context API](/sdk/typescript/context/) - progress, sampling, elicitation, logging.\n- Framework adapters: [@tesseron/react](/sdk/typescript/react/), [@tesseron/svelte](/sdk/typescript/svelte/), [@tesseron/vue](/sdk/typescript/vue/).\n- [@tesseron/vite](/sdk/typescript/vite/) - the dev-server bridge that makes browser apps reachable.\n","bodyText":"## Typical entry-point layouts\n\n## Where to go next\n\n- The [action builder](/sdk/typescript/action-builder/) in full - chaining, output validation, annotations, timeouts.\n- [Standard Schema validators](/sdk/typescript/standard-schema/) - Zod vs Valibot vs ArkType, and JSON Schema interop.\n- The [context API](/sdk/typescript/context/) - progress, sampling, elicitation, logging.\n- Framework adapters: [@tesseron/react](/sdk/typescript/react/), [@tesseron/svelte](/sdk/typescript/svelte/), [@tesseron/vue](/sdk/typescript/vue/).\n- [@tesseron/vite](/sdk/typescript/vite/) - the dev-server bridge that makes browser apps reachable."},{"slug":"sdk/typescript/mcp","title":"@tesseron/mcp (MCP gateway)","description":"The MCP gateway process - a transport-agnostic dialer that discovers apps via ~/.tesseron/instances/ and bridges them to an MCP stdio transport. Bundled into the Claude Code plugin; you rarely run it by hand.","section":"sdk","related":["protocol/handshake","protocol/security","protocol/transport","protocol/transport-bindings/ws","protocol/transport-bindings/uds"],"bodyRaw":"\n`@tesseron/mcp` is the MCP gateway. It:\n\n- Watches `~/.tesseron/instances/` (and the legacy `~/.tesseron/tabs/` for one minor) for per-app instance manifests, picks a dialer matching the manifest's `transport.kind`, and connects.\n- Runs an MCP stdio server that the agent connects to.\n- Translates between the two, maintains session state, handles claim codes, fans out progress / sampling / elicitation across the boundary.\n\nThe gateway itself binds no ports. It is always a transport client — apps host, the gateway dials. This is what makes the same gateway work for browser tabs (via `@tesseron/vite`), Node processes over WebSocket or Unix domain sockets (via `@tesseron/server`), and anything else that can host one of the documented [transport bindings](/protocol/transport/) and write an instance manifest.\n\n99% of users never invoke it directly - the Claude Code plugin spawns it automatically. This page is for the 1%.\n\n## Running it manually\n\n```bash\npnpm dlx @tesseron/mcp\n```\n\nIt starts, listens on stdio for MCP, and begins watching `~/.tesseron/instances/`. Kill it with Ctrl-C.\n\n## Environment\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TESSERON_TOOL_SURFACE` | `both` | `dynamic` / `meta` / `both`. Controls which MCP tools the bridge advertises (per-app tools, meta-dispatcher tools, or both). |\n\nThat's the whole list. No ports, no hosts, no allowlists - the gateway has nothing to bind, so it has nothing to configure.\n\nThe advertised protocol version is pinned to `PROTOCOL_VERSION` in `@tesseron/core` and is not configurable at runtime.\n\n## Discovery\n\nApps announce themselves by writing a JSON v2 manifest to `~/.tesseron/instances/<instanceId>.json`:\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-abc123\",\n \"appName\": \"vue-todo\",\n \"addedAt\": 1777038462692,\n \"pid\": 24837,\n \"transport\":\n | { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n | { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\n`pid` is optional. Gateways probe `process.kill(pid, 0)` on each manifest before dialing and tombstone manifests whose owner is gone, so a dev server killed without a clean shutdown doesn't leave a corpse the gateway re-dials forever. Older SDKs that omit the field stay trusted.\n\nThe gateway watches the directory (inotify / `fs.watch`, with a 2-second poll as a platform fallback), notices the new file, picks the dialer matching `transport.kind`, and connects. The app accepts that one connection; the standard `tesseron/hello` → `welcome` handshake follows.\n\nFor one minor version (1.1.x), the gateway also reads the legacy v1 directory `~/.tesseron/tabs/<tabId>.json` and coerces those manifests to `{ kind: 'ws', url: <wsUrl> }`. New SDKs only ever write `instances/`.\n\nA v1.2-aware host (the `@tesseron/vite` plugin since 2.2.0) writes two extra optional fields alongside the v2 baseline: `helloHandledByHost: true` and `hostMintedClaim: { code, sessionId, mintedAt, boundAgent }`. The gateway treats these as the signal \"don't auto-dial; wait for `tesseron__claim_session`\". When the user pastes the host-minted code, the gateway scans every host-mint manifest for a matching `hostMintedClaim.code`, dials only that one with a `tesseron-bind.<code>` subprotocol element on the upgrade, and the host validates the bind in constant time before accepting. v1.1 gateways ignore the new fields and fall back to legacy auto-dial; v1.2 hosts paired with v1.1 gateways detect the absent bind subprotocol and serve the legacy gateway-mints flow. See [tesseron#60](https://github.com/BrainBlend-AI/tesseron/issues/60).\n\nWhen the app process dies, the channel closes and the gateway drops the session. The app is also expected to delete its own manifest on graceful shutdown.\n\nDiscovery and dial outcomes (connect successes, connect failures, stale-manifest tombstones, foreign-claim probe results) are forwarded to the connected MCP client via `notifications/message` (`logger: \"tesseron.discovery\"`), so a developer running Claude Code sees them inline rather than having to grep `~/.claude/`. Set the level on the client side via `logging/setLevel` to filter. Stderr still receives the same lines for grep-ability.\n\nShipping support for a new runtime is three steps:\n\n1. Bind whichever [transport binding](/protocol/transport/) fits the runtime (WS, UDS, …).\n2. Write `~/.tesseron/instances/<instanceId>.json` with the matching `transport` spec.\n3. Accept the gateway's inbound connection and speak the [Tesseron wire protocol](/protocol/).\n\nThe SDK packages `@tesseron/vite` and `@tesseron/server` are reference implementations.\n\n## MCP stdio channel\n\nWhen an MCP client spawns the gateway, the gateway exposes:\n\n- One built-in tool: `tesseron__claim_session`. Always present.\n- One tool per registered action across all connected sessions, named `<app_id>__<action_name>`.\n- One resource per registered resource, URI `tesseron://<app_id>/<resource_name>`.\n- Four meta-dispatcher tools in the default `both` / `meta` surface modes:\n - `tesseron__list_actions` — enumerates every claimed session's actions and resources, plus the gateway's advertised MCP server name.\n - `tesseron__invoke_action({ app_id, action, args })` — calls any action without needing the per-app tool to be in the client's tool list.\n - `tesseron__read_resource({ app_id, name })` — reads a resource without needing the agent to know the client-side MCP server identifier (which varies by how the server is mounted; e.g. `plugin:tesseron:tesseron` under a Claude Code plugin vs. `tesseron` in a raw config). Prefer this over the generic `ReadMcpResourceTool`.\n - `tesseron__list_pending_claims` — lists every claim code the gateway can currently redeem (gateway-minted sessions waiting for claim, plus host-minted manifests with an unconsumed code). Recovery path when a previously-claimed session is invalidated mid-conversation (browser refresh, dev-server reload, resume failure) and a tools/call returns \"No claimed session found\" — call this, pick the entry whose `app_id` matches, then call `tesseron__claim_session({ code })` to re-pair without asking the user to read the new code from the app UI. See [tesseron#69](https://github.com/BrainBlend-AI/tesseron/issues/69).\n- Full MCP logging (`sendLoggingMessage`), progress (`notifications/progress`), sampling (`createMessage`), and elicitation (`elicitInput`).\n\nWhenever a session connects, claims, or drops, the gateway emits `notifications/tools/list_changed` and `notifications/resources/list_changed`. The agent refreshes automatically.\n\n## Multiple sessions\n\nThe gateway keeps a `Map<sessionId, Session>` internally. Each session has:\n\n- The registered app manifest (actions + resources).\n- A `pendingClaim` until claimed.\n- The outbound transport the gateway dialed.\n- In-flight invocation state.\n\nRouting: `tools/call shop__searchProducts` finds the session whose `app.id === \"shop\"`, dispatches `actions/invoke`, waits for the response, maps it back to an MCP tool result. If the session dropped between listing and call, the gateway returns error `-32003 ActionNotFound`.\n\n## Claim code generation\n\nCodes are six alphanumeric characters minus confusables (no `0`, `1`, `I`, `L`, `O`), formatted `AAAA-BB`. Drawn from the platform CSPRNG (`crypto.getRandomValues`) with rejection sampling so the distribution across the 31-character alphabet is uniform. Stored on the session, claimed via `gateway.claimSession(code)`, cleaned on claim or session close.\n\nEach minted code also drops a breadcrumb at `~/.tesseron/claims/<CODE>.json` so a sibling gateway (a parallel Claude Code session, a leftover dev gateway) that receives `tesseron__claim_session` for a code it doesn't own locally can surface a \"claim code belongs to gateway pid N\" error instead of a flat \"no pending session\". The breadcrumb is removed on successful claim, on unclaimed close, and on `gateway.stop()`. Embedders building their own claim UI can call `gateway.describeForeignClaim(code)` to drive the same behaviour. See the [handshake page](/protocol/handshake/#multiple-gateways-on-one-machine) for the full picture.\n\n## Where the plugin bundles it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo bundles the gateway as `plugin/server/index.cjs`, built via:\n\n```bash\npnpm --filter @tesseron/mcp build:plugin\n```\n\nThis esbuild bundle is what ships to plugin installers. If you're hacking on the gateway, rebuild the plugin bundle before testing against Claude Code.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `packages/mcp/src/cli.ts` - entry point.\n- `packages/mcp/src/gateway.ts` - session management, dialer dispatcher, instances-directory watcher.\n- `packages/mcp/src/dialer.ts` - per-binding dialers (`WsDialer`, `UdsDialer`).\n- `packages/mcp/src/session.ts` - a single session's state + claim code.\n- `packages/mcp/src/mcp-bridge.ts` - MCP stdio server + protocol translation.\n\nAdding a new method (e.g., a custom `tesseron__debug_dump` tool) means editing `mcp-bridge.ts` for the MCP side and routing through `gateway.ts` if it also crosses the SDK channel. Keep new methods under a `tesseron__` prefix to avoid colliding with app action tools.\n\nAdding a new **transport binding**: implement `GatewayDialer` for the new `kind`, register it in the gateway constructor, ship a host transport on the SDK side, document the wire format under `/protocol/transport-bindings/`. See [Port Tesseron to your language](/sdk/porting/) for the full rubric.\n\n## Not for production agents\n\nThis is a local developer tool. Apps bind locally only; the gateway only dials local endpoints. If you need remote-agent support, build a reverse-tunnel with explicit authentication in front.\n","bodyText":"`@tesseron/mcp` is the MCP gateway. It:\n\n- Watches `~/.tesseron/instances/` (and the legacy `~/.tesseron/tabs/` for one minor) for per-app instance manifests, picks a dialer matching the manifest's `transport.kind`, and connects.\n- Runs an MCP stdio server that the agent connects to.\n- Translates between the two, maintains session state, handles claim codes, fans out progress / sampling / elicitation across the boundary.\n\nThe gateway itself binds no ports. It is always a transport client — apps host, the gateway dials. This is what makes the same gateway work for browser tabs (via `@tesseron/vite`), Node processes over WebSocket or Unix domain sockets (via `@tesseron/server`), and anything else that can host one of the documented [transport bindings](/protocol/transport/) and write an instance manifest.\n\n99% of users never invoke it directly - the Claude Code plugin spawns it automatically. This page is for the 1%.\n\n## Running it manually\n\n```bash\npnpm dlx @tesseron/mcp\n```\n\nIt starts, listens on stdio for MCP, and begins watching `~/.tesseron/instances/`. Kill it with Ctrl-C.\n\n## Environment\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TESSERON_TOOL_SURFACE` | `both` | `dynamic` / `meta` / `both`. Controls which MCP tools the bridge advertises (per-app tools, meta-dispatcher tools, or both). |\n\nThat's the whole list. No ports, no hosts, no allowlists - the gateway has nothing to bind, so it has nothing to configure.\n\nThe advertised protocol version is pinned to `PROTOCOL_VERSION` in `@tesseron/core` and is not configurable at runtime.\n\n## Discovery\n\nApps announce themselves by writing a JSON v2 manifest to `~/.tesseron/instances/<instanceId>.json`:\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-abc123\",\n \"appName\": \"vue-todo\",\n \"addedAt\": 1777038462692,\n \"pid\": 24837,\n \"transport\":\n | { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n | { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\n`pid` is optional. Gateways probe `process.kill(pid, 0)` on each manifest before dialing and tombstone manifests whose owner is gone, so a dev server killed without a clean shutdown doesn't leave a corpse the gateway re-dials forever. Older SDKs that omit the field stay trusted.\n\nThe gateway watches the directory (inotify / `fs.watch`, with a 2-second poll as a platform fallback), notices the new file, picks the dialer matching `transport.kind`, and connects. The app accepts that one connection; the standard `tesseron/hello` → `welcome` handshake follows.\n\nFor one minor version (1.1.x), the gateway also reads the legacy v1 directory `~/.tesseron/tabs/<tabId>.json` and coerces those manifests to `{ kind: 'ws', url: <wsUrl> }`. New SDKs only ever write `instances/`.\n\nA v1.2-aware host (the `@tesseron/vite` plugin since 2.2.0) writes two extra optional fields alongside the v2 baseline: `helloHandledByHost: true` and `hostMintedClaim: { code, sessionId, mintedAt, boundAgent }`. The gateway treats these as the signal \"don't auto-dial; wait for `tesseron__claim_session`\". When the user pastes the host-minted code, the gateway scans every host-mint manifest for a matching `hostMintedClaim.code`, dials only that one with a `tesseron-bind.<code>` subprotocol element on the upgrade, and the host validates the bind in constant time before accepting. v1.1 gateways ignore the new fields and fall back to legacy auto-dial; v1.2 hosts paired with v1.1 gateways detect the absent bind subprotocol and serve the legacy gateway-mints flow. See [tesseron#60](https://github.com/BrainBlend-AI/tesseron/issues/60).\n\nWhen the app process dies, the channel closes and the gateway drops the session. The app is also expected to delete its own manifest on graceful shutdown.\n\nDiscovery and dial outcomes (connect successes, connect failures, stale-manifest tombstones, foreign-claim probe results) are forwarded to the connected MCP client via `notifications/message` (`logger: \"tesseron.discovery\"`), so a developer running Claude Code sees them inline rather than having to grep `~/.claude/`. Set the level on the client side via `logging/setLevel` to filter. Stderr still receives the same lines for grep-ability.\n\nShipping support for a new runtime is three steps:\n\n1. Bind whichever [transport binding](/protocol/transport/) fits the runtime (WS, UDS, …).\n2. Write `~/.tesseron/instances/<instanceId>.json` with the matching `transport` spec.\n3. Accept the gateway's inbound connection and speak the [Tesseron wire protocol](/protocol/).\n\nThe SDK packages `@tesseron/vite` and `@tesseron/server` are reference implementations.\n\n## MCP stdio channel\n\nWhen an MCP client spawns the gateway, the gateway exposes:\n\n- One built-in tool: `tesseron__claim_session`. Always present.\n- One tool per registered action across all connected sessions, named `<app_id>__<action_name>`.\n- One resource per registered resource, URI `tesseron://<app_id>/<resource_name>`.\n- Four meta-dispatcher tools in the default `both` / `meta` surface modes:\n - `tesseron__list_actions` — enumerates every claimed session's actions and resources, plus the gateway's advertised MCP server name.\n - `tesseron__invoke_action({ app_id, action, args })` — calls any action without needing the per-app tool to be in the client's tool list.\n - `tesseron__read_resource({ app_id, name })` — reads a resource without needing the agent to know the client-side MCP server identifier (which varies by how the server is mounted; e.g. `plugin:tesseron:tesseron` under a Claude Code plugin vs. `tesseron` in a raw config). Prefer this over the generic `ReadMcpResourceTool`.\n - `tesseron__list_pending_claims` — lists every claim code the gateway can currently redeem (gateway-minted sessions waiting for claim, plus host-minted manifests with an unconsumed code). Recovery path when a previously-claimed session is invalidated mid-conversation (browser refresh, dev-server reload, resume failure) and a tools/call returns \"No claimed session found\" — call this, pick the entry whose `app_id` matches, then call `tesseron__claim_session({ code })` to re-pair without asking the user to read the new code from the app UI. See [tesseron#69](https://github.com/BrainBlend-AI/tesseron/issues/69).\n- Full MCP logging (`sendLoggingMessage`), progress (`notifications/progress`), sampling (`createMessage`), and elicitation (`elicitInput`).\n\nWhenever a session connects, claims, or drops, the gateway emits `notifications/tools/list_changed` and `notifications/resources/list_changed`. The agent refreshes automatically.\n\n## Multiple sessions\n\nThe gateway keeps a `Map<sessionId, Session>` internally. Each session has:\n\n- The registered app manifest (actions + resources).\n- A `pendingClaim` until claimed.\n- The outbound transport the gateway dialed.\n- In-flight invocation state.\n\nRouting: `tools/call shop__searchProducts` finds the session whose `app.id === \"shop\"`, dispatches `actions/invoke`, waits for the response, maps it back to an MCP tool result. If the session dropped between listing and call, the gateway returns error `-32003 ActionNotFound`.\n\n## Claim code generation\n\nCodes are six alphanumeric characters minus confusables (no `0`, `1`, `I`, `L`, `O`), formatted `AAAA-BB`. Drawn from the platform CSPRNG (`crypto.getRandomValues`) with rejection sampling so the distribution across the 31-character alphabet is uniform. Stored on the session, claimed via `gateway.claimSession(code)`, cleaned on claim or session close.\n\nEach minted code also drops a breadcrumb at `~/.tesseron/claims/<CODE>.json` so a sibling gateway (a parallel Claude Code session, a leftover dev gateway) that receives `tesseron__claim_session` for a code it doesn't own locally can surface a \"claim code belongs to gateway pid N\" error instead of a flat \"no pending session\". The breadcrumb is removed on successful claim, on unclaimed close, and on `gateway.stop()`. Embedders building their own claim UI can call `gateway.describeForeignClaim(code)` to drive the same behaviour. See the [handshake page](/protocol/handshake/#multiple-gateways-on-one-machine) for the full picture.\n\n## Where the plugin bundles it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo bundles the gateway as `plugin/server/index.cjs`, built via:\n\n```bash\npnpm --filter @tesseron/mcp build:plugin\n```\n\nThis esbuild bundle is what ships to plugin installers. If you're hacking on the gateway, rebuild the plugin bundle before testing against Claude Code.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `packages/mcp/src/cli.ts` - entry point.\n- `packages/mcp/src/gateway.ts` - session management, dialer dispatcher, instances-directory watcher.\n- `packages/mcp/src/dialer.ts` - per-binding dialers (`WsDialer`, `UdsDialer`).\n- `packages/mcp/src/session.ts` - a single session's state + claim code.\n- `packages/mcp/src/mcp-bridge.ts` - MCP stdio server + protocol translation.\n\nAdding a new method (e.g., a custom `tesseron__debug_dump` tool) means editing `mcp-bridge.ts` for the MCP side and routing through `gateway.ts` if it also crosses the SDK channel. Keep new methods under a `tesseron__` prefix to avoid colliding with app action tools.\n\nAdding a new **transport binding**: implement `GatewayDialer` for the new `kind`, register it in the gateway constructor, ship a host transport on the SDK side, document the wire format under `/protocol/transport-bindings/`. See [Port Tesseron to your language](/sdk/porting/) for the full rubric.\n\n## Not for production agents\n\nThis is a local developer tool. Apps bind locally only; the gateway only dials local endpoints. If you need remote-agent support, build a reverse-tunnel with explicit authentication in front."},{"slug":"sdk/typescript/react","title":"@tesseron/react","description":"Hooks for declarative action and resource registration inside React components.","section":"sdk","related":["sdk/typescript/web","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/react` wraps `@tesseron/web` in three hooks. Registration becomes a declarative part of your component tree; unmount tears down cleanly.\n\nNo `<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.\n\n## Exports\n\n```ts\nimport {\n useTesseronAction,\n useTesseronResource,\n useTesseronConnection,\n // Option types\n UseTesseronActionOptions,\n UseTesseronResourceOptions,\n UseTesseronConnectionOptions,\n // State\n TesseronConnectionState,\n} from '@tesseron/react';\n```\n\nThe full `@tesseron/web` surface is re-exported too.\n\n## `useTesseronConnection`\n\nManages the WebSocket for the component's lifetime.\n\n```tsx\nfunction App() {\n const { status, claimCode, welcome, error } = useTesseronConnection();\n\n if (status === 'connecting') return <p>Connecting to Tesseron…</p>;\n if (status === 'error') return <p>Gateway unavailable: {error?.message}</p>;\n if (status === 'open') return <ClaimBanner code={claimCode!} />;\n return null;\n}\n```\n\nState shape:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n resumeStatus?: 'none' | 'resumed' | 'failed';\n}\n```\n\n`resumeStatus` is set when `status === 'open'`:\n\n- `'none'` - no resume was attempted (no stored creds, or `resume` disabled).\n- `'resumed'` - `tesseron/resume` succeeded; the prior session was reattached.\n- `'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.\n\n`claimCode` clears automatically once the session has been claimed by an agent. The gateway sends a `tesseron/claimed` notification (see [protocol/handshake](/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.\n\nOptions:\n\n```ts\ninterface UseTesseronConnectionOptions {\n url?: string; // defaults to `<location.origin>/@tesseron/ws` (served by @tesseron/vite)\n enabled?: boolean; // gate the connect, e.g. only when logged in\n resume?: boolean | string | ResumeStorage;\n}\n```\n\nOnly one component should call `useTesseronConnection` per client - it owns the WebSocket. Most apps put it at the root.\n\n### Surviving page refresh / HMR with `resume`\n\nBy default, every page load of an app that uses `useTesseronConnection` starts a brand-new session, which means a brand-new claim code on every refresh. For most local-dev React apps that's exactly the wrong default - flip `resume: true` and the hook persists the `sessionId` / `resumeToken` from each handshake in `localStorage`, then sends `tesseron/resume` instead of `tesseron/hello` on the next page load. The agent stays paired across refreshes, HMR reloads, and brief network blips:\n\n```tsx\nconst conn = useTesseronConnection({ resume: true });\n```\n\nThe hook handles the backing protocol details for you - token rotation, the [`ResumeFailed`](/protocol/resume/) fallback to a fresh hello when the gateway zombie has expired, 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](/protocol/resume/) for the underlying primitives.\n\nThe `resume` option accepts three forms:\n\n| Form | Behaviour |\n|---|---|\n| `true` | Persist in `localStorage` under `'tesseron:resume'`. |\n| `string` | Persist in `localStorage` under that exact key. Use a per-app value if you mount multiple `WebTesseronClient` instances on one page. |\n| `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. |\n\n```ts\ninterface ResumeStorage {\n load: () =>\n | ResumeCredentials\n | null\n | undefined\n | Promise<ResumeCredentials | null | undefined>;\n save: (credentials: ResumeCredentials) => void | Promise<void>;\n clear: () => void | Promise<void>;\n}\n```\n\nResume 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.\n\nResume 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.\n\nStorage 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.\n\n## `useTesseronAction`\n\nRegisters a typed action for the component's lifetime.\n\n```tsx\nuseTesseronAction('addTodo', {\n description: 'Add a new todo',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: uuid(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n});\n```\n\nOptions:\n\n```ts\ninterface UseTesseronActionOptions<I, O> {\n description?: string;\n input?: StandardSchemaV1<I>;\n inputJsonSchema?: unknown;\n output?: StandardSchemaV1<O>;\n outputJsonSchema?: unknown;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput?: boolean;\n handler: (input: I, ctx: ActionContext) => O | Promise<O>;\n}\n```\n\nNotes:\n\n- The handler is held via a ref internally, so calling state setters from inside works without stale closures.\n- 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.\n- The hook returns nothing. The action is invoked by the agent, not by your component.\n\n## `useTesseronResource`\n\nRegisters a resource for the component's lifetime. Two call shapes, same result.\n\n```tsx\n// Short form - read-only resource\nuseTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n}));\n```\n\n```tsx\n// Full form - with description + subscribe\nuseTesseronResource('filterState', {\n description: 'Current todo filter',\n read: () => ({ search, onlyDone }),\n subscribe: (emit) => {\n const onChange = () => emit({ search, onlyDone });\n store.on('filter', onChange);\n return () => store.off('filter', onChange);\n },\n});\n```\n\nOptions:\n\n```ts\ninterface UseTesseronResourceOptions<T> {\n description?: string;\n output?: StandardSchemaV1<T>;\n outputJsonSchema?: unknown;\n read?: () => T | Promise<T>;\n subscribe?: (emit: (value: T) => void) => () => void;\n}\n```\n\n## Conditional registration\n\n`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:\n\n```tsx\nreturn (\n <>\n {user && <ActionsForLoggedInUsers />}\n <GlobalActions />\n </>\n);\n```\n\nDon't try to conditionally call the hooks themselves - that breaks the Rules of Hooks.\n\n## Full component example\n\nPulled from `examples/react-todo/src/app.tsx`:\n\n```tsx\nimport { useTesseronAction, useTesseronConnection, useTesseronResource } from '@tesseron/react';\nimport { z } from 'zod';\nimport { useState } from 'react';\n\ntype Todo = { id: string; text: string; done: boolean };\n\nexport function TodoApp() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: crypto.randomUUID(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronAction('toggleTodo', {\n input: z.object({ id: z.string() }),\n annotations: { destructive: true },\n handler: ({ id }) => {\n setTodos((prev) =>\n prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),\n );\n return { id };\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.status === 'open' && conn.claimCode && (\n <ClaimBanner code={conn.claimCode} />\n )}\n <TodoList todos={todos} />\n </>\n );\n}\n```\n","bodyText":"`@tesseron/react` wraps `@tesseron/web` in three hooks. Registration becomes a declarative part of your component tree; unmount tears down cleanly.\n\nNo `<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.\n\n## Exports\n\n```ts\n\n useTesseronAction,\n useTesseronResource,\n useTesseronConnection,\n // Option types\n UseTesseronActionOptions,\n UseTesseronResourceOptions,\n UseTesseronConnectionOptions,\n // State\n TesseronConnectionState,\n} from '@tesseron/react';\n```\n\nThe full `@tesseron/web` surface is re-exported too.\n\n## `useTesseronConnection`\n\nManages the WebSocket for the component's lifetime.\n\n```tsx\nfunction App() {\n const { status, claimCode, welcome, error } = useTesseronConnection();\n\n if (status === 'connecting') return <p>Connecting to Tesseron…</p>;\n if (status === 'error') return <p>Gateway unavailable: {error?.message}</p>;\n if (status === 'open') return ;\n return null;\n}\n```\n\nState shape:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n resumeStatus?: 'none' | 'resumed' | 'failed';\n}\n```\n\n`resumeStatus` is set when `status === 'open'`:\n\n- `'none'` - no resume was attempted (no stored creds, or `resume` disabled).\n- `'resumed'` - `tesseron/resume` succeeded; the prior session was reattached.\n- `'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.\n\n`claimCode` clears automatically once the session has been claimed by an agent. The gateway sends a `tesseron/claimed` notification (see [protocol/handshake](/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.\n\nOptions:\n\n```ts\ninterface UseTesseronConnectionOptions {\n url?: string; // defaults to `<location.origin>/@tesseron/ws` (served by @tesseron/vite)\n enabled?: boolean; // gate the connect, e.g. only when logged in\n resume?: boolean | string | ResumeStorage;\n}\n```\n\nOnly one component should call `useTesseronConnection` per client - it owns the WebSocket. Most apps put it at the root.\n\n### Surviving page refresh / HMR with `resume`\n\nBy default, every page load of an app that uses `useTesseronConnection` starts a brand-new session, which means a brand-new claim code on every refresh. For most local-dev React apps that's exactly the wrong default - flip `resume: true` and the hook persists the `sessionId` / `resumeToken` from each handshake in `localStorage`, then sends `tesseron/resume` instead of `tesseron/hello` on the next page load. The agent stays paired across refreshes, HMR reloads, and brief network blips:\n\n```tsx\nconst conn = useTesseronConnection({ resume: true });\n```\n\nThe hook handles the backing protocol details for you - token rotation, the [`ResumeFailed`](/protocol/resume/) fallback to a fresh hello when the gateway zombie has expired, 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](/protocol/resume/) for the underlying primitives.\n\nThe `resume` option accepts three forms:\n\n| Form | Behaviour |\n|---|---|\n| `true` | Persist in `localStorage` under `'tesseron:resume'`. |\n| `string` | Persist in `localStorage` under that exact key. Use a per-app value if you mount multiple `WebTesseronClient` instances on one page. |\n| `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. |\n\n```ts\ninterface ResumeStorage {\n load: () =>\n | ResumeCredentials\n | null\n | undefined\n | Promise<ResumeCredentials | null | undefined>;\n save: (credentials: ResumeCredentials) => void | Promise<void>;\n clear: () => void | Promise<void>;\n}\n```\n\nResume 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.\n\nResume 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.\n\nStorage 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.\n\n## `useTesseronAction`\n\nRegisters a typed action for the component's lifetime.\n\n```tsx\nuseTesseronAction('addTodo', {\n description: 'Add a new todo',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: uuid(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n});\n```\n\nOptions:\n\n```ts\ninterface UseTesseronActionOptions<I, O> {\n description?: string;\n input?: StandardSchemaV1<I>;\n inputJsonSchema?: unknown;\n output?: StandardSchemaV1<O>;\n outputJsonSchema?: unknown;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput?: boolean;\n handler: (input: I, ctx: ActionContext) => O | Promise<O>;\n}\n```\n\nNotes:\n\n- The handler is held via a ref internally, so calling state setters from inside works without stale closures.\n- 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.\n- The hook returns nothing. The action is invoked by the agent, not by your component.\n\n## `useTesseronResource`\n\nRegisters a resource for the component's lifetime. Two call shapes, same result.\n\n```tsx\n// Short form - read-only resource\nuseTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n}));\n```\n\n```tsx\n// Full form - with description + subscribe\nuseTesseronResource('filterState', {\n description: 'Current todo filter',\n read: () => ({ search, onlyDone }),\n subscribe: (emit) => {\n const onChange = () => emit({ search, onlyDone });\n store.on('filter', onChange);\n return () => store.off('filter', onChange);\n },\n});\n```\n\nOptions:\n\n```ts\ninterface UseTesseronResourceOptions<T> {\n description?: string;\n output?: StandardSchemaV1<T>;\n outputJsonSchema?: unknown;\n read?: () => T | Promise<T>;\n subscribe?: (emit: (value: T) => void) => () => void;\n}\n```\n\n## Conditional registration\n\n`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:\n\n```tsx\nreturn (\n <>\n {user && }\n \n </>\n);\n```\n\nDon't try to conditionally call the hooks themselves - that breaks the Rules of Hooks.\n\n## Full component example\n\nPulled from `examples/react-todo/src/app.tsx`:\n\n```tsx\n\ntype Todo = { id: string; text: string; done: boolean };\n\nexport function TodoApp() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: crypto.randomUUID(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronAction('toggleTodo', {\n input: z.object({ id: z.string() }),\n annotations: { destructive: true },\n handler: ({ id }) => {\n setTodos((prev) =>\n prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),\n );\n return { id };\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.status === 'open' && conn.claimCode && (\n \n )}\n \n </>\n );\n}\n```"},{"slug":"sdk/typescript/resources","title":"Resources","description":"Declaring readable and subscribable state for the agent to observe.","section":"sdk","related":["protocol/resources","sdk/typescript/core"],"bodyRaw":"\nA resource is a named piece of app state. The agent can read it on demand and, if your resource supports it, subscribe for live updates.\n\nSee the [protocol resources page](/protocol/resources/) for wire format. This page focuses on the builder API.\n\n## Builder shape\n\n```ts\ninterface ResourceBuilder<T> {\n describe(description: string): ResourceBuilder<T>;\n output<NewT>(schema: StandardSchemaV1<NewT>, jsonSchema?: unknown): ResourceBuilder<NewT>;\n read(fn: () => T | Promise<T>): ResourceBuilder<T>;\n subscribe(setup: (emit: (value: T) => void) => () => void): ResourceBuilder<T>;\n}\n```\n\nEither `.read()` or `.subscribe()` commits the resource to the client's registry - you can call both (in any order) and the registered entry is updated in place. The agent sees the resource as subscribable as soon as `.subscribe()` is called.\n\n## Read-only resource\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is viewing')\n .read(() => window.location.pathname);\n```\n\nThe agent calls `resources/read tesseron://<app_id>/currentRoute` whenever it wants the value. `.read()` runs on each request.\n\n## Subscribable resource\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `setup` runs once, at subscription time.\n- Call `emit(value)` whenever the value changes.\n- Return an unsubscribe function; the SDK calls it on `resources/unsubscribe` or when the session closes.\n- `.subscribe()` does not terminate the builder - it returns `ResourceBuilder<T>` so you can keep chaining if you want.\n\n## Patterns\n\n### Debounce emissions\n\nThe agent can't usefully consume 60 emissions per second. Debounce:\n\n```ts\n.subscribe((emit) => {\n let t: ReturnType<typeof setTimeout> | null = null;\n const push = () => {\n if (t) clearTimeout(t);\n t = setTimeout(() => emit(stateSnapshot()), 200);\n };\n store.on('change', push);\n return () => { if (t) clearTimeout(t); store.off('change', push); };\n});\n```\n\n### Memoise the read\n\nIf `.read()` is expensive and you also have `.subscribe()`, hold the latest emitted value and serve `.read()` from it:\n\n```ts\nlet latest = initialValue();\n\ntesseron.resource('filterState')\n .read(() => latest)\n .subscribe((emit) => {\n const onChange = () => { latest = compute(); emit(latest); };\n store.on('change', onChange);\n return () => store.off('change', onChange);\n });\n```\n\n### Typed schema\n\nSchemas on resources feed into the MCP descriptor, same as actions:\n\n```ts\n.output(z.object({ search: z.string(), onlyDone: z.boolean() }))\n.read(() => ({ search: state.search, onlyDone: state.onlyDone }))\n```\n\nReads are not schema-validated at runtime by default - the schema is documentation. If you need enforcement, do it yourself inside `.read()` and `.subscribe()` emit.\n\n## What to expose (and what not to)\n\nGood resources:\n\n- User's current route, selected item, filter state.\n- \"What's on screen right now\" - the agent uses these to reason before acting.\n- Counts and summaries - `todoStats`, `unreadCount`.\n- Document content the agent is editing.\n\nBad resources:\n\n- Credentials, session tokens, PII the user hasn't consented to share.\n- Full database dumps - reads happen on demand and can be expensive.\n- High-frequency streams (mouse position, scroll offset) - debounce or expose a summary instead.\n\n## React adapter\n\n`@tesseron/react` wraps the same builder as a hook:\n\n```tsx\nimport { useTesseronResource } from '@tesseron/react';\n\nuseTesseronResource('currentRoute', () => window.location.pathname);\n// or with options:\nuseTesseronResource('currentRoute', {\n description: 'Route',\n read: () => window.location.pathname,\n subscribe: (emit) => { /* … */ return () => {}; },\n});\n```\n\nSee [the react adapter page](/sdk/typescript/react/) for full hook docs.\n","bodyText":"A resource is a named piece of app state. The agent can read it on demand and, if your resource supports it, subscribe for live updates.\n\nSee the [protocol resources page](/protocol/resources/) for wire format. This page focuses on the builder API.\n\n## Builder shape\n\n```ts\ninterface ResourceBuilder<T> {\n describe(description: string): ResourceBuilder<T>;\n output<NewT>(schema: StandardSchemaV1<NewT>, jsonSchema?: unknown): ResourceBuilder<NewT>;\n read(fn: () => T | Promise<T>): ResourceBuilder<T>;\n subscribe(setup: (emit: (value: T) => void) => () => void): ResourceBuilder<T>;\n}\n```\n\nEither `.read()` or `.subscribe()` commits the resource to the client's registry - you can call both (in any order) and the registered entry is updated in place. The agent sees the resource as subscribable as soon as `.subscribe()` is called.\n\n## Read-only resource\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is viewing')\n .read(() => window.location.pathname);\n```\n\nThe agent calls `resources/read tesseron://<app_id>/currentRoute` whenever it wants the value. `.read()` runs on each request.\n\n## Subscribable resource\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `setup` runs once, at subscription time.\n- Call `emit(value)` whenever the value changes.\n- Return an unsubscribe function; the SDK calls it on `resources/unsubscribe` or when the session closes.\n- `.subscribe()` does not terminate the builder - it returns `ResourceBuilder<T>` so you can keep chaining if you want.\n\n## Patterns\n\n### Debounce emissions\n\nThe agent can't usefully consume 60 emissions per second. Debounce:\n\n```ts\n.subscribe((emit) => {\n let t: ReturnType<typeof setTimeout> | null = null;\n const push = () => {\n if (t) clearTimeout(t);\n t = setTimeout(() => emit(stateSnapshot()), 200);\n };\n store.on('change', push);\n return () => { if (t) clearTimeout(t); store.off('change', push); };\n});\n```\n\n### Memoise the read\n\nIf `.read()` is expensive and you also have `.subscribe()`, hold the latest emitted value and serve `.read()` from it:\n\n```ts\nlet latest = initialValue();\n\ntesseron.resource('filterState')\n .read(() => latest)\n .subscribe((emit) => {\n const onChange = () => { latest = compute(); emit(latest); };\n store.on('change', onChange);\n return () => store.off('change', onChange);\n });\n```\n\n### Typed schema\n\nSchemas on resources feed into the MCP descriptor, same as actions:\n\n```ts\n.output(z.object({ search: z.string(), onlyDone: z.boolean() }))\n.read(() => ({ search: state.search, onlyDone: state.onlyDone }))\n```\n\nReads are not schema-validated at runtime by default - the schema is documentation. If you need enforcement, do it yourself inside `.read()` and `.subscribe()` emit.\n\n## What to expose (and what not to)\n\nGood resources:\n\n- User's current route, selected item, filter state.\n- \"What's on screen right now\" - the agent uses these to reason before acting.\n- Counts and summaries - `todoStats`, `unreadCount`.\n- Document content the agent is editing.\n\nBad resources:\n\n- Credentials, session tokens, PII the user hasn't consented to share.\n- Full database dumps - reads happen on demand and can be expensive.\n- High-frequency streams (mouse position, scroll offset) - debounce or expose a summary instead.\n\n## React adapter\n\n`@tesseron/react` wraps the same builder as a hook:\n\n```tsx\n\nuseTesseronResource('currentRoute', () => window.location.pathname);\n// or with options:\nuseTesseronResource('currentRoute', {\n description: 'Route',\n read: () => window.location.pathname,\n subscribe: (emit) => { /* … */ return () => {}; },\n});\n```\n\nSee [the react adapter page](/sdk/typescript/react/) for full hook docs."},{"slug":"sdk/typescript/server","title":"@tesseron/server","description":"The Node SDK. Hosts a loopback WebSocket or Unix domain socket, announces itself via ~/.tesseron/instances/, and waits for the gateway to dial in.","section":"sdk","related":["sdk/typescript/core","protocol/transport","protocol/transport-bindings/ws","protocol/transport-bindings/uds","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/server` is what you use in a Node process - an Express server, a NestJS app, a CLI tool, a background worker, an Electron main process. The builder API is identical to `@tesseron/web`; the transport is what's different.\n\n## How it connects\n\nUnlike the browser SDK, Node can host its own listener. `@tesseron/server` ships two transport bindings and picks one based on `connect()` options:\n\n- **WebSocket on loopback** (default). Binds `127.0.0.1` on an OS-picked port.\n- **Unix domain socket**, opt-in via `tesseron.connect({ transport: 'uds' })`. Linux + macOS only; falls back to WS on Windows.\n\nEither way the connection flow is the same:\n\n1. On `tesseron.connect()` the SDK creates the host endpoint.\n2. Writes `~/.tesseron/instances/<instanceId>.json` with a `{ kind, url | path }` spec.\n3. Waits for the gateway to dial in.\n4. On the first and only accepted connection, sends `tesseron/hello` and runs the normal Tesseron handshake.\n\nNo environment variables, no fixed ports, no client URL. The instance manifest does everything.\n\n## When to use server vs web\n\n| Use server when | Use web when |\n|---|---|\n| The handler's work lives on the backend (DB writes, queue jobs, filesystem). | The handler's work needs DOM or browser APIs. |\n| You don't need the user's tab to be open. | The agent should only work while the user is viewing the page. |\n| You want a headless service that Claude can drive. | You want Claude to drive the UI the user is already looking at. |\n\nBoth can run at the same time against the same MCP gateway - [multi-app coexistence](/protocol/security/#multi-app-coexistence) is first-class.\n\n## Exports\n\n```ts\nimport {\n // Singleton client - pre-constructed, use directly.\n tesseron,\n // Class (if you need multiple clients per process).\n ServerTesseronClient,\n // WS-binding transport — WS server + manifest writer.\n NodeWebSocketServerTransport,\n type NodeWebSocketServerTransportOptions,\n // UDS-binding transport — net server + manifest writer.\n UnixSocketServerTransport,\n type UnixSocketServerTransportOptions,\n} from '@tesseron/server';\n```\n\n## Typical process layout\n\n```ts\nimport { tesseron } from '@tesseron/server';\nimport { z } from 'zod';\n\ntesseron.app({\n id: 'notes_api',\n name: 'Notes API',\n description: 'CRUD over the notes store',\n});\n\ntesseron\n .action('createNote')\n .input(z.object({ title: z.string(), body: z.string() }))\n .handler(async ({ title, body }) => {\n return db.notes.insert({ title, body });\n });\n\ntesseron.resource('noteCount').read(() => db.notes.count());\n\nasync function main() {\n const welcome = await tesseron.connect();\n console.log(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n}\n\nmain().catch((err) => {\n console.error(err);\n process.exit(1);\n});\n\nasync function shutdown() {\n await tesseron.disconnect();\n process.exit(0);\n}\nprocess.on('SIGINT', shutdown);\nprocess.on('SIGTERM', shutdown);\n```\n\n## Customising the bind\n\n### WebSocket binding (default)\n\n```ts\nawait tesseron.connect({ appName: 'notes_api', host: '127.0.0.1', port: 0 });\n```\n\n- `appName` - stamped into the instance manifest so the gateway log names your app usefully. Defaults to `'node'`.\n- `host` - always `127.0.0.1` in practice; exposed for tests that need `::1`.\n- `port` - `0` (OS picks) is almost always what you want. Setting a fixed port only matters if you're reverse-tunnelling the transport.\n\n### UDS binding\n\n```ts\nawait tesseron.connect({ transport: 'uds', appName: 'notes_api' });\n// or, override the socket path:\nawait tesseron.connect({ transport: 'uds', path: '/tmp/notes.sock' });\n```\n\n- `appName` - same as WS.\n- `path` - omit to let the SDK create a per-process 0700 temp dir under `os.tmpdir()` and bind `<dir>/sock` inside (recommended; the parent dir is the access gate). Pin a path only if you need to coordinate with another process that expects it.\n\nPass a `Transport` instead to bypass bind-and-announce entirely - useful in tests or when you're piping frames through some other channel.\n\n## Express example\n\nThe [`express-prompts` example](/examples/express-prompts/) shows the canonical \"HTTP + Tesseron on one Node process\" pattern. Keep the shared state outside both entry points; each channel calls the same functions:\n\n```ts\nconst prompts = new Map<string, Prompt>();\n\n// REST surface\napp.post('/prompts', (req, res) => {\n const p = createPrompt(prompts, req.body);\n res.status(201).json(p);\n});\n\n// Tesseron surface - same underlying function\ntesseron.action('addPrompt')\n .input(z.object({ name: z.string(), template: z.string() }))\n .handler((input) => createPrompt(prompts, input));\n```\n\n## Transport details\n\n### `NodeWebSocketServerTransport` (WS binding)\n\nWraps the [`ws`](https://github.com/websockets/ws) npm package (v8). It:\n\n- Binds a WebSocket server via Node's built-in `http.createServer`.\n- Accepts exactly one upgrade request that advertises the `tesseron-gateway` subprotocol; every other upgrade attempt is destroyed.\n- Tolerates every frame shape `ws` hands back - `string`, `Buffer`, `Buffer[]`, `ArrayBuffer` - and coerces to UTF-8 before parsing.\n- Writes its instance manifest on `listen()` and deletes it on `close()`.\n\nSee the [WebSocket binding spec](/protocol/transport-bindings/ws/) for the wire-level rules.\n\n### `UnixSocketServerTransport` (UDS binding)\n\nWraps Node's `net` module. It:\n\n- Creates a private (mode `0700`) directory under `os.tmpdir()` and binds a socket inside it (or uses the path you supplied).\n- `chmod 0600`s the socket file after bind, so the inode rejects connect attempts from other UIDs.\n- Accepts exactly one connection; rejects subsequent connect attempts.\n- Frames messages as NDJSON: `JSON.stringify(msg) + '\\n'` per outbound, `\\n`-split on inbound.\n- Writes its instance manifest on bind and deletes it (plus the temp dir) on `close()`.\n\nSee the [UDS binding spec](/protocol/transport-bindings/uds/) for the wire-level rules and the Windows limitation.\n\n## Running under Docker / systemd\n\nTwo things to get right:\n\n1. **Same HOME dir as the gateway.** The gateway reads `~/.tesseron/instances/`; your Node process has to write there. In containers, mount `~/.tesseron` into the container's `$HOME`.\n2. **Signal handling.** `process.on('SIGTERM', …)` to call `tesseron.disconnect()` before exit cleans up the manifest and gives the gateway a clean close (code 1001 on WS, normal `'close'` on UDS) so the agent doesn't see abrupt tool failures.\n\nClaim codes surface on stdout/stderr of your Node process, not the gateway's. Plan how you expose them to humans - a web UI endpoint, a file you rotate, whatever fits.\n\n## Capabilities\n\nServer handlers get the same `ActionContext` as browser handlers. Two differences to know:\n\n- `ctx.client.origin` - fabricated. Typically the string `\"node:<app.id>\"` or similar. Don't use it for auth.\n- `ctx.client.route` - always `undefined`. There's no \"current route\" on the server.\n\nEverything else - `progress`, `sample`, `elicit`, `log`, `signal` - behaves the same.\n","bodyText":"`@tesseron/server` is what you use in a Node process - an Express server, a NestJS app, a CLI tool, a background worker, an Electron main process. The builder API is identical to `@tesseron/web`; the transport is what's different.\n\n## How it connects\n\nUnlike the browser SDK, Node can host its own listener. `@tesseron/server` ships two transport bindings and picks one based on `connect()` options:\n\n- **WebSocket on loopback** (default). Binds `127.0.0.1` on an OS-picked port.\n- **Unix domain socket**, opt-in via `tesseron.connect({ transport: 'uds' })`. Linux + macOS only; falls back to WS on Windows.\n\nEither way the connection flow is the same:\n\n1. On `tesseron.connect()` the SDK creates the host endpoint.\n2. Writes `~/.tesseron/instances/<instanceId>.json` with a `{ kind, url | path }` spec.\n3. Waits for the gateway to dial in.\n4. On the first and only accepted connection, sends `tesseron/hello` and runs the normal Tesseron handshake.\n\nNo environment variables, no fixed ports, no client URL. The instance manifest does everything.\n\n## When to use server vs web\n\n| Use server when | Use web when |\n|---|---|\n| The handler's work lives on the backend (DB writes, queue jobs, filesystem). | The handler's work needs DOM or browser APIs. |\n| You don't need the user's tab to be open. | The agent should only work while the user is viewing the page. |\n| You want a headless service that Claude can drive. | You want Claude to drive the UI the user is already looking at. |\n\nBoth can run at the same time against the same MCP gateway - [multi-app coexistence](/protocol/security/#multi-app-coexistence) is first-class.\n\n## Exports\n\n```ts\n\n // Singleton client - pre-constructed, use directly.\n tesseron,\n // Class (if you need multiple clients per process).\n ServerTesseronClient,\n // WS-binding transport — WS server + manifest writer.\n NodeWebSocketServerTransport,\n type NodeWebSocketServerTransportOptions,\n // UDS-binding transport — net server + manifest writer.\n UnixSocketServerTransport,\n type UnixSocketServerTransportOptions,\n} from '@tesseron/server';\n```\n\n## Typical process layout\n\n```ts\n\ntesseron.app({\n id: 'notes_api',\n name: 'Notes API',\n description: 'CRUD over the notes store',\n});\n\ntesseron\n .action('createNote')\n .input(z.object({ title: z.string(), body: z.string() }))\n .handler(async ({ title, body }) => {\n return db.notes.insert({ title, body });\n });\n\ntesseron.resource('noteCount').read(() => db.notes.count());\n\nasync function main() {\n const welcome = await tesseron.connect();\n console.log(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n}\n\nmain().catch((err) => {\n console.error(err);\n process.exit(1);\n});\n\nasync function shutdown() {\n await tesseron.disconnect();\n process.exit(0);\n}\nprocess.on('SIGINT', shutdown);\nprocess.on('SIGTERM', shutdown);\n```\n\n## Customising the bind\n\n### WebSocket binding (default)\n\n```ts\nawait tesseron.connect({ appName: 'notes_api', host: '127.0.0.1', port: 0 });\n```\n\n- `appName` - stamped into the instance manifest so the gateway log names your app usefully. Defaults to `'node'`.\n- `host` - always `127.0.0.1` in practice; exposed for tests that need `::1`.\n- `port` - `0` (OS picks) is almost always what you want. Setting a fixed port only matters if you're reverse-tunnelling the transport.\n\n### UDS binding\n\n```ts\nawait tesseron.connect({ transport: 'uds', appName: 'notes_api' });\n// or, override the socket path:\nawait tesseron.connect({ transport: 'uds', path: '/tmp/notes.sock' });\n```\n\n- `appName` - same as WS.\n- `path` - omit to let the SDK create a per-process 0700 temp dir under `os.tmpdir()` and bind `<dir>/sock` inside (recommended; the parent dir is the access gate). Pin a path only if you need to coordinate with another process that expects it.\n\nPass a `Transport` instead to bypass bind-and-announce entirely - useful in tests or when you're piping frames through some other channel.\n\n## Express example\n\nThe [`express-prompts` example](/examples/express-prompts/) shows the canonical \"HTTP + Tesseron on one Node process\" pattern. Keep the shared state outside both entry points; each channel calls the same functions:\n\n```ts\nconst prompts = new Map<string, Prompt>();\n\n// REST surface\napp.post('/prompts', (req, res) => {\n const p = createPrompt(prompts, req.body);\n res.status(201).json(p);\n});\n\n// Tesseron surface - same underlying function\ntesseron.action('addPrompt')\n .input(z.object({ name: z.string(), template: z.string() }))\n .handler((input) => createPrompt(prompts, input));\n```\n\n## Transport details\n\n### `NodeWebSocketServerTransport` (WS binding)\n\nWraps the [`ws`](https://github.com/websockets/ws) npm package (v8). It:\n\n- Binds a WebSocket server via Node's built-in `http.createServer`.\n- Accepts exactly one upgrade request that advertises the `tesseron-gateway` subprotocol; every other upgrade attempt is destroyed.\n- Tolerates every frame shape `ws` hands back - `string`, `Buffer`, `Buffer[]`, `ArrayBuffer` - and coerces to UTF-8 before parsing.\n- Writes its instance manifest on `listen()` and deletes it on `close()`.\n\nSee the [WebSocket binding spec](/protocol/transport-bindings/ws/) for the wire-level rules.\n\n### `UnixSocketServerTransport` (UDS binding)\n\nWraps Node's `net` module. It:\n\n- Creates a private (mode `0700`) directory under `os.tmpdir()` and binds a socket inside it (or uses the path you supplied).\n- `chmod 0600`s the socket file after bind, so the inode rejects connect attempts from other UIDs.\n- Accepts exactly one connection; rejects subsequent connect attempts.\n- Frames messages as NDJSON: `JSON.stringify(msg) + '\\n'` per outbound, `\\n`-split on inbound.\n- Writes its instance manifest on bind and deletes it (plus the temp dir) on `close()`.\n\nSee the [UDS binding spec](/protocol/transport-bindings/uds/) for the wire-level rules and the Windows limitation.\n\n## Running under Docker / systemd\n\nTwo things to get right:\n\n1. **Same HOME dir as the gateway.** The gateway reads `~/.tesseron/instances/`; your Node process has to write there. In containers, mount `~/.tesseron` into the container's `$HOME`.\n2. **Signal handling.** `process.on('SIGTERM', …)` to call `tesseron.disconnect()` before exit cleans up the manifest and gives the gateway a clean close (code 1001 on WS, normal `'close'` on UDS) so the agent doesn't see abrupt tool failures.\n\nClaim codes surface on stdout/stderr of your Node process, not the gateway's. Plan how you expose them to humans - a web UI endpoint, a file you rotate, whatever fits.\n\n## Capabilities\n\nServer handlers get the same `ActionContext` as browser handlers. Two differences to know:\n\n- `ctx.client.origin` - fabricated. Typically the string `\"node:<app.id>\"` or similar. Don't use it for auth.\n- `ctx.client.route` - always `undefined`. There's no \"current route\" on the server.\n\nEverything else - `progress`, `sample`, `elicit`, `log`, `signal` - behaves the same."},{"slug":"sdk/typescript/standard-schema","title":"Standard Schema (Zod, Valibot, …)","description":"Any Standard Schema v1 validator works. What that means, which libraries are supported, and how to handle JSON Schema export.","section":"sdk","related":["sdk/typescript/action-builder"],"bodyRaw":"\nTesseron's action builder accepts any validator that implements [Standard Schema v1](https://standardschema.dev). That's a small contract that most modern TypeScript validation libraries already expose:\n\n```ts\ninterface StandardSchemaV1<T> {\n readonly '~standard': {\n version: 1;\n vendor: string;\n validate(value: unknown): { value: T } | { issues: Issue[] } | Promise<…>;\n };\n}\n```\n\nBecause the contract is minimal, the SDK doesn't care which library you use. Pick whichever is already in your project - or whichever feels best for writing schemas for agents.\n\n## Supported libraries\n\nAll of these implement Standard Schema v1 and work with Tesseron:\n\n| Library | Notes |\n|---|---|\n| [Zod](https://zod.dev) | De-facto default. The smoothest DX, broadest ecosystem, native `toJSONSchema`. |\n| [Valibot](https://valibot.dev) | Tree-shakable, smaller bundle, functional style. |\n| [ArkType](https://arktype.io) | TypeScript-first; schemas read like runtime type expressions. |\n| [Effect Schema](https://effect.website) | Part of the Effect ecosystem; best if you already use Effect. |\n| [TypeBox](https://github.com/sinclairzx81/typebox) | JSON-Schema-first; schemas *are* JSON Schema. |\n\nIf your library isn't on the list, check its docs for \"Standard Schema\" - most have it or are adding it.\n\n## Input validation\n\nWhichever library you use, the behaviour is the same:\n\n```ts\n.input(validator)\n```\n\n- Before the handler runs, the SDK calls `validator['~standard'].validate(input)`.\n- On `{ issues }` → the invocation fails with `-32004 InputValidation`; `issues` ride in `error.data`.\n- On `{ value }` → the parsed `value` is passed to your handler, typed as `I`.\n\n## Output validation\n\nDefault (informational):\n\n```ts\n.output(validator)\n```\n\nThe SDK does not validate - it uses the schema for JSON Schema export and nothing else.\n\nStrict:\n\n```ts\n.output(validator).strictOutput()\n```\n\nThe SDK validates the handler's return value the same way it validates input. Failures raise `-32005 HandlerError`.\n\n## JSON Schema export\n\nThe wire protocol transports each action's input and output as JSON Schema (for the MCP tool descriptor). The agent reads it to know which fields exist, what types they expect, and how to format invocations. A typeless permissive schema means the LLM has to guess - including, sometimes, JSON-encoding numbers as strings.\n\nThere are three paths the SDK checks, in order:\n\n### 1. Auto-derive from the validator\n\nIf your schema's vendor exposes a JSON Schema converter on the schema object, the SDK calls it automatically. No extra work in your action code.\n\n| Validator | Auto-derive | How |\n|---|---|---|\n| **Zod 4+** | ✅ | calls `schema.toJSONSchema()` (instance method) |\n| **TypeBox** | ✅ | the schema object IS the JSON Schema; the SDK strips the Standard Schema metadata and passes it through |\n| **ArkType** | ✅ | calls `schema.toJsonSchema()` (instance method) |\n| Zod 3 | ❌ | no native exporter; pass JSON Schema explicitly (see below), or use `zod-to-json-schema` |\n| Valibot | ❌ | install `@valibot/to-json-schema` and pass the result explicitly |\n| Effect Schema | ❌ | call `JSONSchema.make(schema)` from `@effect/schema/JSONSchema` and pass the result explicitly |\n\nIf auto-derivation throws (e.g. the validator hits an unsupported feature), the SDK silently falls back - no exception escapes into your action wiring.\n\n### 2. Pass it manually\n\nAlways wins over auto-derivation. Pass the JSON Schema as the second argument:\n\n```ts\n.input(\n myValidator,\n {\n type: 'object',\n properties: { query: { type: 'string' }, limit: { type: 'integer', default: 10 } },\n required: ['query'],\n },\n)\n```\n\nFor Valibot, Effect Schema, or Zod 3, this is the path you'll typically take. Run your validator's converter once and pass the result.\n\n### 3. Fallback\n\nIf both paths above produce nothing, the SDK sends `{ type: 'object', additionalProperties: true }` - permissive, unhelpful to the agent, but the call still works. Avoid this where you can: agents on a permissive schema sometimes JSON-encode numbers as strings (because they have no type signal), and the call then fails Zod runtime validation.\n\n## Zod idioms that help the agent\n\n- Use `.describe()` on fields. The text shows up in the generated JSON Schema as `description`, which the agent reads when deciding what to pass.\n- Prefer `z.enum(['a', 'b'])` over `z.string()` when there's a finite set - gives the agent the choices up front.\n- Provide defaults for optional-looking fields: `z.number().int().default(10)`.\n- Avoid deeply nested structures. Flatten where possible.\n\n```ts\n.input(z.object({\n query: z.string().describe('Full-text search query; empty string matches all.'),\n limit: z.number().int().min(1).max(100).default(20).describe('Max results to return.'),\n sort: z.enum(['relevance', 'date', 'price']).default('relevance'),\n}))\n```\n\n## Mixing validators\n\nYou can use different validators across actions in the same app. Use Zod for one, Valibot for another - the SDK doesn't care. Consistency inside a project is mostly a tooling preference, not a correctness requirement.\n","bodyText":"Tesseron's action builder accepts any validator that implements [Standard Schema v1](https://standardschema.dev). That's a small contract that most modern TypeScript validation libraries already expose:\n\n```ts\ninterface StandardSchemaV1<T> {\n readonly '~standard': {\n version: 1;\n vendor: string;\n validate(value: unknown): { value: T } | { issues: Issue[] } | Promise<…>;\n };\n}\n```\n\nBecause the contract is minimal, the SDK doesn't care which library you use. Pick whichever is already in your project - or whichever feels best for writing schemas for agents.\n\n## Supported libraries\n\nAll of these implement Standard Schema v1 and work with Tesseron:\n\n| Library | Notes |\n|---|---|\n| [Zod](https://zod.dev) | De-facto default. The smoothest DX, broadest ecosystem, native `toJSONSchema`. |\n| [Valibot](https://valibot.dev) | Tree-shakable, smaller bundle, functional style. |\n| [ArkType](https://arktype.io) | TypeScript-first; schemas read like runtime type expressions. |\n| [Effect Schema](https://effect.website) | Part of the Effect ecosystem; best if you already use Effect. |\n| [TypeBox](https://github.com/sinclairzx81/typebox) | JSON-Schema-first; schemas *are* JSON Schema. |\n\nIf your library isn't on the list, check its docs for \"Standard Schema\" - most have it or are adding it.\n\n## Input validation\n\nWhichever library you use, the behaviour is the same:\n\n```ts\n.input(validator)\n```\n\n- Before the handler runs, the SDK calls `validator['~standard'].validate(input)`.\n- On `{ issues }` → the invocation fails with `-32004 InputValidation`; `issues` ride in `error.data`.\n- On `{ value }` → the parsed `value` is passed to your handler, typed as `I`.\n\n## Output validation\n\nDefault (informational):\n\n```ts\n.output(validator)\n```\n\nThe SDK does not validate - it uses the schema for JSON Schema export and nothing else.\n\nStrict:\n\n```ts\n.output(validator).strictOutput()\n```\n\nThe SDK validates the handler's return value the same way it validates input. Failures raise `-32005 HandlerError`.\n\n## JSON Schema export\n\nThe wire protocol transports each action's input and output as JSON Schema (for the MCP tool descriptor). The agent reads it to know which fields exist, what types they expect, and how to format invocations. A typeless permissive schema means the LLM has to guess - including, sometimes, JSON-encoding numbers as strings.\n\nThere are three paths the SDK checks, in order:\n\n### 1. Auto-derive from the validator\n\nIf your schema's vendor exposes a JSON Schema converter on the schema object, the SDK calls it automatically. No extra work in your action code.\n\n| Validator | Auto-derive | How |\n|---|---|---|\n| **Zod 4+** | ✅ | calls `schema.toJSONSchema()` (instance method) |\n| **TypeBox** | ✅ | the schema object IS the JSON Schema; the SDK strips the Standard Schema metadata and passes it through |\n| **ArkType** | ✅ | calls `schema.toJsonSchema()` (instance method) |\n| Zod 3 | ❌ | no native exporter; pass JSON Schema explicitly (see below), or use `zod-to-json-schema` |\n| Valibot | ❌ | install `@valibot/to-json-schema` and pass the result explicitly |\n| Effect Schema | ❌ | call `JSONSchema.make(schema)` from `@effect/schema/JSONSchema` and pass the result explicitly |\n\nIf auto-derivation throws (e.g. the validator hits an unsupported feature), the SDK silently falls back - no exception escapes into your action wiring.\n\n### 2. Pass it manually\n\nAlways wins over auto-derivation. Pass the JSON Schema as the second argument:\n\n```ts\n.input(\n myValidator,\n {\n type: 'object',\n properties: { query: { type: 'string' }, limit: { type: 'integer', default: 10 } },\n required: ['query'],\n },\n)\n```\n\nFor Valibot, Effect Schema, or Zod 3, this is the path you'll typically take. Run your validator's converter once and pass the result.\n\n### 3. Fallback\n\nIf both paths above produce nothing, the SDK sends `{ type: 'object', additionalProperties: true }` - permissive, unhelpful to the agent, but the call still works. Avoid this where you can: agents on a permissive schema sometimes JSON-encode numbers as strings (because they have no type signal), and the call then fails Zod runtime validation.\n\n## Zod idioms that help the agent\n\n- Use `.describe()` on fields. The text shows up in the generated JSON Schema as `description`, which the agent reads when deciding what to pass.\n- Prefer `z.enum(['a', 'b'])` over `z.string()` when there's a finite set - gives the agent the choices up front.\n- Provide defaults for optional-looking fields: `z.number().int().default(10)`.\n- Avoid deeply nested structures. Flatten where possible.\n\n```ts\n.input(z.object({\n query: z.string().describe('Full-text search query; empty string matches all.'),\n limit: z.number().int().min(1).max(100).default(20).describe('Max results to return.'),\n sort: z.enum(['relevance', 'date', 'price']).default('relevance'),\n}))\n```\n\n## Mixing validators\n\nYou can use different validators across actions in the same app. Use Zod for one, Valibot for another - the SDK doesn't care. Consistency inside a project is mostly a tooling preference, not a correctness requirement."},{"slug":"sdk/typescript/svelte","title":"@tesseron/svelte","description":"Svelte adapter. Lifecycle-scoped action and resource registration, reactive connection store.","section":"sdk","related":["sdk/typescript/web","sdk/typescript/vite","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/svelte` wraps `@tesseron/web` with Svelte lifecycle plumbing: actions and resources register on component mount, deregister on destroy; the connection status is a `Readable` store you subscribe to with `$connection` in templates.\n\nWorks with Svelte 4 and Svelte 5. Uses `onMount` / `onDestroy` / `writable` - no rune syntax, so the package ships as normal JS and doesn't need the Svelte compiler to build.\n\n## Install\n\n```bash\npnpm add @tesseron/svelte zod\npnpm add -D @tesseron/vite\n```\n\nThen register the [Vite plugin](/sdk/typescript/vite/) in your `vite.config.ts`.\n\n## API\n\nThree exports. The full `@tesseron/web` surface is re-exported too.\n\n```ts\nimport {\n tesseronAction,\n tesseronResource,\n tesseronConnection,\n} from '@tesseron/svelte';\n```\n\n### `tesseronAction(name, options)`\n\nRegisters an action for the lifetime of the component. Same shape as the builder API, passed as an object:\n\n```svelte\n<script lang=\"ts\">\n import { tesseronAction } from '@tesseron/svelte';\n import { z } from 'zod';\n\n let todos = $state<string[]>([]);\n\n tesseronAction('addTodo', {\n input: z.object({ text: z.string() }),\n handler: ({ text }) => {\n todos = [...todos, text];\n },\n });\n</script>\n```\n\nOptions: `description`, `input`, `inputJsonSchema`, `output`, `outputJsonSchema`, `annotations`, `timeoutMs`, `strictOutput`, `handler`. The handler closes over `$state` / `$derived` variables and reads the current value at invocation time - no `$bindable` required.\n\n### `tesseronResource(name, optionsOrReader)`\n\nRegisters a resource. Pass a reader function for the shorthand, or an options object if you also want `subscribe`, `description`, or an output schema:\n\n```svelte\n<script lang=\"ts\">\n import { tesseronResource } from '@tesseron/svelte';\n\n let todos = $state<Todo[]>([]);\n\n // Read-only\n tesseronResource('todoCount', () => todos.length);\n\n // Read + subscribe\n const subs = new Set<(n: number) => void>();\n $effect(() => { const n = todos.length; subs.forEach(fn => fn(n)); });\n\n tesseronResource('todoCount', {\n read: () => todos.length,\n subscribe: (emit) => { subs.add(emit); return () => subs.delete(emit); },\n });\n</script>\n```\n\n### `tesseronConnection(options?)`\n\nOpens the connection on mount and returns a `Readable<TesseronConnectionState>`:\n\n```svelte\n<script lang=\"ts\">\n import { tesseron, tesseronConnection } from '@tesseron/svelte';\n\n tesseron.app({ id: 'my_app', name: 'My App' });\n // ...tesseronAction / tesseronResource calls register before the connection...\n const connection = tesseronConnection();\n</script>\n\n{#if $connection.status === 'open'}\n <p>Claim code: <code>{$connection.claimCode}</code></p>\n{/if}\n```\n\n`TesseronConnectionState`:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n}\n```\n\nOptions: `{ url?: string; enabled?: boolean }`. Set `enabled: false` to defer the connection (e.g., behind an auth gate).\n\n## Why an adapter at all\n\n`@tesseron/web` by itself works fine in Svelte; you can call `tesseron.action(...)` and `tesseron.connect()` at module scope. The adapter is a convenience when you want:\n\n- **Lifecycle scoping** - actions registered in a `+page.svelte` get torn down when the user navigates away.\n- **Reactive connection status** - `$connection.status` in templates without manual store plumbing.\n- **Latest-value closures** - the handler always sees the current `$state` without re-registration.\n\nIf none of that matters, stick with `@tesseron/web`.\n","bodyText":"`@tesseron/svelte` wraps `@tesseron/web` with Svelte lifecycle plumbing: actions and resources register on component mount, deregister on destroy; the connection status is a `Readable` store you subscribe to with `$connection` in templates.\n\nWorks with Svelte 4 and Svelte 5. Uses `onMount` / `onDestroy` / `writable` - no rune syntax, so the package ships as normal JS and doesn't need the Svelte compiler to build.\n\n## Install\n\n```bash\npnpm add @tesseron/svelte zod\npnpm add -D @tesseron/vite\n```\n\nThen register the [Vite plugin](/sdk/typescript/vite/) in your `vite.config.ts`.\n\n## API\n\nThree exports. The full `@tesseron/web` surface is re-exported too.\n\n```ts\n\n tesseronAction,\n tesseronResource,\n tesseronConnection,\n} from '@tesseron/svelte';\n```\n\n### `tesseronAction(name, options)`\n\nRegisters an action for the lifetime of the component. Same shape as the builder API, passed as an object:\n\n```svelte\n<script lang=\"ts\">\n import { tesseronAction } from '@tesseron/svelte';\n import { z } from 'zod';\n\n let todos = $state<string[]>([]);\n\n tesseronAction('addTodo', {\n input: z.object({ text: z.string() }),\n handler: ({ text }) => {\n todos = [...todos, text];\n },\n });\n</script>\n```\n\nOptions: `description`, `input`, `inputJsonSchema`, `output`, `outputJsonSchema`, `annotations`, `timeoutMs`, `strictOutput`, `handler`. The handler closes over `$state` / `$derived` variables and reads the current value at invocation time - no `$bindable` required.\n\n### `tesseronResource(name, optionsOrReader)`\n\nRegisters a resource. Pass a reader function for the shorthand, or an options object if you also want `subscribe`, `description`, or an output schema:\n\n```svelte\n<script lang=\"ts\">\n import { tesseronResource } from '@tesseron/svelte';\n\n let todos = $state<Todo[]>([]);\n\n // Read-only\n tesseronResource('todoCount', () => todos.length);\n\n // Read + subscribe\n const subs = new Set<(n: number) => void>();\n $effect(() => { const n = todos.length; subs.forEach(fn => fn(n)); });\n\n tesseronResource('todoCount', {\n read: () => todos.length,\n subscribe: (emit) => { subs.add(emit); return () => subs.delete(emit); },\n });\n</script>\n```\n\n### `tesseronConnection(options?)`\n\nOpens the connection on mount and returns a `Readable<TesseronConnectionState>`:\n\n```svelte\n<script lang=\"ts\">\n import { tesseron, tesseronConnection } from '@tesseron/svelte';\n\n tesseron.app({ id: 'my_app', name: 'My App' });\n // ...tesseronAction / tesseronResource calls register before the connection...\n const connection = tesseronConnection();\n</script>\n\n{#if $connection.status === 'open'}\n <p>Claim code: <code>{$connection.claimCode}</code></p>\n{/if}\n```\n\n`TesseronConnectionState`:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n}\n```\n\nOptions: `{ url?: string; enabled?: boolean }`. Set `enabled: false` to defer the connection (e.g., behind an auth gate).\n\n## Why an adapter at all\n\n`@tesseron/web` by itself works fine in Svelte; you can call `tesseron.action(...)` and `tesseron.connect()` at module scope. The adapter is a convenience when you want:\n\n- **Lifecycle scoping** - actions registered in a `+page.svelte` get torn down when the user navigates away.\n- **Reactive connection status** - `$connection.status` in templates without manual store plumbing.\n- **Latest-value closures** - the handler always sees the current `$state` without re-registration.\n\nIf none of that matters, stick with `@tesseron/web`."},{"slug":"sdk/typescript/vite","title":"@tesseron/vite","description":"Vite plugin that exposes `/@tesseron/ws` on your dev server and bridges browser tabs to the Tesseron gateway.","section":"sdk","related":["sdk/typescript/web","protocol/transport","overview/architecture"],"bodyRaw":"\n`@tesseron/vite` is the bridge that lets `@tesseron/web` (and `@tesseron/react`, `@tesseron/svelte`, `@tesseron/vue`) connect without a separate port.\n\n## Why it exists\n\nBrowsers can't bind TCP ports. The gateway needs a WebSocket endpoint to dial. The Vite dev server is already listening on a port - the plugin piggybacks on it.\n\nWhen a browser tab opens your dev URL, it dials `/@tesseron/ws` on the same origin. The plugin:\n\n1. Accepts the browser connection (no subprotocol).\n2. Writes `~/.tesseron/instances/<instanceId>.json` (a v2 manifest with `transport: { kind: 'ws', url }`) pointing at `/@tesseron/ws/<instanceId>` on your dev server.\n3. Waits for the gateway to dial the per-tab URL with the `tesseron-gateway` subprotocol.\n4. Bridges frames between the two sockets, buffering browser → gateway traffic if the browser starts talking before the gateway dials in. Text frames stay text, binary frames stay binary — the bridge preserves the frame type so the browser SDK isn't fed binary blobs that it would silently drop.\n\nOne tab → one instance manifest → one gateway connection → one Tesseron session. Multiple tabs coexist cleanly.\n\n## Install\n\n```bash\npnpm add -D @tesseron/vite\n```\n\nPeer: `vite >= 4`. No runtime dependencies on your framework plugin.\n\n## Register\n\n```ts title=\"vite.config.ts\"\nimport { defineConfig } from 'vite';\nimport { tesseron } from '@tesseron/vite';\n\nexport default defineConfig({\n plugins: [\n // ...your framework plugin (vue(), svelte(), react(), etc.)\n tesseron(),\n ],\n});\n```\n\nWith your framework plugin:\n\n```ts title=\"vite.config.ts (Vue)\"\nimport { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport { tesseron } from '@tesseron/vite';\n\nexport default defineConfig({\n plugins: [vue(), tesseron({ appName: 'vue-todo' })],\n});\n```\n\n## Options\n\n```ts\ntesseron({\n appName: 'my-app', // Optional. Written into the instance manifest so the\n // gateway log names your app usefully. Defaults to the\n // Vite project directory name.\n});\n```\n\nThat's the whole API surface. There's nothing to configure about ports, paths, or subprotocols - those are wire-level details.\n\n## How the browser reaches it\n\nThe client-side `@tesseron/web` defaults to `<location.origin>/@tesseron/ws`, so no URL config is needed in your app code:\n\n```ts\nimport { tesseron } from '@tesseron/web';\ntesseron.app({ id: 'shop', name: 'Shop' });\n// ...declare actions...\nawait tesseron.connect(); // dials ws://localhost:5173/@tesseron/ws\n```\n\nIf your Vite server runs on a non-default port (e.g. `5175`), `location.origin` already reflects that - the connection still lands on the plugin.\n\n## Multiple tabs\n\nEach browser tab gets its own `instanceId`, its own manifest, and its own gateway connection. Session claiming is per-tab - open three tabs of the same app and you get three claim codes, each independent.\n\n## Production builds\n\nThe plugin only runs under `vite dev`. Production builds (`vite build`) don't serve WebSocket endpoints, so a static `dist/` deployed to a CDN won't have `/@tesseron/ws` available.\n\nFor production Tesseron use with a browser SPA, you need a host process. Options:\n\n- **Electron / Tauri** - the native shell can run `@tesseron/server` in its main process and route `/@tesseron/ws` requests to it from the renderer.\n- **A custom reverse proxy in front of your SPA** that terminates `/@tesseron/ws` and bridges to a Node process running `@tesseron/server`.\n- **A separate Node service** that uses `@tesseron/server` if your prod topology already has one.\n\nThe Vite plugin is strictly for dev-time workflows.\n\n## What it doesn't do\n\n- **Not a framework adapter.** You still import from `@tesseron/web` / `@tesseron/react` / `@tesseron/svelte` / `@tesseron/vue` for the declarative API.\n- **Not a bundler plugin.** It only runs `configureServer`; no build-time transforms.\n- **Not a production tool.** See above.\n\n## Writing your own bridge\n\nIf you use a dev server other than Vite (webpack-dev-server, Rsbuild, Next.js dev, a custom Express-based HMR setup), the same three steps work:\n\n1. On WebSocket upgrade at `/@tesseron/ws` - accept the browser and assign an `instanceId`.\n2. Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'ws', url } }`, where `url` points at a tab-specific path like `/@tesseron/ws/<instanceId>`.\n3. On WebSocket upgrade at that per-tab path with subprotocol `tesseron-gateway` - accept the gateway and relay frames between the two sockets. Preserve text/binary frame types when relaying; buffer browser traffic until the gateway arrives.\n\n`@tesseron/vite`'s source is the reference; adapt it to whatever dev server you run.\n","bodyText":"`@tesseron/vite` is the bridge that lets `@tesseron/web` (and `@tesseron/react`, `@tesseron/svelte`, `@tesseron/vue`) connect without a separate port.\n\n## Why it exists\n\nBrowsers can't bind TCP ports. The gateway needs a WebSocket endpoint to dial. The Vite dev server is already listening on a port - the plugin piggybacks on it.\n\nWhen a browser tab opens your dev URL, it dials `/@tesseron/ws` on the same origin. The plugin:\n\n1. Accepts the browser connection (no subprotocol).\n2. Writes `~/.tesseron/instances/<instanceId>.json` (a v2 manifest with `transport: { kind: 'ws', url }`) pointing at `/@tesseron/ws/<instanceId>` on your dev server.\n3. Waits for the gateway to dial the per-tab URL with the `tesseron-gateway` subprotocol.\n4. Bridges frames between the two sockets, buffering browser → gateway traffic if the browser starts talking before the gateway dials in. Text frames stay text, binary frames stay binary — the bridge preserves the frame type so the browser SDK isn't fed binary blobs that it would silently drop.\n\nOne tab → one instance manifest → one gateway connection → one Tesseron session. Multiple tabs coexist cleanly.\n\n## Install\n\n```bash\npnpm add -D @tesseron/vite\n```\n\nPeer: `vite >= 4`. No runtime dependencies on your framework plugin.\n\n## Register\n\n```ts title=\"vite.config.ts\"\n\nexport default defineConfig({\n plugins: [\n // ...your framework plugin (vue(), svelte(), react(), etc.)\n tesseron(),\n ],\n});\n```\n\nWith your framework plugin:\n\n```ts title=\"vite.config.ts (Vue)\"\n\nexport default defineConfig({\n plugins: [vue(), tesseron({ appName: 'vue-todo' })],\n});\n```\n\n## Options\n\n```ts\ntesseron({\n appName: 'my-app', // Optional. Written into the instance manifest so the\n // gateway log names your app usefully. Defaults to the\n // Vite project directory name.\n});\n```\n\nThat's the whole API surface. There's nothing to configure about ports, paths, or subprotocols - those are wire-level details.\n\n## How the browser reaches it\n\nThe client-side `@tesseron/web` defaults to `<location.origin>/@tesseron/ws`, so no URL config is needed in your app code:\n\n```ts\n\ntesseron.app({ id: 'shop', name: 'Shop' });\n// ...declare actions...\nawait tesseron.connect(); // dials ws://localhost:5173/@tesseron/ws\n```\n\nIf your Vite server runs on a non-default port (e.g. `5175`), `location.origin` already reflects that - the connection still lands on the plugin.\n\n## Multiple tabs\n\nEach browser tab gets its own `instanceId`, its own manifest, and its own gateway connection. Session claiming is per-tab - open three tabs of the same app and you get three claim codes, each independent.\n\n## Production builds\n\nThe plugin only runs under `vite dev`. Production builds (`vite build`) don't serve WebSocket endpoints, so a static `dist/` deployed to a CDN won't have `/@tesseron/ws` available.\n\nFor production Tesseron use with a browser SPA, you need a host process. Options:\n\n- **Electron / Tauri** - the native shell can run `@tesseron/server` in its main process and route `/@tesseron/ws` requests to it from the renderer.\n- **A custom reverse proxy in front of your SPA** that terminates `/@tesseron/ws` and bridges to a Node process running `@tesseron/server`.\n- **A separate Node service** that uses `@tesseron/server` if your prod topology already has one.\n\nThe Vite plugin is strictly for dev-time workflows.\n\n## What it doesn't do\n\n- **Not a framework adapter.** You still import from `@tesseron/web` / `@tesseron/react` / `@tesseron/svelte` / `@tesseron/vue` for the declarative API.\n- **Not a bundler plugin.** It only runs `configureServer`; no build-time transforms.\n- **Not a production tool.** See above.\n\n## Writing your own bridge\n\nIf you use a dev server other than Vite (webpack-dev-server, Rsbuild, Next.js dev, a custom Express-based HMR setup), the same three steps work:\n\n1. On WebSocket upgrade at `/@tesseron/ws` - accept the browser and assign an `instanceId`.\n2. Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'ws', url } }`, where `url` points at a tab-specific path like `/@tesseron/ws/<instanceId>`.\n3. On WebSocket upgrade at that per-tab path with subprotocol `tesseron-gateway` - accept the gateway and relay frames between the two sockets. Preserve text/binary frame types when relaying; buffer browser traffic until the gateway arrives.\n\n`@tesseron/vite`'s source is the reference; adapt it to whatever dev server you run."},{"slug":"sdk/typescript/vue","title":"@tesseron/vue","description":"Vue 3 adapter. Composition-API bindings for actions, resources, and the connection state ref.","section":"sdk","related":["sdk/typescript/web","sdk/typescript/vite","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/vue` wraps `@tesseron/web` with Vue 3 Composition API lifecycle plumbing: actions and resources register on `onMounted`, deregister on `onUnmounted`; the connection status is a `Ref` that auto-unwraps in templates.\n\nVue 3.0+, Composition API. Script-setup or `setup()` return - either works.\n\n## Install\n\n```bash\npnpm add @tesseron/vue zod\npnpm add -D @tesseron/vite\n```\n\nThen register the [Vite plugin](/sdk/typescript/vite/) in your `vite.config.ts`.\n\n## API\n\nThree exports. The full `@tesseron/web` surface is re-exported too.\n\n```ts\nimport {\n tesseronAction,\n tesseronResource,\n tesseronConnection,\n} from '@tesseron/vue';\n```\n\n### `tesseronAction(name, options)`\n\nRegisters an action for the lifetime of the component. Same shape as the builder API, passed as an object:\n\n```vue\n<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { tesseronAction } from '@tesseron/vue';\nimport { z } from 'zod';\n\nconst todos = ref<string[]>([]);\n\ntesseronAction('addTodo', {\n input: z.object({ text: z.string() }),\n handler: ({ text }) => {\n todos.value = [...todos.value, text];\n },\n});\n</script>\n```\n\nOptions: `description`, `input`, `inputJsonSchema`, `output`, `outputJsonSchema`, `annotations`, `timeoutMs`, `strictOutput`, `handler`. The handler closes over `ref` / `computed` values and reads the current value at invocation time.\n\n### `tesseronResource(name, optionsOrReader)`\n\nRegisters a resource. Pass a reader function for the shorthand, or an options object if you also want `subscribe`, `description`, or an output schema:\n\n```vue\n<script setup lang=\"ts\">\nimport { ref, watch } from 'vue';\nimport { tesseronResource } from '@tesseron/vue';\n\nconst todos = ref<Todo[]>([]);\n\n// Read-only\ntesseronResource('todoCount', () => todos.value.length);\n\n// Read + subscribe\nconst subs = new Set<(n: number) => void>();\nwatch(() => todos.value.length, (n) => subs.forEach(fn => fn(n)));\n\ntesseronResource('todoCount', {\n read: () => todos.value.length,\n subscribe: (emit) => { subs.add(emit); return () => subs.delete(emit); },\n});\n</script>\n```\n\n### `tesseronConnection(options?)`\n\nOpens the connection on mount and returns a `Ref<TesseronConnectionState>`:\n\n```vue\n<script setup lang=\"ts\">\nimport { tesseron, tesseronConnection } from '@tesseron/vue';\n\ntesseron.app({ id: 'my_app', name: 'My App' });\n// ...tesseronAction / tesseronResource calls register before the connection...\nconst connection = tesseronConnection();\n</script>\n\n<template>\n <p v-if=\"connection.status === 'open'\">\n Claim code: <code>{{ connection.claimCode }}</code>\n </p>\n</template>\n```\n\nTemplates auto-unwrap refs, so `connection.status` works directly. Outside templates use `connection.value.status`.\n\n`TesseronConnectionState`:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n}\n```\n\nOptions: `{ url?: string; enabled?: boolean }`. Set `enabled: false` to defer the connection (e.g., behind an auth gate).\n\n## Why an adapter at all\n\n`@tesseron/web` by itself works fine in Vue; you can call `tesseron.action(...)` and `tesseron.connect()` at module scope. The adapter is a convenience when you want:\n\n- **Lifecycle scoping** - actions registered in a `<script setup>` get torn down when the component unmounts.\n- **Reactive connection status** - `connection.status` in templates without manual `ref` plumbing.\n- **Latest-value closures** - the handler always sees the current `ref.value` without re-registration.\n\nIf none of that matters, stick with `@tesseron/web`.\n","bodyText":"`@tesseron/vue` wraps `@tesseron/web` with Vue 3 Composition API lifecycle plumbing: actions and resources register on `onMounted`, deregister on `onUnmounted`; the connection status is a `Ref` that auto-unwraps in templates.\n\nVue 3.0+, Composition API. Script-setup or `setup()` return - either works.\n\n## Install\n\n```bash\npnpm add @tesseron/vue zod\npnpm add -D @tesseron/vite\n```\n\nThen register the [Vite plugin](/sdk/typescript/vite/) in your `vite.config.ts`.\n\n## API\n\nThree exports. The full `@tesseron/web` surface is re-exported too.\n\n```ts\n\n tesseronAction,\n tesseronResource,\n tesseronConnection,\n} from '@tesseron/vue';\n```\n\n### `tesseronAction(name, options)`\n\nRegisters an action for the lifetime of the component. Same shape as the builder API, passed as an object:\n\n```vue\n<script setup lang=\"ts\">\n\nconst todos = ref<string[]>([]);\n\ntesseronAction('addTodo', {\n input: z.object({ text: z.string() }),\n handler: ({ text }) => {\n todos.value = [...todos.value, text];\n },\n});\n</script>\n```\n\nOptions: `description`, `input`, `inputJsonSchema`, `output`, `outputJsonSchema`, `annotations`, `timeoutMs`, `strictOutput`, `handler`. The handler closes over `ref` / `computed` values and reads the current value at invocation time.\n\n### `tesseronResource(name, optionsOrReader)`\n\nRegisters a resource. Pass a reader function for the shorthand, or an options object if you also want `subscribe`, `description`, or an output schema:\n\n```vue\n<script setup lang=\"ts\">\n\nconst todos = ref<Todo[]>([]);\n\n// Read-only\ntesseronResource('todoCount', () => todos.value.length);\n\n// Read + subscribe\nconst subs = new Set<(n: number) => void>();\nwatch(() => todos.value.length, (n) => subs.forEach(fn => fn(n)));\n\ntesseronResource('todoCount', {\n read: () => todos.value.length,\n subscribe: (emit) => { subs.add(emit); return () => subs.delete(emit); },\n});\n</script>\n```\n\n### `tesseronConnection(options?)`\n\nOpens the connection on mount and returns a `Ref<TesseronConnectionState>`:\n\n```vue\n<script setup lang=\"ts\">\n\ntesseron.app({ id: 'my_app', name: 'My App' });\n// ...tesseronAction / tesseronResource calls register before the connection...\nconst connection = tesseronConnection();\n</script>\n\n<template>\n <p v-if=\"connection.status === 'open'\">\n Claim code: <code>{{ connection.claimCode }}</code>\n </p>\n</template>\n```\n\nTemplates auto-unwrap refs, so `connection.status` works directly. Outside templates use `connection.value.status`.\n\n`TesseronConnectionState`:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n}\n```\n\nOptions: `{ url?: string; enabled?: boolean }`. Set `enabled: false` to defer the connection (e.g., behind an auth gate).\n\n## Why an adapter at all\n\n`@tesseron/web` by itself works fine in Vue; you can call `tesseron.action(...)` and `tesseron.connect()` at module scope. The adapter is a convenience when you want:\n\n- **Lifecycle scoping** - actions registered in a `<script setup>` get torn down when the component unmounts.\n- **Reactive connection status** - `connection.status` in templates without manual `ref` plumbing.\n- **Latest-value closures** - the handler always sees the current `ref.value` without re-registration.\n\nIf none of that matters, stick with `@tesseron/web`."},{"slug":"sdk/typescript/web","title":"@tesseron/web","description":"The browser SDK. Singleton client, WebSocket transport, framework-agnostic.","section":"sdk","related":["sdk/typescript/core","protocol/transport","sdk/typescript/action-builder"],"bodyRaw":"\nThe package for anything running in a browser tab - vanilla TS, Vite, Next, Svelte, Vue. If you use React, the [@tesseron/react](/sdk/typescript/react/) adapter is the ergonomic wrapper on top of this.\n\n## Exports\n\n```ts\nimport {\n // Singleton client - pre-constructed, use directly.\n tesseron,\n // Class (if you need multiple clients, e.g. for multiple apps in one tab).\n WebTesseronClient,\n // WebSocket transport (WS client; dials the Vite plugin's bridge endpoint).\n BrowserWebSocketTransport,\n // Default endpoint — same-origin `/@tesseron/ws`, derived from `location.origin`.\n // Served by the `@tesseron/vite` plugin. In a dev browser this resolves to e.g.\n // `ws://localhost:5173/@tesseron/ws` when the page is served from Vite on :5173.\n DEFAULT_GATEWAY_URL,\n} from '@tesseron/web';\n\n// The full `@tesseron/core` surface is also re-exported.\n```\n\nBrowsers can't bind ports, so `@tesseron/web` is a WebSocket **client**. It dials the [`@tesseron/vite`](/sdk/typescript/vite/) plugin at the same origin; the plugin bridges the connection to the gateway that dialed in with the `tesseron-gateway` subprotocol.\n\n## Singleton usage\n\n```ts\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'shop', name: 'Shop' });\n\ntesseron.action('search')\n .input(z.object({ query: z.string() }))\n .handler(({ query }) => store.search(query));\n\nconst welcome = await tesseron.connect();\nconsole.log('claim code:', welcome.claimCode);\n```\n\n`tesseron.connect()` accepts:\n\n| Argument | Behaviour |\n|---|---|\n| `undefined` | Dials `<location.origin>/@tesseron/ws` - the endpoint exposed by the `@tesseron/vite` plugin. |\n| `string` (URL) | Dials that URL. |\n| `Transport` | Uses the supplied transport - mostly for tests. |\n\nBrowser apps need the [`@tesseron/vite`](/sdk/typescript/vite/) plugin in their `vite.config.ts` to serve `/@tesseron/ws`. Without it, `tesseron.connect()` will fail with a WebSocket error. If you use another dev server, pass a URL explicitly or build your own transport.\n\n### Re-entry safety\n\n`tesseron.connect()` is idempotent against re-entry. Two concurrent calls to the URL form with the same URL and the same `resume` credentials share a single in-flight promise (and a single WebSocket); the second caller does not open a parallel socket. This matters under React 18 StrictMode (mount → cleanup → remount), Vite HMR re-running module-scope `connect()`, and any flow that flips a connection-gating boolean rapidly. Without de-dup, the gateway would receive two `tesseron/resume` requests carrying the same single-shot token; the first would consume the zombie session and rotate, and the second would invariably fail with `ResumeFailed`. Connect-after-connect (a fresh call against an already-open transport) eagerly closes the prior socket, waits for its close handler to drain, and only then starts the new handshake — so dispatcher state never overlaps between the dying and the new transport.\n\nReturns `WelcomeResult`:\n\n```ts\ninterface WelcomeResult {\n sessionId: string;\n protocolVersion: string;\n capabilities: TesseronCapabilities; // { streaming, subscriptions, sampling, elicitation }\n agent: { id: string; name: string };\n claimCode?: string;\n}\n```\n\nThe SDK's own agreed-side capabilities (advertised in `tesseron/hello`) live in `SDK_CAPABILITIES`. The `welcome.capabilities` above describe what the *agent side* supports. Inside a handler the narrower `ctx.agentCapabilities` surface (`{ sampling, elicitation, subscriptions }`) is the one to branch on.\n\n## Multiple clients in one page\n\nThe singleton is convenient, but if you need two apps in one tab:\n\n```ts\nimport { WebTesseronClient } from '@tesseron/web';\n\nconst shop = new WebTesseronClient();\nshop.app({ id: 'shop', name: 'Shop' });\nshop.action('search').input(...).handler(...);\nawait shop.connect();\n\nconst admin = new WebTesseronClient();\nadmin.app({ id: 'admin', name: 'Admin' });\nadmin.action('ban').input(...).handler(...);\nawait admin.connect();\n```\n\nEach `WebTesseronClient` holds its own WebSocket to the MCP gateway. Two sessions, two claim codes. Tools don't collide because they're namespaced by `app.id`.\n\n## Custom transport\n\nThe built-in transport uses the browser's `WebSocket`. If you need something else (a service worker relaying to an extension, a shared worker, a BroadcastChannel for tests), pass a `Transport` directly:\n\n```ts\nconst custom: Transport = {\n send: (msg) => postMessage(msg),\n onMessage: (h) => addEventListener('message', (e) => h(e.data)),\n onClose: (h) => { /* ... */ },\n close: () => { /* ... */ },\n};\nawait tesseron.connect(custom);\n```\n\n## Frame handling quirks\n\n- The transport only handles string frames (`typeof ev.data === 'string'`). Non-string frames from the gateway are dropped - in practice the gateway always sends text, so this never fires.\n- Messages that fail `JSON.parse` are dropped silently.\n- The `open` event resolves `connect()`. If the WebSocket's `error` fires before `open`, `connect()` rejects with `WebSocket connection failed: <url>`.\n\n## Disconnect\n\n```ts\nawait tesseron.disconnect();\n```\n\nSends WebSocket close frame, rejects pending requests with `TransportClosedError`, aborts in-flight invocations. Safe to call multiple times.\n\n## Reconnect pattern\n\nThere is no built-in reconnect. Pattern:\n\n```ts\nasync function connectWithRetry(attempt = 0) {\n try {\n const welcome = await tesseron.connect();\n surfaceClaimCode(welcome.claimCode);\n } catch (err) {\n const delay = Math.min(30_000, 500 * 2 ** attempt);\n setTimeout(() => connectWithRetry(attempt + 1), delay);\n }\n}\nconnectWithRetry();\n```\n\nDon't reconnect automatically in a hot loop - if the gateway is down (plugin disabled), hammering the port wastes CPU. Back off, cap at ~30 s, surface the state to the user.\n","bodyText":"The package for anything running in a browser tab - vanilla TS, Vite, Next, Svelte, Vue. If you use React, the [@tesseron/react](/sdk/typescript/react/) adapter is the ergonomic wrapper on top of this.\n\n## Exports\n\n```ts\n\n // Singleton client - pre-constructed, use directly.\n tesseron,\n // Class (if you need multiple clients, e.g. for multiple apps in one tab).\n WebTesseronClient,\n // WebSocket transport (WS client; dials the Vite plugin's bridge endpoint).\n BrowserWebSocketTransport,\n // Default endpoint — same-origin `/@tesseron/ws`, derived from `location.origin`.\n // Served by the `@tesseron/vite` plugin. In a dev browser this resolves to e.g.\n // `ws://localhost:5173/@tesseron/ws` when the page is served from Vite on :5173.\n DEFAULT_GATEWAY_URL,\n} from '@tesseron/web';\n\n// The full `@tesseron/core` surface is also re-exported.\n```\n\nBrowsers can't bind ports, so `@tesseron/web` is a WebSocket **client**. It dials the [`@tesseron/vite`](/sdk/typescript/vite/) plugin at the same origin; the plugin bridges the connection to the gateway that dialed in with the `tesseron-gateway` subprotocol.\n\n## Singleton usage\n\n```ts\n\ntesseron.app({ id: 'shop', name: 'Shop' });\n\ntesseron.action('search')\n .input(z.object({ query: z.string() }))\n .handler(({ query }) => store.search(query));\n\nconst welcome = await tesseron.connect();\nconsole.log('claim code:', welcome.claimCode);\n```\n\n`tesseron.connect()` accepts:\n\n| Argument | Behaviour |\n|---|---|\n| `undefined` | Dials `<location.origin>/@tesseron/ws` - the endpoint exposed by the `@tesseron/vite` plugin. |\n| `string` (URL) | Dials that URL. |\n| `Transport` | Uses the supplied transport - mostly for tests. |\n\nBrowser apps need the [`@tesseron/vite`](/sdk/typescript/vite/) plugin in their `vite.config.ts` to serve `/@tesseron/ws`. Without it, `tesseron.connect()` will fail with a WebSocket error. If you use another dev server, pass a URL explicitly or build your own transport.\n\n### Re-entry safety\n\n`tesseron.connect()` is idempotent against re-entry. Two concurrent calls to the URL form with the same URL and the same `resume` credentials share a single in-flight promise (and a single WebSocket); the second caller does not open a parallel socket. This matters under React 18 StrictMode (mount → cleanup → remount), Vite HMR re-running module-scope `connect()`, and any flow that flips a connection-gating boolean rapidly. Without de-dup, the gateway would receive two `tesseron/resume` requests carrying the same single-shot token; the first would consume the zombie session and rotate, and the second would invariably fail with `ResumeFailed`. Connect-after-connect (a fresh call against an already-open transport) eagerly closes the prior socket, waits for its close handler to drain, and only then starts the new handshake — so dispatcher state never overlaps between the dying and the new transport.\n\nReturns `WelcomeResult`:\n\n```ts\ninterface WelcomeResult {\n sessionId: string;\n protocolVersion: string;\n capabilities: TesseronCapabilities; // { streaming, subscriptions, sampling, elicitation }\n agent: { id: string; name: string };\n claimCode?: string;\n}\n```\n\nThe SDK's own agreed-side capabilities (advertised in `tesseron/hello`) live in `SDK_CAPABILITIES`. The `welcome.capabilities` above describe what the *agent side* supports. Inside a handler the narrower `ctx.agentCapabilities` surface (`{ sampling, elicitation, subscriptions }`) is the one to branch on.\n\n## Multiple clients in one page\n\nThe singleton is convenient, but if you need two apps in one tab:\n\n```ts\n\nconst shop = new WebTesseronClient();\nshop.app({ id: 'shop', name: 'Shop' });\nshop.action('search').input(...).handler(...);\nawait shop.connect();\n\nconst admin = new WebTesseronClient();\nadmin.app({ id: 'admin', name: 'Admin' });\nadmin.action('ban').input(...).handler(...);\nawait admin.connect();\n```\n\nEach `WebTesseronClient` holds its own WebSocket to the MCP gateway. Two sessions, two claim codes. Tools don't collide because they're namespaced by `app.id`.\n\n## Custom transport\n\nThe built-in transport uses the browser's `WebSocket`. If you need something else (a service worker relaying to an extension, a shared worker, a BroadcastChannel for tests), pass a `Transport` directly:\n\n```ts\nconst custom: Transport = {\n send: (msg) => postMessage(msg),\n onMessage: (h) => addEventListener('message', (e) => h(e.data)),\n onClose: (h) => { /* ... */ },\n close: () => { /* ... */ },\n};\nawait tesseron.connect(custom);\n```\n\n## Frame handling quirks\n\n- The transport only handles string frames (`typeof ev.data === 'string'`). Non-string frames from the gateway are dropped - in practice the gateway always sends text, so this never fires.\n- Messages that fail `JSON.parse` are dropped silently.\n- The `open` event resolves `connect()`. If the WebSocket's `error` fires before `open`, `connect()` rejects with `WebSocket connection failed: <url>`.\n\n## Disconnect\n\n```ts\nawait tesseron.disconnect();\n```\n\nSends WebSocket close frame, rejects pending requests with `TransportClosedError`, aborts in-flight invocations. Safe to call multiple times.\n\n## Reconnect pattern\n\nThere is no built-in reconnect. Pattern:\n\n```ts\nasync function connectWithRetry(attempt = 0) {\n try {\n const welcome = await tesseron.connect();\n surfaceClaimCode(welcome.claimCode);\n } catch (err) {\n const delay = Math.min(30_000, 500 * 2 ** attempt);\n setTimeout(() => connectWithRetry(attempt + 1), delay);\n }\n}\nconnectWithRetry();\n```\n\nDon't reconnect automatically in a hot loop - if the gateway is down (plugin disabled), hammering the port wastes CPU. Back off, cap at ~30 s, surface the state to the user."}]}
1
+ {"version":"0b92bbc","generatedAt":"2026-05-16T16:58:53.537Z","count":42,"docs":[{"slug":"examples/express-prompts","title":"express-prompts","description":"REST API + Tesseron on the same Node process, backed by the same state. Sampling-heavy prompt-library domain.","section":"examples","related":["sdk/typescript/server","examples/node-prompts"],"bodyRaw":"\n**What it teaches:** how to expose the same backend operations via two channels at once - HTTP for human or programmatic clients, Tesseron for the agent (with `ctx.sample` and `ctx.elicit` layered on top). Both channels mutate the same state and fire the same resource subscribers.\n\n**Source:** [`examples/express-prompts`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/express-prompts)\n\n## Run it\n\n```bash\npnpm --filter express-prompts dev\n# REST on http://localhost:3001\n# @tesseron/server binds its WS endpoint on a random loopback port and writes\n# ~/.tesseron/instances/<instanceId>.json; the gateway dials it in. No port to configure.\n```\n\n## Domain\n\nA prompt library. REST clients (curl, internal dashboards) can CRUD prompts over `GET/POST/PATCH/DELETE /prompts`. Claude sees the same library via Tesseron, plus four actions that don't exist on the REST side because they depend on the agent's own LLM:\n\n- `testPrompt` - run a prompt through `ctx.sample`, store the response.\n- `refinePrompt` - elicit a refinement instruction, rewrite via `ctx.sample`.\n- `generateVariants` - ask the LLM for N alternative phrasings, stream progress as they land.\n- `purgeAll` - wipe everything; demands a typed `DELETE` confirmation via `ctx.elicit`.\n\nA resource subscription to `tesseron://prompt_lab/library` updates whether the mutation came from REST or from Claude.\n\n## Pattern: shared state, two interfaces\n\n```ts title=\"src/index.ts (excerpt)\"\nimport express from 'express';\nimport { tesseron } from '@tesseron/server';\nimport { z } from 'zod';\n\nconst prompts = new Map<string, Prompt>();\nconst librarySubs = new Set<(v: Prompt[]) => void>();\nfunction notifyLibrary() {\n const v = Array.from(prompts.values());\n librarySubs.forEach((fn) => fn(v));\n}\n\n// --- REST ---\nconst app = express();\napp.post('/prompts', (req, res) => {\n const p = { id: newId(), name: req.body.name, template: req.body.template, /* ... */ };\n prompts.set(p.id, p);\n notifyLibrary(); // <-- also fires Tesseron subscribers\n res.status(201).json(p);\n});\n// GET /prompts, PATCH /prompts/:id, DELETE /prompts/:id, GET /last-test ...\n\n// --- Tesseron ---\ntesseron.app({ id: 'prompt_lab', name: 'Prompt Lab (Express)' });\n\ntesseron.action('testPrompt')\n .input(z.object({\n id: z.string(),\n variables: z.record(z.string(), z.string()).optional(),\n }))\n .handler(async ({ id, variables }, ctx) => {\n const prompt = prompts.get(id)!;\n const response = await ctx.sample({\n prompt: applyTemplate(prompt.template, variables ?? {}),\n });\n // store response, bump timesTested, notifyLibrary(), notifyLastTest()\n return { id, response };\n });\n\ntesseron.resource<Prompt[]>('library')\n .read(() => Array.from(prompts.values()))\n .subscribe((emit) => { librarySubs.add(emit); return () => librarySubs.delete(emit); });\n\n// start both\napp.listen(3001);\nconst welcome = await tesseron.connect();\nconsole.log('Tesseron claim code:', welcome.claimCode);\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`deletePrompt`), `ctx.elicit` with schema (`refinePrompt`, `purgeAll`), `ctx.progress` (`importPrompts`, `generateVariants`), `ctx.sample` free-text (`testPrompt`, `refinePrompt`) and `ctx.sample` with Zod schema (`generateVariants`), cancellation via `ctx.signal`, capability gating via `ctx.agentCapabilities.sampling`, coexistence with an HTTP server in one process, unified notification layer that keeps Tesseron subscribers in sync with REST writes**.\n\n## When this pattern fits\n\n- You already have a backend and want Claude to drive it without duplicating business logic.\n- You want a single source of truth (the `Map`, in this example - a database, in real life).\n- You want the two channels to stay out of each other's way - no HTTP calls pretending to be agent calls, no awkward \"AI mode\" in your REST routes.\n- The agent's own LLM is part of the workflow for at least some operations (`ctx.sample`), and you'd rather not burn an extra API key on the server side.\n","bodyText":"**What it teaches:** how to expose the same backend operations via two channels at once - HTTP for human or programmatic clients, Tesseron for the agent (with `ctx.sample` and `ctx.elicit` layered on top). Both channels mutate the same state and fire the same resource subscribers.\n\n**Source:** [`examples/express-prompts`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/express-prompts)\n\n## Run it\n\n```bash\npnpm --filter express-prompts dev\n# REST on http://localhost:3001\n# @tesseron/server binds its WS endpoint on a random loopback port and writes\n# ~/.tesseron/instances/<instanceId>.json; the gateway dials it in. No port to configure.\n```\n\n## Domain\n\nA prompt library. REST clients (curl, internal dashboards) can CRUD prompts over `GET/POST/PATCH/DELETE /prompts`. Claude sees the same library via Tesseron, plus four actions that don't exist on the REST side because they depend on the agent's own LLM:\n\n- `testPrompt` - run a prompt through `ctx.sample`, store the response.\n- `refinePrompt` - elicit a refinement instruction, rewrite via `ctx.sample`.\n- `generateVariants` - ask the LLM for N alternative phrasings, stream progress as they land.\n- `purgeAll` - wipe everything; demands a typed `DELETE` confirmation via `ctx.elicit`.\n\nA resource subscription to `tesseron://prompt_lab/library` updates whether the mutation came from REST or from Claude.\n\n## Pattern: shared state, two interfaces\n\n```ts title=\"src/index.ts (excerpt)\"\n\nconst prompts = new Map<string, Prompt>();\nconst librarySubs = new Set<(v: Prompt[]) => void>();\nfunction notifyLibrary() {\n const v = Array.from(prompts.values());\n librarySubs.forEach((fn) => fn(v));\n}\n\n// --- REST ---\nconst app = express();\napp.post('/prompts', (req, res) => {\n const p = { id: newId(), name: req.body.name, template: req.body.template, /* ... */ };\n prompts.set(p.id, p);\n notifyLibrary(); // <-- also fires Tesseron subscribers\n res.status(201).json(p);\n});\n// GET /prompts, PATCH /prompts/:id, DELETE /prompts/:id, GET /last-test ...\n\n// --- Tesseron ---\ntesseron.app({ id: 'prompt_lab', name: 'Prompt Lab (Express)' });\n\ntesseron.action('testPrompt')\n .input(z.object({\n id: z.string(),\n variables: z.record(z.string(), z.string()).optional(),\n }))\n .handler(async ({ id, variables }, ctx) => {\n const prompt = prompts.get(id)!;\n const response = await ctx.sample({\n prompt: applyTemplate(prompt.template, variables ?? {}),\n });\n // store response, bump timesTested, notifyLibrary(), notifyLastTest()\n return { id, response };\n });\n\ntesseron.resource<Prompt[]>('library')\n .read(() => Array.from(prompts.values()))\n .subscribe((emit) => { librarySubs.add(emit); return () => librarySubs.delete(emit); });\n\n// start both\napp.listen(3001);\nconst welcome = await tesseron.connect();\nconsole.log('Tesseron claim code:', welcome.claimCode);\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`deletePrompt`), `ctx.elicit` with schema (`refinePrompt`, `purgeAll`), `ctx.progress` (`importPrompts`, `generateVariants`), `ctx.sample` free-text (`testPrompt`, `refinePrompt`) and `ctx.sample` with Zod schema (`generateVariants`), cancellation via `ctx.signal`, capability gating via `ctx.agentCapabilities.sampling`, coexistence with an HTTP server in one process, unified notification layer that keeps Tesseron subscribers in sync with REST writes**.\n\n## When this pattern fits\n\n- You already have a backend and want Claude to drive it without duplicating business logic.\n- You want a single source of truth (the `Map`, in this example - a database, in real life).\n- You want the two channels to stay out of each other's way - no HTTP calls pretending to be agent calls, no awkward \"AI mode\" in your REST routes.\n- The agent's own LLM is part of the workflow for at least some operations (`ctx.sample`), and you'd rather not burn an extra API key on the server side."},{"slug":"examples/index","title":"All examples","description":"Six runnable apps across two domains that together cover every framework adapter and every major feature of the SDK.","section":"examples","related":["overview/quickstart","sdk/typescript/index"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\n\nAll six examples live in [`examples/`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples). Each is complete, runnable, and intentionally simple so the Tesseron-specific code is easy to read. Client-side examples share a **todo** domain; server-side examples share a sampling-heavy **prompt-library** domain.\n\n<CardGrid>\n <LinkCard title=\"vanilla-todo\" href=\"./vanilla-todo/\"\n description=\"Zero-framework baseline. Start here.\" />\n <LinkCard title=\"node-prompts\" href=\"./node-prompts/\"\n description=\"Headless Node prompt library. No browser.\" />\n <LinkCard title=\"express-prompts\" href=\"./express-prompts/\"\n description=\"HTTP REST + MCP on the same Node process.\" />\n <LinkCard title=\"react-todo\" href=\"./react-todo/\"\n description=\"React 18 + `@tesseron/react` hooks.\" />\n <LinkCard title=\"svelte-todo\" href=\"./svelte-todo/\"\n description=\"Svelte 5 runes (`$state`, `$derived`).\" />\n <LinkCard title=\"vue-todo\" href=\"./vue-todo/\"\n description=\"Vue 3 composition API.\" />\n</CardGrid>\n\n## Feature matrix\n\n| Feature | vanilla | node | express | react | svelte | vue |\n|---|:-:|:-:|:-:|:-:|:-:|:-:|\n| Basic actions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Subscribable resources | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Annotations (`destructive`, `requiresConfirmation`, `readOnly`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Zod input validation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.confirm` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.elicit` with schema | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.progress` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.sample` | ✅ | ✅ (first-class) | ✅ (first-class) | ✅ | ✅ | ✅ |\n| Framework hooks | ✗ | - | ✗ | ✅ | ✗ | ✗ |\n| Runs in the browser | ✅ | ✗ | ✗ | ✅ | ✅ | ✅ |\n| Runs in Node | ✗ | ✅ | ✅ | ✗ | ✗ | ✗ |\n| Parallel REST API | ✗ | ✗ | ✅ | ✗ | ✗ | ✗ |\n\n## Recommended reading order\n\n1. **[vanilla-todo](/examples/vanilla-todo/)** - plain DOM, no framework. The SDK's builder API with nothing in the way.\n2. **[node-prompts](/examples/node-prompts/)** - a headless prompt library on Node. Shows the same builder API on the server, with `ctx.sample` and `ctx.elicit` as the center of the domain.\n3. **[express-prompts](/examples/express-prompts/)** - the same prompt library plus a REST API. Demonstrates \"same state, two channels\": HTTP writes fire Tesseron resource notifications and vice versa.\n4. **[react-todo](/examples/react-todo/)** - hooks-based integration.\n5. **[svelte-todo](/examples/svelte-todo/)** - mutation via `$state` runes.\n6. **[vue-todo](/examples/vue-todo/)** - mutation via `ref().value` / `computed()`.\n\n## Running any of them\n\n```bash\ngit clone https://github.com/BrainBlend-AI/tesseron\ncd tesseron\npnpm install\npnpm --filter <example-name> dev\n```\n\nThen claim the session from your agent - see the [quickstart](/overview/quickstart/) if you haven't already.\n","bodyText":"All six examples live in [`examples/`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples). Each is complete, runnable, and intentionally simple so the Tesseron-specific code is easy to read. Client-side examples share a **todo** domain; server-side examples share a sampling-heavy **prompt-library** domain.\n\n## Feature matrix\n\n| Feature | vanilla | node | express | react | svelte | vue |\n|---|:-:|:-:|:-:|:-:|:-:|:-:|\n| Basic actions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Subscribable resources | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Annotations (`destructive`, `requiresConfirmation`, `readOnly`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Zod input validation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.confirm` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.elicit` with schema | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.progress` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.sample` | ✅ | ✅ (first-class) | ✅ (first-class) | ✅ | ✅ | ✅ |\n| Framework hooks | ✗ | - | ✗ | ✅ | ✗ | ✗ |\n| Runs in the browser | ✅ | ✗ | ✗ | ✅ | ✅ | ✅ |\n| Runs in Node | ✗ | ✅ | ✅ | ✗ | ✗ | ✗ |\n| Parallel REST API | ✗ | ✗ | ✅ | ✗ | ✗ | ✗ |\n\n## Recommended reading order\n\n1. **[vanilla-todo](/examples/vanilla-todo/)** - plain DOM, no framework. The SDK's builder API with nothing in the way.\n2. **[node-prompts](/examples/node-prompts/)** - a headless prompt library on Node. Shows the same builder API on the server, with `ctx.sample` and `ctx.elicit` as the center of the domain.\n3. **[express-prompts](/examples/express-prompts/)** - the same prompt library plus a REST API. Demonstrates \"same state, two channels\": HTTP writes fire Tesseron resource notifications and vice versa.\n4. **[react-todo](/examples/react-todo/)** - hooks-based integration.\n5. **[svelte-todo](/examples/svelte-todo/)** - mutation via `$state` runes.\n6. **[vue-todo](/examples/vue-todo/)** - mutation via `ref().value` / `computed()`.\n\n## Running any of them\n\n```bash\ngit clone https://github.com/BrainBlend-AI/tesseron\ncd tesseron\npnpm install\npnpm --filter <example-name> dev\n```\n\nThen claim the session from your agent - see the [quickstart](/overview/quickstart/) if you haven't already."},{"slug":"examples/node-prompts","title":"node-prompts","description":"Headless Node prompt library - no HTTP, no browser. Shows sampling and elicitation as first-class domain features.","section":"examples","related":["sdk/typescript/server"],"bodyRaw":"\n**What it teaches:** a pure-Node Tesseron integration whose domain revolves around `ctx.sample` and `ctx.elicit`. No Express, no HTTP server - just a Node script that registers actions and connects. Good when you're building a CLI, a daemon, or a worker that Claude should drive, and the agent's LLM is part of the workflow.\n\n**Source:** [`examples/node-prompts`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/node-prompts)\n\n## Run it\n\n```bash\npnpm --filter node-prompts dev\n# prints the claim code to stdout; no browser\n```\n\n## Domain\n\nA library of reusable LLM prompts. Claude can:\n\n- `addPrompt`, `listPrompts`, `deletePrompt`, `importPrompts`, `purgeAll` - CRUD over the library.\n- `testPrompt` - fill `{{var}}` placeholders, send the prompt through `ctx.sample`, store the response as `lastTest`.\n- `refinePrompt` - elicit a free-text refinement instruction from the user via `ctx.elicit`, then ask the agent LLM via `ctx.sample` to rewrite the template in place.\n- `generateVariants` - ask the agent LLM for N alternative phrasings of a prompt, stream progress as each lands, store them as new prompts.\n\nTwo subscribable resources push updates on every mutation: `library` (`Prompt[]`) and `lastTest` (`TestResult | null`).\n\n## What's inside\n\n```ts title=\"src/index.ts (excerpt)\"\nimport { tesseron } from '@tesseron/server';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'prompt_lab', name: 'Prompt Lab' });\n\ntesseron.action('testPrompt')\n .input(z.object({\n id: z.string(),\n variables: z.record(z.string(), z.string()).optional(),\n }))\n .handler(async ({ id, variables }, ctx) => {\n const prompt = prompts.get(id)!;\n if (!ctx.agentCapabilities.sampling) {\n throw new Error('Agent does not support sampling.');\n }\n const filled = applyTemplate(prompt.template, variables ?? {});\n const response = await ctx.sample({ prompt: filled, maxTokens: 512 });\n // store response as lastTest, bump timesTested, notify subscribers\n return { id, response };\n });\n\ntesseron.action('refinePrompt')\n .input(z.object({ id: z.string() }))\n .handler(async ({ id }, ctx) => {\n const answer = await ctx.elicit({\n question: `What should change?`,\n schema: z.object({ instruction: z.string().min(1) }),\n jsonSchema: { /* ... */ },\n });\n if (answer === null) return { id, refined: false, cancelled: true };\n const rewritten = await ctx.sample({\n prompt: `Rewrite this prompt per instruction: ${answer.instruction}\\n\\n${prompt.template}`,\n });\n // replace template with rewritten.trim(), notify subscribers\n });\n\ntesseron.resource<Prompt[]>('library')\n .read(() => Array.from(prompts.values()))\n .subscribe((emit) => { librarySubs.add(emit); return () => librarySubs.delete(emit); });\n\nconst welcome = await tesseron.connect();\nlog(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`deletePrompt`), `ctx.elicit` with schema (`refinePrompt`, `purgeAll`), `ctx.progress` (`importPrompts`, `generateVariants`), `ctx.sample` free-text (`testPrompt`, `refinePrompt`) and `ctx.sample` with Zod schema (`generateVariants`), cancellation via `ctx.signal`, capability gating via `ctx.agentCapabilities.sampling`, structured logging via `log()`, signal-aware shutdown**.\n\nPair with [`express-prompts`](/examples/express-prompts/) to see the same domain served over HTTP.\n","bodyText":"**What it teaches:** a pure-Node Tesseron integration whose domain revolves around `ctx.sample` and `ctx.elicit`. No Express, no HTTP server - just a Node script that registers actions and connects. Good when you're building a CLI, a daemon, or a worker that Claude should drive, and the agent's LLM is part of the workflow.\n\n**Source:** [`examples/node-prompts`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/node-prompts)\n\n## Run it\n\n```bash\npnpm --filter node-prompts dev\n# prints the claim code to stdout; no browser\n```\n\n## Domain\n\nA library of reusable LLM prompts. Claude can:\n\n- `addPrompt`, `listPrompts`, `deletePrompt`, `importPrompts`, `purgeAll` - CRUD over the library.\n- `testPrompt` - fill `{{var}}` placeholders, send the prompt through `ctx.sample`, store the response as `lastTest`.\n- `refinePrompt` - elicit a free-text refinement instruction from the user via `ctx.elicit`, then ask the agent LLM via `ctx.sample` to rewrite the template in place.\n- `generateVariants` - ask the agent LLM for N alternative phrasings of a prompt, stream progress as each lands, store them as new prompts.\n\nTwo subscribable resources push updates on every mutation: `library` (`Prompt[]`) and `lastTest` (`TestResult | null`).\n\n## What's inside\n\n```ts title=\"src/index.ts (excerpt)\"\n\ntesseron.app({ id: 'prompt_lab', name: 'Prompt Lab' });\n\ntesseron.action('testPrompt')\n .input(z.object({\n id: z.string(),\n variables: z.record(z.string(), z.string()).optional(),\n }))\n .handler(async ({ id, variables }, ctx) => {\n const prompt = prompts.get(id)!;\n if (!ctx.agentCapabilities.sampling) {\n throw new Error('Agent does not support sampling.');\n }\n const filled = applyTemplate(prompt.template, variables ?? {});\n const response = await ctx.sample({ prompt: filled, maxTokens: 512 });\n // store response as lastTest, bump timesTested, notify subscribers\n return { id, response };\n });\n\ntesseron.action('refinePrompt')\n .input(z.object({ id: z.string() }))\n .handler(async ({ id }, ctx) => {\n const answer = await ctx.elicit({\n question: `What should change?`,\n schema: z.object({ instruction: z.string().min(1) }),\n jsonSchema: { /* ... */ },\n });\n if (answer === null) return { id, refined: false, cancelled: true };\n const rewritten = await ctx.sample({\n prompt: `Rewrite this prompt per instruction: ${answer.instruction}\\n\\n${prompt.template}`,\n });\n // replace template with rewritten.trim(), notify subscribers\n });\n\ntesseron.resource<Prompt[]>('library')\n .read(() => Array.from(prompts.values()))\n .subscribe((emit) => { librarySubs.add(emit); return () => librarySubs.delete(emit); });\n\nconst welcome = await tesseron.connect();\nlog(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`deletePrompt`), `ctx.elicit` with schema (`refinePrompt`, `purgeAll`), `ctx.progress` (`importPrompts`, `generateVariants`), `ctx.sample` free-text (`testPrompt`, `refinePrompt`) and `ctx.sample` with Zod schema (`generateVariants`), cancellation via `ctx.signal`, capability gating via `ctx.agentCapabilities.sampling`, structured logging via `log()`, signal-aware shutdown**.\n\nPair with [`express-prompts`](/examples/express-prompts/) to see the same domain served over HTTP."},{"slug":"examples/react-todo","title":"react-todo","description":"React 18 + `@tesseron/react` hooks. Idiomatic integration with component lifecycle.","section":"examples","related":["sdk/typescript/react","sdk/typescript/web"],"bodyRaw":"\n**What it teaches:** declarative action registration in React. Mount = register; unmount = unregister. State is mutated through `setTodos` exactly like in a normal React app.\n\n**Source:** [`examples/react-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/react-todo)\n\n## Run it\n\n```bash\npnpm --filter react-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```tsx title=\"src/app.tsx (excerpt)\"\nimport { useTesseronAction, useTesseronResource, useTesseronConnection } from '@tesseron/react';\nimport { z } from 'zod';\nimport { useState } from 'react';\n\nexport function App() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.claimCode && <ClaimBanner code={conn.claimCode} />}\n <TodoList todos={todos} />\n </>\n );\n}\n```\n\nFeatures exercised: **all three React hooks (`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`), annotations, Zod input, setState-driven UI reactivity, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), subscribable resources**.\n\nSee the [React adapter docs](/sdk/typescript/react/) for the full hook API.\n","bodyText":"**What it teaches:** declarative action registration in React. Mount = register; unmount = unregister. State is mutated through `setTodos` exactly like in a normal React app.\n\n**Source:** [`examples/react-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/react-todo)\n\n## Run it\n\n```bash\npnpm --filter react-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```tsx title=\"src/app.tsx (excerpt)\"\n\nexport function App() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.claimCode && }\n \n </>\n );\n}\n```\n\nFeatures exercised: **all three React hooks (`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`), annotations, Zod input, setState-driven UI reactivity, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), subscribable resources**.\n\nSee the [React adapter docs](/sdk/typescript/react/) for the full hook API."},{"slug":"examples/svelte-todo","title":"svelte-todo","description":"Svelte 5 runes driven by `@tesseron/svelte` and bridged by `@tesseron/vite`.","section":"examples","related":["sdk/typescript/svelte","sdk/typescript/vite"],"bodyRaw":"\n**What it teaches:** integrating Tesseron with Svelte 5's rune-based reactivity via `@tesseron/svelte`. Handlers reassign `$state` variables and Svelte re-renders; the `@tesseron/vite` plugin bridges the browser WebSocket to the gateway.\n\n**Source:** [`examples/svelte-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/svelte-todo)\n\n## Run it\n\n```bash\npnpm --filter svelte-todo dev\n# http://localhost:5175\n```\n\n## What's inside\n\n```ts title=\"vite.config.ts\"\nimport { defineConfig } from 'vite';\nimport { svelte } from '@sveltejs/vite-plugin-svelte';\nimport { tesseron } from '@tesseron/vite';\n\nexport default defineConfig({\n plugins: [svelte(), tesseron({ appName: 'svelte-todo' })],\n server: { port: 5175 },\n});\n```\n\n```svelte title=\"src/app.svelte (excerpt)\"\n<script lang=\"ts\">\n import { tesseron, tesseronAction, tesseronResource, tesseronConnection } from '@tesseron/svelte';\n import { z } from 'zod';\n\n let todos = $state<Todo[]>([]);\n let filter = $state<'all' | 'active' | 'done'>('all');\n const visibleTodos = $derived(\n filter === 'all' ? todos : todos.filter((t) => (filter === 'done' ? t.done : !t.done))\n );\n\n tesseron.app({ id: 'svelte_todo', name: 'Svelte Todo' });\n\n tesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos = [...todos, todo];\n return todo;\n },\n });\n\n tesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n const connection = tesseronConnection();\n</script>\n\n{#if $connection.status === 'open'}\n <p>Claim code: {$connection.claimCode}</p>\n{/if}\n```\n\nFeatures exercised: **`$state` / `$derived` runes, component-scoped actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`, with graceful fallback when sampling isn't advertised)**.\n\nThe Vite plugin serves `/@tesseron/ws` on the same port as the dev server; the adapter package handles lifecycle scoping. If you prefer the raw API, you can use `@tesseron/web` directly inside `onMount` - the adapter is a convenience.\n","bodyText":"**What it teaches:** integrating Tesseron with Svelte 5's rune-based reactivity via `@tesseron/svelte`. Handlers reassign `$state` variables and Svelte re-renders; the `@tesseron/vite` plugin bridges the browser WebSocket to the gateway.\n\n**Source:** [`examples/svelte-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/svelte-todo)\n\n## Run it\n\n```bash\npnpm --filter svelte-todo dev\n# http://localhost:5175\n```\n\n## What's inside\n\n```ts title=\"vite.config.ts\"\n\nexport default defineConfig({\n plugins: [svelte(), tesseron({ appName: 'svelte-todo' })],\n server: { port: 5175 },\n});\n```\n\n```svelte title=\"src/app.svelte (excerpt)\"\n<script lang=\"ts\">\n import { tesseron, tesseronAction, tesseronResource, tesseronConnection } from '@tesseron/svelte';\n import { z } from 'zod';\n\n let todos = $state<Todo[]>([]);\n let filter = $state<'all' | 'active' | 'done'>('all');\n const visibleTodos = $derived(\n filter === 'all' ? todos : todos.filter((t) => (filter === 'done' ? t.done : !t.done))\n );\n\n tesseron.app({ id: 'svelte_todo', name: 'Svelte Todo' });\n\n tesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos = [...todos, todo];\n return todo;\n },\n });\n\n tesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n const connection = tesseronConnection();\n</script>\n\n{#if $connection.status === 'open'}\n <p>Claim code: {$connection.claimCode}</p>\n{/if}\n```\n\nFeatures exercised: **`$state` / `$derived` runes, component-scoped actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`, with graceful fallback when sampling isn't advertised)**.\n\nThe Vite plugin serves `/@tesseron/ws` on the same port as the dev server; the adapter package handles lifecycle scoping. If you prefer the raw API, you can use `@tesseron/web` directly inside `onMount` - the adapter is a convenience."},{"slug":"examples/vanilla-todo","title":"vanilla-todo","description":"Plain Vite + TypeScript. The minimum environment for exercising the SDK.","section":"examples","related":["sdk/typescript/web","sdk/typescript/index"],"bodyRaw":"\n**What it teaches:** the raw action / resource builder API with no framework in the way. Read this before any of the framework-specific examples.\n\n**Source:** [`examples/vanilla-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vanilla-todo)\n\n## Run it\n\n```bash\npnpm --filter vanilla-todo dev\n# opens http://localhost:5173\n```\n\n## What's inside\n\n```ts title=\"src/main.ts (excerpt)\"\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'vanilla_todo', name: 'Vanilla Todo' });\n\ntesseron\n .action('addTodo')\n .describe('Add a new todo item. Returns the created todo.')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n state.todos = [...state.todos, todo];\n render();\n return todo;\n });\n\ntesseron.action('toggleTodo')\n .input(z.object({ id: z.string() }))\n .annotate({ destructive: true })\n .handler(/* … */);\n\ntesseron.resource('todoStats')\n .read(() => ({ total: state.todos.length, completed: state.todos.filter(t => t.done).length }));\n\nawait tesseron.connect();\n```\n\nNine actions (`addTodo`, `toggleTodo`, `deleteTodo`, `listTodos`, `setFilter`, `clearCompleted`, `renameTodo`, `importTodos`, `suggestTodos`) and two subscribable resources (`currentFilter`, `todoStats`) - a realistic-but-contained surface for experimenting.\n\nFeatures exercised: **actions, annotations (`destructive`, `requiresConfirmation`, `readOnly`), subscribable resources, Zod input validation, `ctx.confirm` (in `clearCompleted`), `ctx.elicit` with schema (in `renameTodo`), `ctx.progress` (in `importTodos`), `ctx.sample` (in `suggestTodos`), connection lifecycle**.\n","bodyText":"**What it teaches:** the raw action / resource builder API with no framework in the way. Read this before any of the framework-specific examples.\n\n**Source:** [`examples/vanilla-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vanilla-todo)\n\n## Run it\n\n```bash\npnpm --filter vanilla-todo dev\n# opens http://localhost:5173\n```\n\n## What's inside\n\n```ts title=\"src/main.ts (excerpt)\"\n\ntesseron.app({ id: 'vanilla_todo', name: 'Vanilla Todo' });\n\ntesseron\n .action('addTodo')\n .describe('Add a new todo item. Returns the created todo.')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n state.todos = [...state.todos, todo];\n render();\n return todo;\n });\n\ntesseron.action('toggleTodo')\n .input(z.object({ id: z.string() }))\n .annotate({ destructive: true })\n .handler(/* … */);\n\ntesseron.resource('todoStats')\n .read(() => ({ total: state.todos.length, completed: state.todos.filter(t => t.done).length }));\n\nawait tesseron.connect();\n```\n\nNine actions (`addTodo`, `toggleTodo`, `deleteTodo`, `listTodos`, `setFilter`, `clearCompleted`, `renameTodo`, `importTodos`, `suggestTodos`) and two subscribable resources (`currentFilter`, `todoStats`) - a realistic-but-contained surface for experimenting.\n\nFeatures exercised: **actions, annotations (`destructive`, `requiresConfirmation`, `readOnly`), subscribable resources, Zod input validation, `ctx.confirm` (in `clearCompleted`), `ctx.elicit` with schema (in `renameTodo`), `ctx.progress` (in `importTodos`), `ctx.sample` (in `suggestTodos`), connection lifecycle**."},{"slug":"examples/vue-todo","title":"vue-todo","description":"Vue 3 composition API driven by `@tesseron/vue` and bridged by `@tesseron/vite`.","section":"examples","related":["sdk/typescript/vue","sdk/typescript/vite"],"bodyRaw":"\n**What it teaches:** integrating Tesseron with Vue 3's reactivity via `@tesseron/vue`. Handlers mutate `todos.value` and the `@tesseron/vite` plugin bridges the browser WebSocket to the gateway.\n\n**Source:** [`examples/vue-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vue-todo)\n\n## Run it\n\n```bash\npnpm --filter vue-todo dev\n# http://localhost:5176\n```\n\n## What's inside\n\n```ts title=\"vite.config.ts\"\nimport { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport { tesseron } from '@tesseron/vite';\n\nexport default defineConfig({\n plugins: [vue(), tesseron({ appName: 'vue-todo' })],\n server: { port: 5176 },\n});\n```\n\n```vue title=\"src/app.vue (excerpt)\"\n<script setup lang=\"ts\">\nimport { ref, computed } from 'vue';\nimport { tesseron, tesseronAction, tesseronResource, tesseronConnection } from '@tesseron/vue';\nimport { z } from 'zod';\n\nconst todos = ref<Todo[]>([]);\nconst filter = ref<'all' | 'active' | 'done'>('all');\nconst visibleTodos = computed(() =>\n filter.value === 'all'\n ? todos.value\n : todos.value.filter((t) => (filter.value === 'done' ? t.done : !t.done))\n);\n\ntesseron.app({ id: 'vue_todo', name: 'Vue Todo' });\n\ntesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.value = [...todos.value, todo];\n return todo;\n },\n});\n\ntesseronResource('todoStats', () => ({\n total: todos.value.length,\n completed: todos.value.filter((t) => t.done).length,\n}));\n\nconst connection = tesseronConnection();\n</script>\n\n<template>\n <p v-if=\"connection.status === 'open'\">Claim code: {{ connection.claimCode }}</p>\n</template>\n```\n\nFeatures exercised: **`ref` + `computed`, component-scoped actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`)**.\n\nThe Vite plugin serves `/@tesseron/ws` on the same port as the dev server; the adapter package handles lifecycle scoping. If you prefer the raw API, you can use `@tesseron/web` directly inside `onMounted` - the adapter is a convenience.\n","bodyText":"**What it teaches:** integrating Tesseron with Vue 3's reactivity via `@tesseron/vue`. Handlers mutate `todos.value` and the `@tesseron/vite` plugin bridges the browser WebSocket to the gateway.\n\n**Source:** [`examples/vue-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vue-todo)\n\n## Run it\n\n```bash\npnpm --filter vue-todo dev\n# http://localhost:5176\n```\n\n## What's inside\n\n```ts title=\"vite.config.ts\"\n\nexport default defineConfig({\n plugins: [vue(), tesseron({ appName: 'vue-todo' })],\n server: { port: 5176 },\n});\n```\n\n```vue title=\"src/app.vue (excerpt)\"\n<script setup lang=\"ts\">\n\nconst todos = ref<Todo[]>([]);\nconst filter = ref<'all' | 'active' | 'done'>('all');\nconst visibleTodos = computed(() =>\n filter.value === 'all'\n ? todos.value\n : todos.value.filter((t) => (filter.value === 'done' ? t.done : !t.done))\n);\n\ntesseron.app({ id: 'vue_todo', name: 'Vue Todo' });\n\ntesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.value = [...todos.value, todo];\n return todo;\n },\n});\n\ntesseronResource('todoStats', () => ({\n total: todos.value.length,\n completed: todos.value.filter((t) => t.done).length,\n}));\n\nconst connection = tesseronConnection();\n</script>\n\n<template>\n <p v-if=\"connection.status === 'open'\">Claim code: {{ connection.claimCode }}</p>\n</template>\n```\n\nFeatures exercised: **`ref` + `computed`, component-scoped actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`)**.\n\nThe Vite plugin serves `/@tesseron/ws` on the same port as the dev server; the adapter package handles lifecycle scoping. If you prefer the raw API, you can use `@tesseron/web` directly inside `onMounted` - the adapter is a convenience."},{"slug":"index","title":"Tesseron","description":"Expose typed web-app actions to MCP-compatible AI agents over WebSocket. No browser automation, no scraping.","section":"","related":["overview/quickstart","overview/why","overview/architecture"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Diagram from '../../components/Diagram.astro';\n\n<Diagram\n caption=\"Your web app declares actions. The MCP gateway bridges them to any MCP-capable agent (Claude Code, Cursor, Claude Desktop).\"\n nodeWidth={130}\n spacing={140}\n pad={42}\n nodes={[\n { id: 'user', label: 'USER', sub: ['human at', 'the keyboard'], icon: 'user' },\n { id: 'app', label: 'YOUR APP', sub: 'browser or Node', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'WS client + MCP', icon: 'bridge', variant: 'accent' },\n { id: 'agent', label: 'AGENT', sub: ['Claude Code,', 'Cursor, Desktop'], icon: 'agent' },\n ]}\n edges={[\n { from: 'user', to: 'app' },\n // Arrow direction controls ELK's layer assignment — `app → gw → agent`\n // forms a linear chain so the four cards sit in one horizontal row.\n // The bidirectional flag keeps the visual meaning (both protocols flow\n // both ways) regardless of source/target.\n { from: 'app', to: 'gw', label: 'WebSocket', bidirectional: true, accent: true },\n { from: 'gw', to: 'agent', label: 'MCP stdio', bidirectional: true, accent: true },\n ]}\n/>\n\n## What you get\n\n<CardGrid>\n <Card title=\"Typed actions\" icon=\"seti:typescript\">\n Declare actions with a fluent builder backed by any [Standard Schema](https://standardschema.dev) validator - Zod, Valibot, ArkType, Effect Schema. The MCP tool schema is derived automatically.\n </Card>\n <Card title=\"Real UI, not a shadow DOM\" icon=\"open-book\">\n The agent drives your actual running app. State, auth, feature flags - all intact. Nothing to scrape, nothing to re-implement.\n </Card>\n <Card title=\"Full MCP capability set\" icon=\"rocket\">\n Streaming progress, cancellation, resources (read + subscribe), sampling, and elicitation work out of the box over a single WebSocket.\n </Card>\n <Card title=\"Framework-agnostic\" icon=\"puzzle\">\n One-file integrations for vanilla TS, React, Svelte, Vue, Node, and Express. Same builder API everywhere.\n </Card>\n</CardGrid>\n\n## Read the docs in two halves\n\n<CardGrid>\n <LinkCard\n title=\"Protocol\"\n href=\"./protocol/\"\n description=\"The wire format, handshake, action model, and advanced MCP features - with a diagram for every flow.\"\n />\n <LinkCard\n title=\"SDK\"\n href=\"./sdk/\"\n description=\"Build with @tesseron/web, /server, /react, or port Tesseron to a new language.\"\n />\n</CardGrid>\n\n## 60-second taste\n\n```ts title=\"src/main.ts\"\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\n\n// 1. A plain action - input, handler, streaming progress, return value.\ntesseron\n .action('searchProducts')\n .describe('Search the product catalog')\n .input(z.object({ query: z.string().min(1), limit: z.number().default(10) }))\n .handler(async ({ query, limit }, ctx) => {\n ctx.progress({ message: 'searching...', percent: 20 });\n const items = await store.search(query, { limit });\n return { items }; // becomes the MCP tool result the agent sees\n });\n\n// 2. An action that pauses to ask the user through the agent's UI.\ntesseron\n .action('checkout')\n .describe('Place the pending order')\n .input(z.object({ cartId: z.string() }))\n .handler(async ({ cartId }, ctx) => {\n const ok = await ctx.confirm({\n question: `Place order for $${cart.total(cartId)}? This charges your card.`,\n });\n if (!ok) throw new Error('User cancelled');\n return await orders.place(cartId);\n });\n\n// 3. A resource - readable, subscribable app state. No polling needed.\ntesseron\n .resource('currentRoute')\n .describe('URL the user is viewing')\n .read(() => location.pathname)\n .subscribe((emit) => {\n const fn = () => emit(location.pathname);\n addEventListener('popstate', fn);\n return () => removeEventListener('popstate', fn);\n });\n\n// 4. Connect. `connect()` resolves with the claim code - surface it\n// in your UI so the human can paste it into their agent.\nconst { claimCode } = await tesseron.connect();\ndocument.querySelector('#connect-banner')!.textContent =\n `Paste \"${claimCode}\" into Claude to connect this tab.`;\n```\n\n**What the agent sees once connected:**\n\n- Two MCP tools: `shop__searchProducts` and `shop__checkout`. It can call either, pass typed input, and receive your typed output.\n- One resource: `tesseron://shop/currentRoute`. It can read once, or subscribe and get pushed updates every time the user navigates - no polling, no webhooks.\n\n**What you didn't have to do:**\n\n- No HTTP server. The WebSocket goes to the gateway that runs next to the agent.\n- No OpenAPI spec, no tool schemas. They're derived from your Zod validators.\n- No glue between tools. The agent reads `searchProducts`'s output, picks a product, calls `checkout` with it, and pauses on `ctx.confirm` until the user approves - all orchestrated by the agent loop.\n\nThat's the whole surface: `.action()`, `.resource()`, and `.connect()`. Everything else is detail.\n\n**The other half runs next to the agent.** The gateway is `@tesseron/mcp` - an MCP server that opens the WebSocket port, hands out claim codes, and translates MCP tool calls into `actions/invoke` frames on your app's socket. You don't write MCP code; the gateway *is* the MCP server.\n\nYou wire it into your agent's MCP config once. Claude Desktop example (`claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@tesseron/mcp\"]\n }\n }\n}\n```\n\nClaude Code / Cursor / any MCP-capable client: same pattern, their own config file.\n","bodyText":"## What you get\n\n## Read the docs in two halves\n\n## 60-second taste\n\n```ts title=\"src/main.ts\"\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\n\n// 1. A plain action - input, handler, streaming progress, return value.\ntesseron\n .action('searchProducts')\n .describe('Search the product catalog')\n .input(z.object({ query: z.string().min(1), limit: z.number().default(10) }))\n .handler(async ({ query, limit }, ctx) => {\n ctx.progress({ message: 'searching...', percent: 20 });\n const items = await store.search(query, { limit });\n return { items }; // becomes the MCP tool result the agent sees\n });\n\n// 2. An action that pauses to ask the user through the agent's UI.\ntesseron\n .action('checkout')\n .describe('Place the pending order')\n .input(z.object({ cartId: z.string() }))\n .handler(async ({ cartId }, ctx) => {\n const ok = await ctx.confirm({\n question: `Place order for $${cart.total(cartId)}? This charges your card.`,\n });\n if (!ok) throw new Error('User cancelled');\n return await orders.place(cartId);\n });\n\n// 3. A resource - readable, subscribable app state. No polling needed.\ntesseron\n .resource('currentRoute')\n .describe('URL the user is viewing')\n .read(() => location.pathname)\n .subscribe((emit) => {\n const fn = () => emit(location.pathname);\n addEventListener('popstate', fn);\n return () => removeEventListener('popstate', fn);\n });\n\n// 4. Connect. `connect()` resolves with the claim code - surface it\n// in your UI so the human can paste it into their agent.\nconst { claimCode } = await tesseron.connect();\ndocument.querySelector('#connect-banner')!.textContent =\n `Paste \"${claimCode}\" into Claude to connect this tab.`;\n```\n\n**What the agent sees once connected:**\n\n- Two MCP tools: `shop__searchProducts` and `shop__checkout`. It can call either, pass typed input, and receive your typed output.\n- One resource: `tesseron://shop/currentRoute`. It can read once, or subscribe and get pushed updates every time the user navigates - no polling, no webhooks.\n\n**What you didn't have to do:**\n\n- No HTTP server. The WebSocket goes to the gateway that runs next to the agent.\n- No OpenAPI spec, no tool schemas. They're derived from your Zod validators.\n- No glue between tools. The agent reads `searchProducts`'s output, picks a product, calls `checkout` with it, and pauses on `ctx.confirm` until the user approves - all orchestrated by the agent loop.\n\nThat's the whole surface: `.action()`, `.resource()`, and `.connect()`. Everything else is detail.\n\n**The other half runs next to the agent.** The gateway is `@tesseron/mcp` - an MCP server that opens the WebSocket port, hands out claim codes, and translates MCP tool calls into `actions/invoke` frames on your app's socket. You don't write MCP code; the gateway *is* the MCP server.\n\nYou wire it into your agent's MCP config once. Claude Desktop example (`claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@tesseron/mcp\"]\n }\n }\n}\n```\n\nClaude Code / Cursor / any MCP-capable client: same pattern, their own config file."},{"slug":"overview/architecture","title":"Architecture at a glance","description":"The three moving parts - your app, the MCP gateway, the agent - and how a single action flows between them.","section":"overview","related":["overview/quickstart","protocol/handshake","protocol/actions","sdk/typescript/mcp"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\n<Diagram\n caption=\"Three processes, two protocols. Your app hosts a WebSocket endpoint and announces itself; the gateway dials in and speaks MCP stdio to the agent.\"\n nodeWidth={130}\n spacing={140}\n pad={42}\n nodes={[\n { id: 'user', label: 'USER', sub: ['human at', 'the keyboard'], icon: 'user' },\n { id: 'app', label: 'YOUR APP', sub: ['WS server +', 'tab file'], icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: ['WS client', '+ MCP'], icon: 'bridge', variant: 'accent' },\n { id: 'agent', label: 'AGENT', sub: ['Claude Code,', 'Cursor, Desktop'], icon: 'agent' },\n ]}\n edges={[\n { from: 'user', to: 'app' },\n // Arrow direction controls ELK's layer assignment — `app → gw → agent`\n // forms a linear chain so the four cards sit in one horizontal row.\n // The bidirectional flag keeps the visual meaning (both protocols flow\n // both ways) regardless of source/target. In reality the gateway is\n // the WS client dialling into the app; only the layout is reversed.\n { from: 'app', to: 'gw', label: 'WebSocket', bidirectional: true, accent: true },\n { from: 'gw', to: 'agent', label: 'MCP stdio', bidirectional: true, accent: true },\n ]}\n/>\n\n## Three processes\n\n- **Your app** - browser tab, React / Svelte / Vue / vanilla-TS, or a Node / desktop process. Hosts the action handlers and the real state they mutate. Also hosts a local endpoint the gateway dials into:\n - Browser apps get that endpoint for free by adding the `@tesseron/vite` plugin (or the equivalent for your dev server). The binding is WebSocket.\n - Node apps get it via `@tesseron/server`, which by default binds a loopback WebSocket; pass `{ transport: 'uds' }` for a Unix domain socket on Linux/macOS.\n - Anything else (Electron main, .NET, Python, Go, Rust, ...) can follow the same pattern: bind whichever [transport binding](/protocol/transport/) makes sense, drop an instance manifest at `~/.tesseron/instances/<instanceId>.json`, speak the protocol.\n- **The MCP gateway** - a small Node process (`@tesseron/mcp`) bundled into the Claude Code plugin. Runs on stdio for the agent. Watches `~/.tesseron/instances/` and dials each app it finds via the binding the manifest advertises. The gateway never binds a port of its own.\n- **The agent** - Claude Code, Claude Desktop, Cursor, or any other MCP client. Doesn't know or care about transport bindings - it only sees standard MCP tools.\n\n## Two protocols\n\n| Hop | Protocol | Transport |\n|---|---|---|\n| app ↔ gateway | Tesseron JSON-RPC 2.0 (custom) | WebSocket or UDS (per binding) |\n| gateway ↔ agent | Model Context Protocol | stdio |\n\nThe gateway is the only place that knows both dialects. Everything else is clean on each side: your app speaks one flavour of JSON-RPC, the agent speaks MCP.\n\n## What travels on each hop\n\n**App → Gateway** (you send these):\n- `tesseron/hello` - register app, actions, resources, capabilities.\n- `actions/invoke` response - the return value of an invoked action.\n- `actions/progress` - streaming progress updates.\n- `resources/updated` - push notifications for subscribed resources.\n- `sampling/request`, `elicitation/request` - ask the agent or user something mid-handler.\n\n**Gateway → App** (you handle these):\n- `actions/invoke` - the agent called one of your actions.\n- `actions/cancel` - the agent cancelled a running invocation.\n- `resources/read`, `resources/subscribe`, `resources/unsubscribe` - resource I/O.\n\n**Gateway → Agent** (abstracted - the SDK takes care of MCP framing):\n- `tools/list` with entries named `<app_id>__<action_name>`.\n- `tools/call` results, streamed via `notifications/progress` where available.\n- `resources/list`, `resources/read`, `resources/subscribe`.\n\n## Why an MCP gateway?\n\nBecause MCP doesn't run over WebSocket, and JSON-RPC-over-stdio doesn't work from a browser tab. The gateway reconciles the two, plus:\n\n- **Session claiming.** A 6-character code (`AB3X-7K`) that the user pastes into the agent binds one tab to one agent session. Keeps strangers out.\n- **Local-only.** Apps bind to loopback (TCP `127.0.0.1`) or a private Unix socket; the gateway never binds a port. Nothing leaks off the machine.\n- **Multi-app fan-in.** You can run several apps at once; tools are namespaced by `app.id` so `shop__addItem` and `admin__banUser` never collide.\n\n## Discovery, not binding\n\nThere is exactly one discovery mechanism and it works the same for every runtime:\n\n1. Your app binds an endpoint locally - WebSocket on loopback, or a Unix domain socket. Whichever [binding](/protocol/transport/) fits the runtime.\n2. It writes `~/.tesseron/instances/<instanceId>.json` with a `{ kind, url | path }` spec.\n3. The gateway (watching that directory) picks up the file and dials the advertised endpoint via the matching dialer.\n4. Once connected, the app sends `tesseron/hello` and the normal protocol takes over.\n\nNo fixed ports. No environment variables. No \"which gateway do I connect to\". Just bind, announce, serve. If you want to port Tesseron to a new language, that's the entire runtime contract - everything else is libraries of your language's choosing.\n\nNext: the [5-minute quickstart](/overview/quickstart/) walks through getting this running end-to-end.\n","bodyText":"## Three processes\n\n- **Your app** - browser tab, React / Svelte / Vue / vanilla-TS, or a Node / desktop process. Hosts the action handlers and the real state they mutate. Also hosts a local endpoint the gateway dials into:\n - Browser apps get that endpoint for free by adding the `@tesseron/vite` plugin (or the equivalent for your dev server). The binding is WebSocket.\n - Node apps get it via `@tesseron/server`, which by default binds a loopback WebSocket; pass `{ transport: 'uds' }` for a Unix domain socket on Linux/macOS.\n - Anything else (Electron main, .NET, Python, Go, Rust, ...) can follow the same pattern: bind whichever [transport binding](/protocol/transport/) makes sense, drop an instance manifest at `~/.tesseron/instances/<instanceId>.json`, speak the protocol.\n- **The MCP gateway** - a small Node process (`@tesseron/mcp`) bundled into the Claude Code plugin. Runs on stdio for the agent. Watches `~/.tesseron/instances/` and dials each app it finds via the binding the manifest advertises. The gateway never binds a port of its own.\n- **The agent** - Claude Code, Claude Desktop, Cursor, or any other MCP client. Doesn't know or care about transport bindings - it only sees standard MCP tools.\n\n## Two protocols\n\n| Hop | Protocol | Transport |\n|---|---|---|\n| app ↔ gateway | Tesseron JSON-RPC 2.0 (custom) | WebSocket or UDS (per binding) |\n| gateway ↔ agent | Model Context Protocol | stdio |\n\nThe gateway is the only place that knows both dialects. Everything else is clean on each side: your app speaks one flavour of JSON-RPC, the agent speaks MCP.\n\n## What travels on each hop\n\n**App → Gateway** (you send these):\n- `tesseron/hello` - register app, actions, resources, capabilities.\n- `actions/invoke` response - the return value of an invoked action.\n- `actions/progress` - streaming progress updates.\n- `resources/updated` - push notifications for subscribed resources.\n- `sampling/request`, `elicitation/request` - ask the agent or user something mid-handler.\n\n**Gateway → App** (you handle these):\n- `actions/invoke` - the agent called one of your actions.\n- `actions/cancel` - the agent cancelled a running invocation.\n- `resources/read`, `resources/subscribe`, `resources/unsubscribe` - resource I/O.\n\n**Gateway → Agent** (abstracted - the SDK takes care of MCP framing):\n- `tools/list` with entries named `<app_id>__<action_name>`.\n- `tools/call` results, streamed via `notifications/progress` where available.\n- `resources/list`, `resources/read`, `resources/subscribe`.\n\n## Why an MCP gateway?\n\nBecause MCP doesn't run over WebSocket, and JSON-RPC-over-stdio doesn't work from a browser tab. The gateway reconciles the two, plus:\n\n- **Session claiming.** A 6-character code (`AB3X-7K`) that the user pastes into the agent binds one tab to one agent session. Keeps strangers out.\n- **Local-only.** Apps bind to loopback (TCP `127.0.0.1`) or a private Unix socket; the gateway never binds a port. Nothing leaks off the machine.\n- **Multi-app fan-in.** You can run several apps at once; tools are namespaced by `app.id` so `shop__addItem` and `admin__banUser` never collide.\n\n## Discovery, not binding\n\nThere is exactly one discovery mechanism and it works the same for every runtime:\n\n1. Your app binds an endpoint locally - WebSocket on loopback, or a Unix domain socket. Whichever [binding](/protocol/transport/) fits the runtime.\n2. It writes `~/.tesseron/instances/<instanceId>.json` with a `{ kind, url | path }` spec.\n3. The gateway (watching that directory) picks up the file and dials the advertised endpoint via the matching dialer.\n4. Once connected, the app sends `tesseron/hello` and the normal protocol takes over.\n\nNo fixed ports. No environment variables. No \"which gateway do I connect to\". Just bind, announce, serve. If you want to port Tesseron to a new language, that's the entire runtime contract - everything else is libraries of your language's choosing.\n\nNext: the [5-minute quickstart](/overview/quickstart/) walks through getting this running end-to-end."},{"slug":"overview/quickstart","title":"Quickstart (5 minutes)","description":"Install the plugin, drop the SDK into an app, declare one action, watch Claude call it.","section":"overview","related":["sdk/typescript/index","sdk/typescript/action-builder","overview/architecture","examples/index"],"bodyRaw":"\nimport { Steps, Tabs, TabItem } from '@astrojs/starlight/components';\n\n**Prereqs.** Node ≥ 20. Claude Code installed.\n\n<Steps>\n\n1. **Install the Claude Code plugin.** It bundles the MCP gateway and auto-registers it as an MCP server.\n\n ```text\n /plugin marketplace add BrainBlend-AI/tesseron\n /plugin install tesseron@tesseron\n ```\n\n Restart Claude Code after installation. The gateway now runs whenever the plugin is enabled; no separate process to manage.\n\n2. **Add the SDK to your app.**\n\n <Tabs>\n <TabItem label=\"Vite (Vue / Svelte / vanilla)\">\n ```bash\n pnpm add @tesseron/web zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Vue\">\n ```bash\n pnpm add @tesseron/vue zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Svelte\">\n ```bash\n pnpm add @tesseron/svelte zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"React\">\n ```bash\n pnpm add @tesseron/react zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Node / server\">\n ```bash\n pnpm add @tesseron/server zod\n ```\n </TabItem>\n </Tabs>\n\n3. **Register the Vite plugin** (browser apps only). It exposes `/@tesseron/ws` on the Vite dev server and writes per-tab discovery files the gateway watches.\n\n ```ts title=\"vite.config.ts\"\n import { defineConfig } from 'vite';\n import { tesseron } from '@tesseron/vite';\n\n export default defineConfig({\n plugins: [/* your framework plugin, e.g. vue() or svelte() */, tesseron()],\n });\n ```\n\n Node apps skip this step - `@tesseron/server` binds and announces automatically.\n\n4. **Declare an app and one action.**\n\n ```ts title=\"src/main.ts\"\n import { tesseron } from '@tesseron/web';\n import { z } from 'zod';\n\n tesseron.app({ id: 'notes', name: 'My Notes App' });\n\n tesseron\n .action('createNote')\n .describe('Create a new note with a title and body')\n .input(z.object({\n title: z.string().min(1),\n body: z.string().default(''),\n }))\n .handler(({ title, body }) => {\n const note = { id: crypto.randomUUID(), title, body, createdAt: Date.now() };\n store.add(note); // whatever \"add\" means in your app\n return note;\n });\n\n await tesseron.connect();\n ```\n\n In the browser, `tesseron.connect()` opens a WebSocket to the Vite plugin at `<location.origin>/@tesseron/ws`, which bridges it to the gateway. In Node, `@tesseron/server` binds a loopback WS server (or a Unix domain socket if you pass `{ transport: 'uds' }`) and announces itself via `~/.tesseron/instances/`; the gateway dials in. Either way, `connect()` resolves with a `welcome` that carries the `claimCode`.\n\n5. **Claim the session from Claude.** Open your app - the gateway prints a 6-character claim code to its stderr (and you can surface it in your UI too). Tell Claude:\n\n > \"Claim Tesseron session AB3X-7K\"\n\n Claude calls the built-in `tesseron__claim_session` tool, the gateway marks the session claimed, and a `notifications/tools/list_changed` event fires.\n\n6. **Call your action.** The tool list now contains `notes__createNote`. Ask Claude:\n\n > \"Create a note titled 'Groceries' with body 'eggs, milk, bread'.\"\n\n The handler runs inside your tab. The new note appears in your UI, reactively. Claude sees the returned object as the tool result.\n\n</Steps>\n\n## Next steps\n\n- [Add progress + cancellation](/protocol/progress-cancellation/) - for actions that take more than a beat.\n- [Expose resources](/protocol/resources/) - let Claude read your UI state (current route, selected item, filter settings).\n- [Use sampling](/protocol/sampling/) - let your handler ask the agent's LLM mid-execution.\n- [Pick your framework adapter](/sdk/) - React, Svelte, Vue, Express patterns.\n","bodyText":"**Prereqs.** Node ≥ 20. Claude Code installed.\n\n## Next steps\n\n- [Add progress + cancellation](/protocol/progress-cancellation/) - for actions that take more than a beat.\n- [Expose resources](/protocol/resources/) - let Claude read your UI state (current route, selected item, filter settings).\n- [Use sampling](/protocol/sampling/) - let your handler ask the agent's LLM mid-execution.\n- [Pick your framework adapter](/sdk/) - React, Svelte, Vue, Express patterns."},{"slug":"overview/why","title":"Why Tesseron?","description":"The problem Tesseron solves, and where it fits relative to browser automation, chat widgets, and custom APIs.","section":"overview","related":["overview/architecture","protocol/index"],"bodyRaw":"\nAgents are great at reasoning about what to do. They're bad at reaching into your app to do it.\n\nThere are three common ways to close that gap. Tesseron is a fourth.\n\n## 1. Browser automation (Playwright, Selenium, Computer Use)\n\nThe agent drives a pixel-level browser. Conceptually powerful, practically fragile: every layout tweak breaks selectors, every modal needs bespoke handling, every authentication flow is re-solved from scratch. Token-heavy. Slow.\n\n## 2. Chat widget embedded in the app\n\nYou bolt an AI sidebar into your UI and wire up tool calls manually. The agent can talk to your backend, but it can't touch the running UI state the user is looking at. Two worlds that never meet.\n\n## 3. A bespoke MCP server for your backend\n\nGreat for headless automation. Useless for \"put this in the user's cart on the page they're already viewing.\" The user's session, their open tab, their in-memory draft - all invisible to a backend MCP server.\n\n## 4. Tesseron\n\nThe running app opens a WebSocket to a local MCP gateway and declares its actions:\n\n```ts\ntesseron.action('addToCart').input(...).handler(...);\n```\n\nThe gateway exposes those actions as MCP tools over stdio. Any MCP-capable agent - Claude Code, Cursor, Claude Desktop, any other - sees them and calls them. The handler runs inside the user's real tab, with their real state, their real auth.\n\n## Tradeoffs (be honest)\n\n- **Localhost by default.** Tesseron is a local-first developer tool. Apps bind to `127.0.0.1`; the gateway only dials loopback URLs. Nothing leaks off the machine.\n- **Requires the tab to be open.** If the page is closed, the session is gone. This is a feature - it keeps the agent bound to what the user can see.\n- **Not a replacement for a headless API.** If you need scheduled or unattended automation, you want a server-side MCP. Tesseron complements it - it doesn't replace it.\n\n## When Tesseron is the right fit\n\n- Internal tools where power users want to drive the UI via chat.\n- Complex workflows that already exist as UI actions - search, filter, create, approve - and shouldn't be duplicated on the backend.\n- Product demos and prototypes where \"the agent actually does what the user sees\" is the whole point.\n- Personal dashboards, admin panels, CMS editors, developer tooling.\n\nIf you're shipping one of those, keep reading.\n","bodyText":"Agents are great at reasoning about what to do. They're bad at reaching into your app to do it.\n\nThere are three common ways to close that gap. Tesseron is a fourth.\n\n## 1. Browser automation (Playwright, Selenium, Computer Use)\n\nThe agent drives a pixel-level browser. Conceptually powerful, practically fragile: every layout tweak breaks selectors, every modal needs bespoke handling, every authentication flow is re-solved from scratch. Token-heavy. Slow.\n\n## 2. Chat widget embedded in the app\n\nYou bolt an AI sidebar into your UI and wire up tool calls manually. The agent can talk to your backend, but it can't touch the running UI state the user is looking at. Two worlds that never meet.\n\n## 3. A bespoke MCP server for your backend\n\nGreat for headless automation. Useless for \"put this in the user's cart on the page they're already viewing.\" The user's session, their open tab, their in-memory draft - all invisible to a backend MCP server.\n\n## 4. Tesseron\n\nThe running app opens a WebSocket to a local MCP gateway and declares its actions:\n\n```ts\ntesseron.action('addToCart').input(...).handler(...);\n```\n\nThe gateway exposes those actions as MCP tools over stdio. Any MCP-capable agent - Claude Code, Cursor, Claude Desktop, any other - sees them and calls them. The handler runs inside the user's real tab, with their real state, their real auth.\n\n## Tradeoffs (be honest)\n\n- **Localhost by default.** Tesseron is a local-first developer tool. Apps bind to `127.0.0.1`; the gateway only dials loopback URLs. Nothing leaks off the machine.\n- **Requires the tab to be open.** If the page is closed, the session is gone. This is a feature - it keeps the agent bound to what the user can see.\n- **Not a replacement for a headless API.** If you need scheduled or unattended automation, you want a server-side MCP. Tesseron complements it - it doesn't replace it.\n\n## When Tesseron is the right fit\n\n- Internal tools where power users want to drive the UI via chat.\n- Complex workflows that already exist as UI actions - search, filter, create, approve - and shouldn't be duplicated on the backend.\n- Product demos and prototypes where \"the agent actually does what the user sees\" is the whole point.\n- Personal dashboards, admin panels, CMS editors, developer tooling.\n\nIf you're shipping one of those, keep reading."},{"slug":"protocol/actions","title":"Action model","description":"How actions are declared, namespaced, invoked, validated, and returned.","section":"protocol","related":["sdk/typescript/action-builder","protocol/wire-format","protocol/elicitation","protocol/sampling","protocol/progress-cancellation"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nAn **action** is a named, typed, handler-backed operation that the app exposes to the agent. On the MCP side it looks like a single tool. On the Tesseron side it has a schema, a handler, and a set of per-invocation context helpers.\n\n<Sequence\n caption=\"One invocation from tools/call to tool result - with input validation between.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: \"tools/call { name: 'shop__addItem', arguments }\" },\n { from: 'g', to: 's', label: 'actions/invoke { name, invocationId, input }' },\n { note: 's', label: 'validate input (Standard Schema)' },\n { note: 's', label: 'run handler(input, ctx)' },\n { from: 's', to: 'g', label: \"result { id: 'item_42', ... }\", style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## Declaration\n\n```ts\ntesseron\n .action('addItem') // action name\n .describe('Add an item to the cart')\n .input(z.object({ sku: z.string(), quantity: z.number().int().positive() }))\n .output(z.object({ cartId: z.string(), itemId: z.string() }))\n .annotate({ destructive: false })\n .timeout({ ms: 10_000 })\n .handler(async ({ sku, quantity }, ctx) => {\n const item = await cart.add(sku, quantity);\n return { cartId: cart.id, itemId: item.id };\n });\n```\n\nBuilder steps:\n\n| Step | Purpose | Required? |\n|---|---|---|\n| `.describe(string)` | Human-readable description shown to the agent's LLM. | Recommended |\n| `.input(schema)` | Standard Schema validator for arguments. Becomes JSON Schema on the wire. | Recommended |\n| `.output(schema)` | Validator for the return value. Not enforced by default. | Optional |\n| `.annotate({…})` | Metadata: `readOnly`, `destructive`, `requiresConfirmation`. | Optional |\n| `.timeout(ms)` | Abort the invocation after N ms. Default 60 000. | Optional |\n| `.strictOutput()` | Enforce the output schema. Default is passthrough. | Optional |\n| `.handler(fn)` | The function that runs. Receives `(input, ctx)`. Terminates the builder. | Required |\n\n## Naming and the MCP tool list\n\nThe MCP gateway registers every action as an MCP tool under the name `<app.id>__<action.name>`. For `app.id = \"shop\"` and `action = \"searchProducts\"` the agent sees `shop__searchProducts`. Multiple apps can coexist - see [multi-app coexistence](/protocol/security/#multi-app-coexistence).\n\n## Invocation wire format\n\nRequest from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"method\": \"actions/invoke\",\n \"params\": {\n \"name\": \"addItem\",\n \"invocationId\": \"inv_abc123\",\n \"input\": { \"sku\": \"SKU-1\", \"quantity\": 2 },\n \"client\": { \"route\": \"/cart\" }\n }\n}\n```\n\nThe SDK turns `params` into an `ActionContext` and calls your handler. `ctx.agent`, `ctx.agentCapabilities`, and the rest of `ctx.client` come from the `welcome` the SDK cached at handshake time - they don't ride on every `actions/invoke`. Response:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"result\": { \"cartId\": \"c_1\", \"itemId\": \"i_42\" }\n}\n```\n\nOr, on error:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"error\": { \"code\": -32005, \"message\": \"Cart is locked\", \"data\": { \"cartId\": \"c_1\" } }\n}\n```\n\n## Validation passes\n\n- **Input** is validated **before** the handler runs. Failure → error code `-32004 InputValidation`, handler never fires. Validation `issues` are returned in `error.data`.\n- **Output** is **not** validated by default. Call `.strictOutput()` to enforce - failure becomes `-32005 HandlerError` with issues in `data`. The permissive default is deliberate: output schemas are often loose, and most teams use `.output()` purely for documentation.\n\n## Annotations\n\n| Field | Meaning |\n|---|---|\n| `readOnly` | The action doesn't mutate state. The agent may parallelise or cache. |\n| `destructive` | The action mutates persistent state. Agents SHOULD surface a confirmation UI. |\n| `requiresConfirmation` | The action MUST NOT be called without explicit user confirmation. |\n\nAnnotations are **advisory**. They ride along with the MCP tool descriptor; honouring them is the agent's job.\n\n## What the handler receives\n\n```ts\nhandler: (input: I, ctx: ActionContext) => O | Promise<O>\n```\n\n`ctx` is the per-invocation context. Full reference in [SDK → Context](/sdk/typescript/context/):\n\n| Field | Purpose |\n|---|---|\n| `ctx.signal` | `AbortSignal` fired on timeout or cancel. |\n| `ctx.agent` | `{ id, name }` of the calling agent. |\n| `ctx.agentCapabilities` | What the agent can do. Gate your sampling / elicit calls on this. |\n| `ctx.client` | `{ origin, route?, userAgent? }`. |\n| `ctx.progress(update)` | Emit an `actions/progress` notification. |\n| `ctx.sample(req)` | Re-enter the agent LLM for a reasoning step. |\n| `ctx.confirm(req)` | Ask the user a yes/no question. Returns `false` when the client can't prompt. |\n| `ctx.elicit(req)` | Ask the user for structured content matching a schema. |\n| `ctx.log({ level, message, meta? })` | Structured log forwarded to MCP logging. |\n\nNext: [progress & cancellation](/protocol/progress-cancellation/).\n","bodyText":"An **action** is a named, typed, handler-backed operation that the app exposes to the agent. On the MCP side it looks like a single tool. On the Tesseron side it has a schema, a handler, and a set of per-invocation context helpers.\n\n## Declaration\n\n```ts\ntesseron\n .action('addItem') // action name\n .describe('Add an item to the cart')\n .input(z.object({ sku: z.string(), quantity: z.number().int().positive() }))\n .output(z.object({ cartId: z.string(), itemId: z.string() }))\n .annotate({ destructive: false })\n .timeout({ ms: 10_000 })\n .handler(async ({ sku, quantity }, ctx) => {\n const item = await cart.add(sku, quantity);\n return { cartId: cart.id, itemId: item.id };\n });\n```\n\nBuilder steps:\n\n| Step | Purpose | Required? |\n|---|---|---|\n| `.describe(string)` | Human-readable description shown to the agent's LLM. | Recommended |\n| `.input(schema)` | Standard Schema validator for arguments. Becomes JSON Schema on the wire. | Recommended |\n| `.output(schema)` | Validator for the return value. Not enforced by default. | Optional |\n| `.annotate({…})` | Metadata: `readOnly`, `destructive`, `requiresConfirmation`. | Optional |\n| `.timeout(ms)` | Abort the invocation after N ms. Default 60 000. | Optional |\n| `.strictOutput()` | Enforce the output schema. Default is passthrough. | Optional |\n| `.handler(fn)` | The function that runs. Receives `(input, ctx)`. Terminates the builder. | Required |\n\n## Naming and the MCP tool list\n\nThe MCP gateway registers every action as an MCP tool under the name `<app.id>__<action.name>`. For `app.id = \"shop\"` and `action = \"searchProducts\"` the agent sees `shop__searchProducts`. Multiple apps can coexist - see [multi-app coexistence](/protocol/security/#multi-app-coexistence).\n\n## Invocation wire format\n\nRequest from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"method\": \"actions/invoke\",\n \"params\": {\n \"name\": \"addItem\",\n \"invocationId\": \"inv_abc123\",\n \"input\": { \"sku\": \"SKU-1\", \"quantity\": 2 },\n \"client\": { \"route\": \"/cart\" }\n }\n}\n```\n\nThe SDK turns `params` into an `ActionContext` and calls your handler. `ctx.agent`, `ctx.agentCapabilities`, and the rest of `ctx.client` come from the `welcome` the SDK cached at handshake time - they don't ride on every `actions/invoke`. Response:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"result\": { \"cartId\": \"c_1\", \"itemId\": \"i_42\" }\n}\n```\n\nOr, on error:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"error\": { \"code\": -32005, \"message\": \"Cart is locked\", \"data\": { \"cartId\": \"c_1\" } }\n}\n```\n\n## Validation passes\n\n- **Input** is validated **before** the handler runs. Failure → error code `-32004 InputValidation`, handler never fires. Validation `issues` are returned in `error.data`.\n- **Output** is **not** validated by default. Call `.strictOutput()` to enforce - failure becomes `-32005 HandlerError` with issues in `data`. The permissive default is deliberate: output schemas are often loose, and most teams use `.output()` purely for documentation.\n\n## Annotations\n\n| Field | Meaning |\n|---|---|\n| `readOnly` | The action doesn't mutate state. The agent may parallelise or cache. |\n| `destructive` | The action mutates persistent state. Agents SHOULD surface a confirmation UI. |\n| `requiresConfirmation` | The action MUST NOT be called without explicit user confirmation. |\n\nAnnotations are **advisory**. They ride along with the MCP tool descriptor; honouring them is the agent's job.\n\n## What the handler receives\n\n```ts\nhandler: (input: I, ctx: ActionContext) => O | Promise<O>\n```\n\n`ctx` is the per-invocation context. Full reference in [SDK → Context](/sdk/typescript/context/):\n\n| Field | Purpose |\n|---|---|\n| `ctx.signal` | `AbortSignal` fired on timeout or cancel. |\n| `ctx.agent` | `{ id, name }` of the calling agent. |\n| `ctx.agentCapabilities` | What the agent can do. Gate your sampling / elicit calls on this. |\n| `ctx.client` | `{ origin, route?, userAgent? }`. |\n| `ctx.progress(update)` | Emit an `actions/progress` notification. |\n| `ctx.sample(req)` | Re-enter the agent LLM for a reasoning step. |\n| `ctx.confirm(req)` | Ask the user a yes/no question. Returns `false` when the client can't prompt. |\n| `ctx.elicit(req)` | Ask the user for structured content matching a schema. |\n| `ctx.log({ level, message, meta? })` | Structured log forwarded to MCP logging. |\n\nNext: [progress & cancellation](/protocol/progress-cancellation/)."},{"slug":"protocol/elicitation","title":"Elicitation","description":"Handlers pause to ask the user a question. Two verbs - ctx.confirm for yes/no, ctx.elicit for structured content.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\n**Elicitation** is sampling's human sibling. Instead of the LLM generating the next value, the user is prompted through the agent UI and submits the answer themselves.\n\nTesseron exposes two verbs on `ctx`, mapping onto MCP elicit's two orthogonal return fields (`action`, `content`):\n\n- **`ctx.confirm({ question })`** returns `Promise<boolean>`. For yes/no safety gates. No schema.\n- **`ctx.elicit({ question, schema, jsonSchema? })`** returns `Promise<T | null>`. For structured content.\n\nPick by intent: a destructive-op gate is a confirm; a \"which warehouse?\" is an elicit.\n\n## ctx.confirm - yes/no safety gates\n\n```ts\ntesseron.action('clearCompleted')\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (_input, ctx) => {\n const ok = await ctx.confirm({\n question: 'Remove 5 completed todos? This cannot be undone.',\n });\n if (!ok) return { removed: 0, cancelled: true };\n // ... proceed\n });\n```\n\nReturns `true` only on explicit accept. Decline, cancel, and absence of elicitation capability all collapse to `false` - the safe default for destructive ops. You don't need to guard with `ctx.agentCapabilities.elicitation`; `confirm` returns `false` when the client can't prompt.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties JSON Schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render a pure Accept/Decline prompt with no input field.\n\n## ctx.elicit - structured content\n\n```ts\nimport { z } from 'zod';\n\nconst warehouseSchema = z.object({ warehouseId: z.string() });\n\ntesseron.action('checkStock')\n .handler(async (_input, ctx) => {\n const answer = await ctx.elicit({\n question: 'Which warehouse should I check?',\n schema: warehouseSchema,\n jsonSchema: z.toJSONSchema(warehouseSchema),\n });\n if (answer === null) return { cancelled: true };\n return stock.lookup(answer.warehouseId);\n });\n```\n\nReturns the validated value on accept, `null` on decline or cancel. Throws `ElicitationNotAvailableError` (code `-32007`) when the client didn't advertise elicitation - structured data has no safe default, so the handler must branch explicitly.\n\n`jsonSchema` is technically optional; if you omit it, the SDK sends a permissive text-only fallback (`{ response: string }`), which Claude Code renders as a single text input. For good UX, always derive it from your validator - Zod 4 has `z.toJSONSchema(schema)` built in.\n\nMCP elicit constrains `requestedSchema`:\n\n- Top level must be `{ type: \"object\" }`.\n- Each property must be a primitive type (`string`, `number`, `integer`, `boolean`).\n- No `oneOf` / `anyOf` / `allOf` / `not` at the top level.\n\nThe SDK enforces this on send and surfaces an `InvalidParams` error (code `-32602`) at the `ctx.elicit` call site if you send something else.\n\n<Sequence\n caption=\"ctx.confirm and ctx.elicit share the same wire flow - the difference is the requestedSchema they send.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.confirm / ctx.elicit', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'u', label: 'USER', icon: 'user' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'elicitation/request { question, schema }', accent: true },\n { from: 'g', to: 'a', label: 'MCP elicitation/elicit', accent: true },\n { from: 'a', to: 'u', label: 'shows form or Accept/Decline', style: 'dashed' },\n { from: 'u', to: 'a', label: 'submits or declines', style: 'dashed' },\n { from: 'a', to: 'g', label: 'elicitation result', accent: true },\n { from: 'g', to: 's', label: '{ action, value? }', accent: true },\n ]}\n/>\n\n## Wire format\n\nRequest:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"method\": \"elicitation/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"question\": \"Which warehouse should I check?\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": { \"warehouseId\": { \"type\": \"string\" } },\n \"required\": [\"warehouseId\"]\n }\n }\n}\n```\n\nResponse (accept):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"result\": { \"action\": \"accept\", \"value\": { \"warehouseId\": \"WH-7\" } }\n}\n```\n\nResponse (decline / cancel):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 11, \"result\": { \"action\": \"decline\" } }\n```\n\nThe SDK maps `action: 'accept'` to the validated value, `decline` / `cancel` to `null` (for `ctx.elicit`) or `false` (for `ctx.confirm`).\n\n## Capability gate\n\n`ctx.agentCapabilities.elicitation` reflects what the connected MCP client advertised during `initialize`. Claude Code advertises elicitation; earlier clients may not.\n\n- `ctx.confirm` is safe in any handler: missing capability returns `false`, which destructive-op guards treat correctly.\n- `ctx.elicit` throws `ElicitationNotAvailableError` when capability is missing - catch it or pre-check the flag and provide a non-interactive fallback.\n\n## Design hints\n\n- **One question per call.** Don't pack a wizard into a schema - chain actions instead.\n- **Use annotations in tandem.** `{ destructive: true, requiresConfirmation: true }` tells the agent to warn upfront; `ctx.confirm` is what gates.\n- **Avoid chained elicitations** in one handler - latency accumulates. If you need multi-step input, build a dedicated action per step.\n\nNext: [resources](/protocol/resources/) - state the agent can read and subscribe to.\n","bodyText":"**Elicitation** is sampling's human sibling. Instead of the LLM generating the next value, the user is prompted through the agent UI and submits the answer themselves.\n\nTesseron exposes two verbs on `ctx`, mapping onto MCP elicit's two orthogonal return fields (`action`, `content`):\n\n- **`ctx.confirm({ question })`** returns `Promise<boolean>`. For yes/no safety gates. No schema.\n- **`ctx.elicit({ question, schema, jsonSchema? })`** returns `Promise<T | null>`. For structured content.\n\nPick by intent: a destructive-op gate is a confirm; a \"which warehouse?\" is an elicit.\n\n## ctx.confirm - yes/no safety gates\n\n```ts\ntesseron.action('clearCompleted')\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (_input, ctx) => {\n const ok = await ctx.confirm({\n question: 'Remove 5 completed todos? This cannot be undone.',\n });\n if (!ok) return { removed: 0, cancelled: true };\n // ... proceed\n });\n```\n\nReturns `true` only on explicit accept. Decline, cancel, and absence of elicitation capability all collapse to `false` - the safe default for destructive ops. You don't need to guard with `ctx.agentCapabilities.elicitation`; `confirm` returns `false` when the client can't prompt.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties JSON Schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render a pure Accept/Decline prompt with no input field.\n\n## ctx.elicit - structured content\n\n```ts\n\nconst warehouseSchema = z.object({ warehouseId: z.string() });\n\ntesseron.action('checkStock')\n .handler(async (_input, ctx) => {\n const answer = await ctx.elicit({\n question: 'Which warehouse should I check?',\n schema: warehouseSchema,\n jsonSchema: z.toJSONSchema(warehouseSchema),\n });\n if (answer === null) return { cancelled: true };\n return stock.lookup(answer.warehouseId);\n });\n```\n\nReturns the validated value on accept, `null` on decline or cancel. Throws `ElicitationNotAvailableError` (code `-32007`) when the client didn't advertise elicitation - structured data has no safe default, so the handler must branch explicitly.\n\n`jsonSchema` is technically optional; if you omit it, the SDK sends a permissive text-only fallback (`{ response: string }`), which Claude Code renders as a single text input. For good UX, always derive it from your validator - Zod 4 has `z.toJSONSchema(schema)` built in.\n\nMCP elicit constrains `requestedSchema`:\n\n- Top level must be `{ type: \"object\" }`.\n- Each property must be a primitive type (`string`, `number`, `integer`, `boolean`).\n- No `oneOf` / `anyOf` / `allOf` / `not` at the top level.\n\nThe SDK enforces this on send and surfaces an `InvalidParams` error (code `-32602`) at the `ctx.elicit` call site if you send something else.\n\n## Wire format\n\nRequest:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"method\": \"elicitation/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"question\": \"Which warehouse should I check?\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": { \"warehouseId\": { \"type\": \"string\" } },\n \"required\": [\"warehouseId\"]\n }\n }\n}\n```\n\nResponse (accept):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"result\": { \"action\": \"accept\", \"value\": { \"warehouseId\": \"WH-7\" } }\n}\n```\n\nResponse (decline / cancel):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 11, \"result\": { \"action\": \"decline\" } }\n```\n\nThe SDK maps `action: 'accept'` to the validated value, `decline` / `cancel` to `null` (for `ctx.elicit`) or `false` (for `ctx.confirm`).\n\n## Capability gate\n\n`ctx.agentCapabilities.elicitation` reflects what the connected MCP client advertised during `initialize`. Claude Code advertises elicitation; earlier clients may not.\n\n- `ctx.confirm` is safe in any handler: missing capability returns `false`, which destructive-op guards treat correctly.\n- `ctx.elicit` throws `ElicitationNotAvailableError` when capability is missing - catch it or pre-check the flag and provide a non-interactive fallback.\n\n## Design hints\n\n- **One question per call.** Don't pack a wizard into a schema - chain actions instead.\n- **Use annotations in tandem.** `{ destructive: true, requiresConfirmation: true }` tells the agent to warn upfront; `ctx.confirm` is what gates.\n- **Avoid chained elicitations** in one handler - latency accumulates. If you need multi-step input, build a dedicated action per step.\n\nNext: [resources](/protocol/resources/) - state the agent can read and subscribe to."},{"slug":"protocol/errors","title":"Errors & capabilities","description":"Every error code Tesseron defines, what raises each one, and how capability negotiation shapes handler behaviour.","section":"protocol","related":["protocol/wire-format","protocol/handshake"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nTesseron uses JSON-RPC error codes with a Tesseron-specific extension range.\n\n<Sequence\n caption=\"A validation error path. The handler never runs; the agent gets structured issues it can correct.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call arguments: { query: 42 }' },\n { from: 'g', to: 's', label: 'actions/invoke' },\n { note: 's', label: 'validate input (Standard Schema)', variant: 'danger' },\n { from: 's', to: 'g', label: 'error -32004 InputValidation data: [issues]', danger: true, style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call error (agent can retry with corrected args)', danger: true, style: 'dashed' },\n ]}\n/>\n\n## Error catalog\n\n| Code | Name | Raised when |\n|---:|---|---|\n| `-32700` | `ParseError` | JSON-RPC message failed to parse. Almost always a bug or a non-JSON frame. |\n| `-32600` | `InvalidRequest` | Envelope is well-formed JSON but not a valid JSON-RPC request. |\n| `-32601` | `MethodNotFound` | Method isn't registered. Tesseron's method surface is fixed - this is almost always a version mismatch. |\n| `-32602` | `InvalidParams` | Params don't match the method's expected shape. |\n| `-32603` | `InternalError` | Unhandled exception inside the SDK or gateway. Report it. |\n| `-32000` | `ProtocolMismatch` | `tesseron/hello` sent a `protocolVersion` the gateway doesn't accept. |\n| `-32001` | `Cancelled` | Invocation was cancelled by the agent. |\n| `-32002` | `Timeout` | Invocation exceeded its timeout. |\n| `-32003` | `ActionNotFound` | Agent called an action that isn't registered for this session. |\n| `-32004` | `InputValidation` | Input failed Standard Schema validation. Issues in `error.data`. |\n| `-32005` | `HandlerError` | Handler threw, or output failed strict validation. Message comes from the thrown error. |\n| `-32006` | `SamplingNotAvailable` | Handler called `ctx.sample` but agent didn't advertise sampling. |\n| `-32007` | `ElicitationNotAvailable` | Handler called `ctx.elicit` but agent didn't advertise elicitation. (`ctx.confirm` returns `false` instead of throwing — safe default for destructive gates.) |\n| `-32008` | `SamplingDepthExceeded` | Sampling chain exceeded `maxSamplingDepth` (3). |\n| `-32009` | `Unauthorized` | Wrong claim code, unclaimed session invoking action, or origin not allowlisted. |\n\nErrors carry an optional `data` field. Tesseron uses it to attach:\n\n- **For `-32004` InputValidation**: the `issues` array from Standard Schema.\n- **For `-32005` HandlerError** with strict output: the `issues` for the failed output check.\n- **For `-32008` SamplingDepthExceeded**: `{ depth, max }`.\n\n## Capability negotiation\n\nBoth sides declare capabilities during the handshake. The `welcome` response contains their intersection - that's what your handler should trust.\n\n| Capability | Meaning |\n|---|---|\n| `streaming` | `actions/progress` notifications are allowed. |\n| `subscriptions` | Agent will call `resources/subscribe`. |\n| `sampling` | `ctx.sample` is available. |\n| `elicitation` | `ctx.confirm` and `ctx.elicit` are available. |\n\nYour handler, in general:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n return fallbackResponse();\n}\nconst refined = await ctx.sample({ prompt, schema });\n```\n\nIf you'd rather error out than fall back, just call `ctx.sample` unconditionally - the SDK throws `SamplingNotAvailableError` (code `-32006`) which the agent sees as a structured tool failure.\n\n## Errors are data, not disasters\n\nAgents are good at recovering from structured errors. Prefer returning a well-typed error to throwing a vague one:\n\n- Bad input? Let Standard Schema reject it - the agent gets issues to correct.\n- Impossible state? `throw new Error(\"Cart is locked; ask the user to unlock it\")` surfaces as code `-32005` with a useful message.\n- Need clarification? Use [elicitation](/protocol/elicitation/) instead of failing.\n\nNext: [lifecycle & failure modes](/protocol/lifecycle/).\n","bodyText":"Tesseron uses JSON-RPC error codes with a Tesseron-specific extension range.\n\n## Error catalog\n\n| Code | Name | Raised when |\n|---:|---|---|\n| `-32700` | `ParseError` | JSON-RPC message failed to parse. Almost always a bug or a non-JSON frame. |\n| `-32600` | `InvalidRequest` | Envelope is well-formed JSON but not a valid JSON-RPC request. |\n| `-32601` | `MethodNotFound` | Method isn't registered. Tesseron's method surface is fixed - this is almost always a version mismatch. |\n| `-32602` | `InvalidParams` | Params don't match the method's expected shape. |\n| `-32603` | `InternalError` | Unhandled exception inside the SDK or gateway. Report it. |\n| `-32000` | `ProtocolMismatch` | `tesseron/hello` sent a `protocolVersion` the gateway doesn't accept. |\n| `-32001` | `Cancelled` | Invocation was cancelled by the agent. |\n| `-32002` | `Timeout` | Invocation exceeded its timeout. |\n| `-32003` | `ActionNotFound` | Agent called an action that isn't registered for this session. |\n| `-32004` | `InputValidation` | Input failed Standard Schema validation. Issues in `error.data`. |\n| `-32005` | `HandlerError` | Handler threw, or output failed strict validation. Message comes from the thrown error. |\n| `-32006` | `SamplingNotAvailable` | Handler called `ctx.sample` but agent didn't advertise sampling. |\n| `-32007` | `ElicitationNotAvailable` | Handler called `ctx.elicit` but agent didn't advertise elicitation. (`ctx.confirm` returns `false` instead of throwing — safe default for destructive gates.) |\n| `-32008` | `SamplingDepthExceeded` | Sampling chain exceeded `maxSamplingDepth` (3). |\n| `-32009` | `Unauthorized` | Wrong claim code, unclaimed session invoking action, or origin not allowlisted. |\n\nErrors carry an optional `data` field. Tesseron uses it to attach:\n\n- **For `-32004` InputValidation**: the `issues` array from Standard Schema.\n- **For `-32005` HandlerError** with strict output: the `issues` for the failed output check.\n- **For `-32008` SamplingDepthExceeded**: `{ depth, max }`.\n\n## Capability negotiation\n\nBoth sides declare capabilities during the handshake. The `welcome` response contains their intersection - that's what your handler should trust.\n\n| Capability | Meaning |\n|---|---|\n| `streaming` | `actions/progress` notifications are allowed. |\n| `subscriptions` | Agent will call `resources/subscribe`. |\n| `sampling` | `ctx.sample` is available. |\n| `elicitation` | `ctx.confirm` and `ctx.elicit` are available. |\n\nYour handler, in general:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n return fallbackResponse();\n}\nconst refined = await ctx.sample({ prompt, schema });\n```\n\nIf you'd rather error out than fall back, just call `ctx.sample` unconditionally - the SDK throws `SamplingNotAvailableError` (code `-32006`) which the agent sees as a structured tool failure.\n\n## Errors are data, not disasters\n\nAgents are good at recovering from structured errors. Prefer returning a well-typed error to throwing a vague one:\n\n- Bad input? Let Standard Schema reject it - the agent gets issues to correct.\n- Impossible state? `throw new Error(\"Cart is locked; ask the user to unlock it\")` surfaces as code `-32005` with a useful message.\n- Need clarification? Use [elicitation](/protocol/elicitation/) instead of failing.\n\nNext: [lifecycle & failure modes](/protocol/lifecycle/)."},{"slug":"protocol/handshake","title":"Handshake & claiming","description":"How a WebSocket becomes a bound session - tesseron/hello, welcome, claim code, and tools/list_changed.","section":"protocol","related":["protocol/wire-format","protocol/transport","protocol/security","protocol/lifecycle"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nA Tesseron session goes through three states: **connected**, **awaiting claim**, **claimed**. Only claimed sessions can have their actions invoked.\n\n<Sequence\n caption=\"From page load to first tool call.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 'app', label: 'WEB APP', sub: '@tesseron/web', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: '@tesseron/mcp', icon: 'bridge', variant: 'accent' },\n { id: 'u', label: 'USER', icon: 'user' },\n { id: 'a', label: 'AGENT', sub: 'Claude Code', icon: 'agent' },\n ]}\n steps={[\n { from: 'app', to: 'gw', label: 'tesseron/hello { app, actions, resources, caps }' },\n { from: 'gw', to: 'app', label: \"tesseron/welcome { sessionId, claimCode: 'AB3X-7K' }\", style: 'dashed' },\n { from: 'gw', to: 'u', label: 'claim code (web UI or stdout)', style: 'dashed' },\n { from: 'u', to: 'a', label: 'connect AB3X-7K' },\n { from: 'a', to: 'gw', label: 'tools/call tesseron__claim_session', accent: true },\n { from: 'gw', to: 'a', label: 'notifications/tools/list_changed', style: 'dashed' },\n { from: 'a', to: 'gw', label: 'tools/call shop__searchProducts' },\n { from: 'gw', to: 'app', label: 'actions/invoke { invocationId, input }' },\n { from: 'app', to: 'gw', label: 'result', style: 'dashed' },\n { from: 'gw', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## The `tesseron/hello` request\n\nSent by the app right after the WebSocket opens.\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/hello\",\n \"params\": {\n \"protocolVersion\": \"1.1.0\",\n \"app\": {\n \"id\": \"shop\",\n \"name\": \"Acme Shop\",\n \"description\": \"Product catalog and cart\",\n \"origin\": \"http://localhost:3000\",\n \"version\": \"1.0.0\",\n \"iconUrl\": \"https://shop.example/icon.svg\"\n },\n \"actions\": [\n {\n \"name\": \"searchProducts\",\n \"description\": \"Search the product catalog\",\n \"inputSchema\": { /* JSON Schema */ },\n \"outputSchema\": { /* JSON Schema, optional */ },\n \"annotations\": { \"readOnly\": true },\n \"timeoutMs\": 60000\n }\n ],\n \"resources\": [\n { \"name\": \"currentRoute\", \"description\": \"URL the user is viewing\", \"subscribable\": true }\n ],\n \"capabilities\": {\n \"streaming\": true,\n \"subscriptions\": true,\n \"sampling\": true,\n \"elicitation\": true\n }\n }\n}\n```\n\nRules:\n\n- `app.id` must match `/^[a-z][a-z0-9_]*$/`. It becomes the prefix on every MCP tool this app contributes.\n- `app.origin` is informational; the MCP gateway treats its own origin check (see [Transport](/protocol/transport/)) as authoritative.\n- Action `inputSchema` / `outputSchema` are JSON Schema. The SDK derives them from your Standard Schema validator where possible, or you can pass them explicitly.\n- `capabilities` is what the **app** can do, not what the agent can do - that comes back in `welcome`.\n\n## The `welcome` response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"protocolVersion\": \"1.1.0\",\n \"capabilities\": { \"streaming\": true, \"subscriptions\": true, \"sampling\": true, \"elicitation\": true },\n \"agent\": { \"id\": \"pending\", \"name\": \"Awaiting agent\" },\n \"claimCode\": \"AB3X-7K\"\n }\n}\n```\n\n- `sessionId` is opaque and only meaningful to the gateway - log it for debugging.\n- `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.\n- `agent` stays at `{ id: \"pending\", name: \"Awaiting agent\" }` until a claim happens.\n- `claimCode` is a 6-character human-friendly string like `AB3X-7K`. Alphanumerics minus visually confusing characters.\n\n## Claiming\n\nThe 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:\n\n1. The gateway prints it to stderr (which Claude Code surfaces).\n2. The app is free to render it in its UI - e.g. a \"Connect Claude\" button that reveals the code.\n\nThe user then tells the agent:\n\n> Claim Tesseron session AB3X-7K\n\nThe 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:\n\n- Marks the session `claimed: true`.\n- Sets `agent` on the session to the agent's identity.\n- Emits `notifications/tools/list_changed` so the agent refreshes its tool list.\n- Sends a `tesseron/claimed` notification to the SDK (see below) so the app can clear the spent claim code from its UI.\n- From this point, `tools/call <app_id>__<action>` is allowed.\n\nIf the code doesn't match (expired, wrong app, already used): error `-32009 Unauthorized`.\n\n## The `tesseron/claimed` notification\n\nOnce 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).\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"tesseron/claimed\",\n \"params\": {\n \"agent\": { \"id\": \"claude-code\", \"name\": \"Claude Code\" },\n \"claimedAt\": 1714145210123\n }\n}\n```\n\n`@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.\n\nThe 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.\n\n## Why out-of-band claim?\n\nBecause 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 browser tab 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).\n\n## Multiple gateways on one machine\n\nA 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.\n\n**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](https://github.com/BrainBlend-AI/tesseron/issues/60).\n\n**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.\n\nIn 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:\n\n```json\n{\n \"version\": 1,\n \"code\": \"AB3X-7K\",\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"appId\": \"shop\",\n \"appName\": \"Acme Shop\",\n \"gatewayPid\": 12345,\n \"mintedAt\": 1714145210123\n}\n```\n\nA 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.\n\nThis 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.\n\n## Protocol version mismatch\n\nThe MCP gateway parses `protocolVersion` as `major.minor`:\n\n- **Different major** → hard reject with error code `-32000 ProtocolMismatch` and the WebSocket is closed.\n- **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.\n- **Exact match** → no logging.\n\n```json\n{ \"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.\" } }\n```\n\nNext: the [action model](/protocol/actions/).\n","bodyText":"A Tesseron session goes through three states: **connected**, **awaiting claim**, **claimed**. Only claimed sessions can have their actions invoked.\n\n## The `tesseron/hello` request\n\nSent by the app right after the WebSocket opens.\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/hello\",\n \"params\": {\n \"protocolVersion\": \"1.1.0\",\n \"app\": {\n \"id\": \"shop\",\n \"name\": \"Acme Shop\",\n \"description\": \"Product catalog and cart\",\n \"origin\": \"http://localhost:3000\",\n \"version\": \"1.0.0\",\n \"iconUrl\": \"https://shop.example/icon.svg\"\n },\n \"actions\": [\n {\n \"name\": \"searchProducts\",\n \"description\": \"Search the product catalog\",\n \"inputSchema\": { /* JSON Schema */ },\n \"outputSchema\": { /* JSON Schema, optional */ },\n \"annotations\": { \"readOnly\": true },\n \"timeoutMs\": 60000\n }\n ],\n \"resources\": [\n { \"name\": \"currentRoute\", \"description\": \"URL the user is viewing\", \"subscribable\": true }\n ],\n \"capabilities\": {\n \"streaming\": true,\n \"subscriptions\": true,\n \"sampling\": true,\n \"elicitation\": true\n }\n }\n}\n```\n\nRules:\n\n- `app.id` must match `/^[a-z][a-z0-9_]*$/`. It becomes the prefix on every MCP tool this app contributes.\n- `app.origin` is informational; the MCP gateway treats its own origin check (see [Transport](/protocol/transport/)) as authoritative.\n- Action `inputSchema` / `outputSchema` are JSON Schema. The SDK derives them from your Standard Schema validator where possible, or you can pass them explicitly.\n- `capabilities` is what the **app** can do, not what the agent can do - that comes back in `welcome`.\n\n## The `welcome` response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"protocolVersion\": \"1.1.0\",\n \"capabilities\": { \"streaming\": true, \"subscriptions\": true, \"sampling\": true, \"elicitation\": true },\n \"agent\": { \"id\": \"pending\", \"name\": \"Awaiting agent\" },\n \"claimCode\": \"AB3X-7K\"\n }\n}\n```\n\n- `sessionId` is opaque and only meaningful to the gateway - log it for debugging.\n- `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.\n- `agent` stays at `{ id: \"pending\", name: \"Awaiting agent\" }` until a claim happens.\n- `claimCode` is a 6-character human-friendly string like `AB3X-7K`. Alphanumerics minus visually confusing characters.\n\n## Claiming\n\nThe 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:\n\n1. The gateway prints it to stderr (which Claude Code surfaces).\n2. The app is free to render it in its UI - e.g. a \"Connect Claude\" button that reveals the code.\n\nThe user then tells the agent:\n\n> Claim Tesseron session AB3X-7K\n\nThe 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:\n\n- Marks the session `claimed: true`.\n- Sets `agent` on the session to the agent's identity.\n- Emits `notifications/tools/list_changed` so the agent refreshes its tool list.\n- Sends a `tesseron/claimed` notification to the SDK (see below) so the app can clear the spent claim code from its UI.\n- From this point, `tools/call <app_id>__<action>` is allowed.\n\nIf the code doesn't match (expired, wrong app, already used): error `-32009 Unauthorized`.\n\n## The `tesseron/claimed` notification\n\nOnce 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).\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"tesseron/claimed\",\n \"params\": {\n \"agent\": { \"id\": \"claude-code\", \"name\": \"Claude Code\" },\n \"claimedAt\": 1714145210123\n }\n}\n```\n\n`@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.\n\nThe 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.\n\n## Why out-of-band claim?\n\nBecause 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 browser tab 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).\n\n## Multiple gateways on one machine\n\nA 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.\n\n**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](https://github.com/BrainBlend-AI/tesseron/issues/60).\n\n**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.\n\nIn 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:\n\n```json\n{\n \"version\": 1,\n \"code\": \"AB3X-7K\",\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"appId\": \"shop\",\n \"appName\": \"Acme Shop\",\n \"gatewayPid\": 12345,\n \"mintedAt\": 1714145210123\n}\n```\n\nA 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.\n\nThis 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.\n\n## Protocol version mismatch\n\nThe MCP gateway parses `protocolVersion` as `major.minor`:\n\n- **Different major** → hard reject with error code `-32000 ProtocolMismatch` and the WebSocket is closed.\n- **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.\n- **Exact match** → no logging.\n\n```json\n{ \"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.\" } }\n```\n\nNext: the [action model](/protocol/actions/)."},{"slug":"protocol/index","title":"Protocol overview","description":"The Tesseron protocol in one page - wire format, transport, handshake, action model, MCP capabilities, errors, lifecycle.","section":"protocol","related":["protocol/wire-format","protocol/transport","protocol/handshake","protocol/actions","protocol/errors","protocol/lifecycle"],"bodyRaw":"\nimport { Aside, Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Sequence from '../../../components/Sequence.astro';\n\n<Aside type=\"note\" title=\"Spec license\">\nThe Tesseron protocol specification (every page under `docs/protocol/`) is licensed **CC BY 4.0** — independent from the reference implementation. You are free to build a compatible implementation in any language for any purpose, including commercially, with attribution. See [`LICENSE`](https://github.com/BrainBlend-AI/tesseron/blob/main/docs/src/content/docs/protocol/LICENSE) in the protocol directory.\n</Aside>\n\nTesseron speaks **JSON-RPC 2.0 over a reliable, ordered, duplex channel** between your app and the MCP gateway, then the gateway bridges that to **MCP over stdio** for the agent. One action round-trip crosses both protocols.\n\nThe channel is a **transport binding** - WebSocket by default, Unix domain socket as an opt-in for Node apps that don't need the browser bridge. The protocol is binding-neutral; see [Transport](/protocol/transport/) for the contract every binding satisfies.\n\nThe protocol is at **version `1.1.0`**.\n\n<Sequence\n caption=\"A first-use session, start to finish: from WebSocket open to the first tool-call result returned to the agent.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 'app', label: 'WEB APP', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'u', label: 'USER', icon: 'user' },\n { id: 'a', label: 'AGENT', icon: 'agent' },\n ]}\n steps={[\n { from: 'app', to: 'gw', label: 'tesseron/hello { app, actions, caps }' },\n { from: 'gw', to: 'app', label: 'tesseron/welcome { sessionId, claimCode }', style: 'dashed' },\n { from: 'gw', to: 'u', label: 'claim code (stdout / web UI)', style: 'dashed' },\n { from: 'u', to: 'a', label: 'connect AB3X-7K' },\n { from: 'a', to: 'gw', label: 'tools/call tesseron__claim_session', accent: true },\n { from: 'gw', to: 'a', label: 'notifications/tools/list_changed', style: 'dashed' },\n { from: 'a', to: 'gw', label: 'tools/call shop__searchProducts' },\n { from: 'gw', to: 'app', label: 'actions/invoke { invocationId, input }' },\n { from: 'app', to: 'gw', label: 'result', style: 'dashed' },\n { from: 'gw', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## Read the pages in order\n\n<CardGrid>\n <LinkCard title=\"Wire format (JSON-RPC)\" href=\"./wire-format/\"\n description=\"Envelope shapes, methods, notifications, ID correlation.\" />\n <LinkCard title=\"Transport\" href=\"./transport/\"\n description=\"The binding-neutral channel contract, plus per-binding pages for WebSocket and Unix domain sockets.\" />\n <LinkCard title=\"Handshake & claiming\" href=\"./handshake/\"\n description=\"`tesseron/hello` → `welcome` → claim code → bound session.\" />\n <LinkCard title=\"Action model\" href=\"./actions/\"\n description=\"How actions are declared, invoked, validated, and namespaced.\" />\n <LinkCard title=\"Progress & cancellation\" href=\"./progress-cancellation/\"\n description=\"Streaming updates; `AbortSignal`-based cancellation.\" />\n <LinkCard title=\"Sampling\" href=\"./sampling/\"\n description=\"Handlers re-enter the agent LLM for a reasoning step.\" />\n <LinkCard title=\"Elicitation\" href=\"./elicitation/\"\n description=\"Handlers pause and ask the user a question via the agent UI.\" />\n <LinkCard title=\"Resources\" href=\"./resources/\"\n description=\"Typed, readable, optionally subscribable state projected to the agent.\" />\n <LinkCard title=\"Errors & capabilities\" href=\"./errors/\"\n description=\"Error codes, capability negotiation, not-available paths.\" />\n <LinkCard title=\"Lifecycle & failure modes\" href=\"./lifecycle/\"\n description=\"Disconnect, reconnect, timeout, MCP gateway restart, tab close.\" />\n <LinkCard title=\"Security model\" href=\"./security/\"\n description=\"Origin allowlist, claim codes, multi-app namespacing.\" />\n</CardGrid>\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.1.0` |\n| Discovery directory (v2) | `~/.tesseron/instances/` |\n| Discovery directory (v1, compat) | `~/.tesseron/tabs/` |\n| Manifest version | `2` |\n| WebSocket subprotocol (gateway side) | `tesseron-gateway` |\n| UDS framing | NDJSON (one JSON-RPC message per `\\n`) |\n| Default action timeout | `60_000` ms |\n| Max sampling depth | `3` |\n| Tool name pattern | `<app_id>__<action_name>` |\n| Resource URI pattern | `tesseron://<app_id>/<resource_name>` |\n| `app.id` validator | `/^[a-z][a-z0-9_]*$/` |\n\nAll of these are **observable from the wire** - they're not SDK-specific. A Python or Go SDK implementing Tesseron MUST match them exactly.\n","bodyText":"Tesseron speaks **JSON-RPC 2.0 over a reliable, ordered, duplex channel** between your app and the MCP gateway, then the gateway bridges that to **MCP over stdio** for the agent. One action round-trip crosses both protocols.\n\nThe channel is a **transport binding** - WebSocket by default, Unix domain socket as an opt-in for Node apps that don't need the browser bridge. The protocol is binding-neutral; see [Transport](/protocol/transport/) for the contract every binding satisfies.\n\nThe protocol is at **version `1.1.0`**.\n\n## Read the pages in order\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.1.0` |\n| Discovery directory (v2) | `~/.tesseron/instances/` |\n| Discovery directory (v1, compat) | `~/.tesseron/tabs/` |\n| Manifest version | `2` |\n| WebSocket subprotocol (gateway side) | `tesseron-gateway` |\n| UDS framing | NDJSON (one JSON-RPC message per `\\n`) |\n| Default action timeout | `60_000` ms |\n| Max sampling depth | `3` |\n| Tool name pattern | `<app_id>__<action_name>` |\n| Resource URI pattern | `tesseron://<app_id>/<resource_name>` |\n| `app.id` validator | `/^[a-z][a-z0-9_]*$/` |\n\nAll of these are **observable from the wire** - they're not SDK-specific. A Python or Go SDK implementing Tesseron MUST match them exactly."},{"slug":"protocol/lifecycle","title":"Lifecycle & failure modes","description":"The session state machine, and what happens to pending work at every transition.","section":"protocol","related":["protocol/handshake","protocol/resume","protocol/transport"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nEvery WebSocket connection to the MCP gateway produces a session that walks this state machine:\n\n<Diagram\n caption=\"Five states. Only Claimed can run actions. Every terminal transition aborts in-flight work.\"\n nodeWidth={130}\n nodeHeight={100}\n spacing={50}\n pad={44}\n nodes={[\n { id: 'dc', label: 'DISCONNECTED', icon: 'x' },\n { id: 'hs', label: 'HANDSHAKING', icon: 'arrow' },\n { id: 'aw', label: 'AWAITING', sub: 'claim pending', icon: 'lock' },\n { id: 'cl', label: 'CLAIMED', sub: 'tools exposed', icon: 'check', variant: 'accent' },\n { id: 'end', label: 'CLOSED', icon: 'x', variant: 'danger' },\n ]}\n edges={[\n { from: 'dc', to: 'hs', label: 'ws open' },\n { from: 'hs', to: 'aw', label: 'welcome' },\n { from: 'aw', to: 'cl', label: 'claim ok', accent: true },\n { from: 'aw', to: 'end', label: 'timeout', style: 'dashed', danger: true },\n { from: 'cl', to: 'end', label: 'ws close', style: 'dashed', danger: true },\n ]}\n/>\n\n## States\n\n- **Disconnected** - no WebSocket yet. Tooling surface shows no tools for this app.\n- **Handshaking** - WebSocket open, `tesseron/hello` in flight.\n- **Awaiting claim** - `welcome` sent with a `claimCode`. Actions are registered in the gateway but *not* exposed as MCP tools until claim.\n- **Claimed** - agent has submitted a matching claim. Tool list is published. Actions can be invoked.\n- **Closed** - WebSocket closed. Session forgotten by the gateway.\n\n## Transitions\n\n| From → To | Trigger | Side effects |\n|---|---|---|\n| Disconnected → Handshaking | App opens WebSocket. | `tesseron/hello` sent. |\n| Handshaking → Awaiting claim | MCP gateway returns `welcome`. | Claim code generated + printed to gateway stderr. |\n| Awaiting claim → Claimed | Agent calls `tesseron__claim_session` with matching code. | `notifications/tools/list_changed` fires. |\n| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |\n| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; `tools/list_changed` fires so the agent drops stale tools. |\n\nThe TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.\n\n## What pending work does on close\n\nFrom inside your handler, on any Closed transition:\n\n- `ctx.signal.aborted` becomes `true`.\n- `ctx.progress(…)` after close is silently dropped.\n- `ctx.sample(…)` / `ctx.confirm(…)` / `ctx.elicit(…)` in flight reject with `TransportClosedError`.\n- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.\n\n**Handler best practices:**\n\n```ts\n.handler(async (input, ctx) => {\n const abortable = new AbortController();\n ctx.signal.addEventListener('abort', () => abortable.abort());\n try {\n return await longWork(input, { signal: abortable.signal });\n } finally {\n abortable.abort(); // release any resources even on normal exit\n }\n});\n```\n\n## Reconnection: plain `tesseron/hello` starts over, `tesseron/resume` doesn't\n\nA plain `tesseron/hello` after a disconnect yields a **new** `sessionId` and **new** `claimCode`. The agent must re-claim. In-flight work from the old session is gone.\n\nWhy not resumable by default at the protocol level? Two reasons:\n\n1. The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.\n2. Claim is meant to be a user-visible act. An invisible reconnection would bypass the \"human-in-the-loop authorisation\" the claim code represents.\n\n[Session resume](/protocol/resume/) (`tesseron/resume` with a stored `resumeToken`) sidesteps this by binding the new socket to a specific previously-claimed `sessionId`. The agent sees the same tool list, the user does nothing. `@tesseron/web` performs this round-trip automatically by default — see the resume page for the four shapes the `resume` option accepts and how to opt out.\n\n## MCP gateway restart\n\nIf the gateway process dies (plugin disabled, Claude Code restart, crash):\n\n- Every app's WebSocket closes with code 1001 (`Going Away`).\n- Every SDK instance aborts in-flight work and rejects pending requests.\n- Apps are free to `connect()` again when the gateway comes back. Zombie sessions are held in gateway-process memory only, so a gateway restart wipes every resumable session — a reconnect after gateway restart will always be a fresh `tesseron/hello` with a new claim code, regardless of what the SDK had stored.\n\nA small \"reconnect\" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.\n\nNext: the [security model](/protocol/security/).\n","bodyText":"Every WebSocket connection to the MCP gateway produces a session that walks this state machine:\n\n## States\n\n- **Disconnected** - no WebSocket yet. Tooling surface shows no tools for this app.\n- **Handshaking** - WebSocket open, `tesseron/hello` in flight.\n- **Awaiting claim** - `welcome` sent with a `claimCode`. Actions are registered in the gateway but *not* exposed as MCP tools until claim.\n- **Claimed** - agent has submitted a matching claim. Tool list is published. Actions can be invoked.\n- **Closed** - WebSocket closed. Session forgotten by the gateway.\n\n## Transitions\n\n| From → To | Trigger | Side effects |\n|---|---|---|\n| Disconnected → Handshaking | App opens WebSocket. | `tesseron/hello` sent. |\n| Handshaking → Awaiting claim | MCP gateway returns `welcome`. | Claim code generated + printed to gateway stderr. |\n| Awaiting claim → Claimed | Agent calls `tesseron__claim_session` with matching code. | `notifications/tools/list_changed` fires. |\n| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |\n| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; `tools/list_changed` fires so the agent drops stale tools. |\n\nThe TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.\n\n## What pending work does on close\n\nFrom inside your handler, on any Closed transition:\n\n- `ctx.signal.aborted` becomes `true`.\n- `ctx.progress(…)` after close is silently dropped.\n- `ctx.sample(…)` / `ctx.confirm(…)` / `ctx.elicit(…)` in flight reject with `TransportClosedError`.\n- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.\n\n**Handler best practices:**\n\n```ts\n.handler(async (input, ctx) => {\n const abortable = new AbortController();\n ctx.signal.addEventListener('abort', () => abortable.abort());\n try {\n return await longWork(input, { signal: abortable.signal });\n } finally {\n abortable.abort(); // release any resources even on normal exit\n }\n});\n```\n\n## Reconnection: plain `tesseron/hello` starts over, `tesseron/resume` doesn't\n\nA plain `tesseron/hello` after a disconnect yields a **new** `sessionId` and **new** `claimCode`. The agent must re-claim. In-flight work from the old session is gone.\n\nWhy not resumable by default at the protocol level? Two reasons:\n\n1. The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.\n2. Claim is meant to be a user-visible act. An invisible reconnection would bypass the \"human-in-the-loop authorisation\" the claim code represents.\n\n[Session resume](/protocol/resume/) (`tesseron/resume` with a stored `resumeToken`) sidesteps this by binding the new socket to a specific previously-claimed `sessionId`. The agent sees the same tool list, the user does nothing. `@tesseron/web` performs this round-trip automatically by default — see the resume page for the four shapes the `resume` option accepts and how to opt out.\n\n## MCP gateway restart\n\nIf the gateway process dies (plugin disabled, Claude Code restart, crash):\n\n- Every app's WebSocket closes with code 1001 (`Going Away`).\n- Every SDK instance aborts in-flight work and rejects pending requests.\n- Apps are free to `connect()` again when the gateway comes back. Zombie sessions are held in gateway-process memory only, so a gateway restart wipes every resumable session — a reconnect after gateway restart will always be a fresh `tesseron/hello` with a new claim code, regardless of what the SDK had stored.\n\nA small \"reconnect\" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.\n\nNext: the [security model](/protocol/security/)."},{"slug":"protocol/progress-cancellation","title":"Progress & cancellation","description":"Streaming updates via `actions/progress` and AbortSignal-based cancellation via `actions/cancel`.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nLong-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.\n\n## Streaming progress\n\n<Sequence\n caption=\"Progress notifications ride along while the handler runs. The agent's UI typically renders them as an animated status line.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call { ... }' },\n { from: 'g', to: 's', label: 'actions/invoke' },\n { from: 's', to: 'g', label: 'actions/progress { percent: 10 }', style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/progress', style: 'dashed', accent: true },\n { from: 's', to: 'g', label: 'actions/progress { percent: 60 }', style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/progress', style: 'dashed', accent: true },\n { from: 's', to: 'g', label: 'result { ... }', style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n```ts\ntesseron.action('importCsv')\n .input(z.object({ url: z.string().url() }))\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url);\n\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('Cancelled');\n ctx.progress({\n message: `${i}/${rows.length}`,\n percent: 5 + Math.floor((i / rows.length) * 90),\n });\n await importBatch(rows.slice(i, i + 100));\n }\n\n return { imported: rows.length };\n });\n```\n\nWire format - sent by the app as a notification (no response):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"message\": \"500/2000\",\n \"percent\": 27,\n \"data\": { \"etaMs\": 14000 }\n }\n}\n```\n\nAll three payload fields (`message`, `percent`, `data`) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP `notifications/progress`; MCP clients render them at their leisure.\n\n**Guideline:** cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.\n\n## Cancellation\n\n<Sequence\n caption=\"The agent cancels. The gateway translates to actions/cancel. The handler sees ctx.signal.aborted.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call { ... }' },\n { from: 'g', to: 's', label: \"actions/invoke { invocationId: 'inv_1' }\" },\n { note: 's', label: 'handler running (reads ctx.signal)' },\n { from: 'a', to: 'g', label: 'cancel invocation', style: 'dashed', danger: true },\n { from: 'g', to: 's', label: \"actions/cancel { invocationId: 'inv_1' }\", style: 'dashed', danger: true },\n { note: 's', label: 'ctx.signal.aborted = true', variant: 'danger' },\n { from: 's', to: 'g', label: 'error -32001 Cancelled', style: 'dashed', danger: true },\n { from: 'g', to: 'a', label: 'tools/call error', style: 'dashed', danger: true },\n ]}\n/>\n\n```ts\ntesseron.action('generateReport')\n .input(...)\n .handler(async (input, ctx) => {\n const rows = await slowQuery(ctx.signal); // pass signal down\n if (ctx.signal.aborted) throw new Cancelled();\n return formatReport(rows);\n });\n```\n\n- `ctx.signal` is a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Pass it to `fetch`, `setTimeout`, database drivers, or anything else that accepts one.\n- Cancellation fires for **two reasons**: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.\n- After abort, the SDK returns an error response with code `-32001 Cancelled` (explicit) or `-32002 Timeout` (timer).\n- **The wire is freed at the deadline regardless of the handler.** The SDK races the handler against the abort signal, so a handler stuck inside a non-signal-aware promise (`modern-screenshot.domToPng`, `<canvas>.toBlob`, `<img>.decode`, `document.fonts.ready`, `Audio.play`, `MediaRecorder`, ...) doesn't pin the agent's `tools/call` indefinitely - the orphaned handler keeps running, but the agent has already received its error response. To bound an individual stuck call from inside the handler, use [`ctx.withTimeout(p, ms)`](/sdk/typescript/context/#ctxwithtimeout-drop-stuck-inner-promises).\n\nWire format - notification from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/cancel\",\n \"params\": { \"invocationId\": \"inv_abc\" }\n}\n```\n\nThe app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.\n\n## Reading the progress on the agent side\n\nWhen the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as \"best-effort hint\".\n\n## What NOT to use progress for\n\n- Final results. Use the response.\n- Data the next handler needs. Use a return value, a sampling round-trip, or a resource.\n- Error surfacing. Return an error response.\n\nNext: [sampling](/protocol/sampling/) - handlers that call back into the LLM.\n","bodyText":"Long-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.\n\n## Streaming progress\n\n```ts\ntesseron.action('importCsv')\n .input(z.object({ url: z.string().url() }))\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url);\n\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('Cancelled');\n ctx.progress({\n message: `${i}/${rows.length}`,\n percent: 5 + Math.floor((i / rows.length) * 90),\n });\n await importBatch(rows.slice(i, i + 100));\n }\n\n return { imported: rows.length };\n });\n```\n\nWire format - sent by the app as a notification (no response):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"message\": \"500/2000\",\n \"percent\": 27,\n \"data\": { \"etaMs\": 14000 }\n }\n}\n```\n\nAll three payload fields (`message`, `percent`, `data`) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP `notifications/progress`; MCP clients render them at their leisure.\n\n**Guideline:** cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.\n\n## Cancellation\n\n```ts\ntesseron.action('generateReport')\n .input(...)\n .handler(async (input, ctx) => {\n const rows = await slowQuery(ctx.signal); // pass signal down\n if (ctx.signal.aborted) throw new Cancelled();\n return formatReport(rows);\n });\n```\n\n- `ctx.signal` is a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Pass it to `fetch`, `setTimeout`, database drivers, or anything else that accepts one.\n- Cancellation fires for **two reasons**: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.\n- After abort, the SDK returns an error response with code `-32001 Cancelled` (explicit) or `-32002 Timeout` (timer).\n- **The wire is freed at the deadline regardless of the handler.** The SDK races the handler against the abort signal, so a handler stuck inside a non-signal-aware promise (`modern-screenshot.domToPng`, `<canvas>.toBlob`, `<img>.decode`, `document.fonts.ready`, `Audio.play`, `MediaRecorder`, ...) doesn't pin the agent's `tools/call` indefinitely - the orphaned handler keeps running, but the agent has already received its error response. To bound an individual stuck call from inside the handler, use [`ctx.withTimeout(p, ms)`](/sdk/typescript/context/#ctxwithtimeout-drop-stuck-inner-promises).\n\nWire format - notification from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/cancel\",\n \"params\": { \"invocationId\": \"inv_abc\" }\n}\n```\n\nThe app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.\n\n## Reading the progress on the agent side\n\nWhen the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as \"best-effort hint\".\n\n## What NOT to use progress for\n\n- Final results. Use the response.\n- Data the next handler needs. Use a return value, a sampling round-trip, or a resource.\n- Error surfacing. Return an error response.\n\nNext: [sampling](/protocol/sampling/) - handlers that call back into the LLM."},{"slug":"protocol/resources","title":"Resources","description":"Typed, readable, optionally subscribable state your app projects to the agent.","section":"protocol","related":["sdk/typescript/resources","protocol/wire-format"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nA **resource** is a named piece of app state the agent can read - and optionally subscribe to for push updates. Resources complement actions: actions cause changes, resources expose what changed.\n\n<Sequence\n caption=\"Agent reads once, then subscribes. When the app's value changes, the SDK pushes a resources/updated notification.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'RESOURCE HANDLER', icon: 'database' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'resources/read tesseron://shop/route' },\n { from: 'g', to: 's', label: 'resources/read' },\n { from: 's', to: 'g', label: \"{ value: '/cart' }\", style: 'dashed' },\n { from: 'g', to: 'a', label: 'read result', style: 'dashed' },\n { from: 'a', to: 'g', label: 'resources/subscribe', accent: true },\n { from: 'g', to: 's', label: 'resources/subscribe', accent: true },\n { note: 's', label: 'register emit() callback' },\n { from: 's', to: 'g', label: \"resources/updated { value: '/checkout' }\", style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/resources/updated', style: 'dashed', accent: true },\n ]}\n/>\n\n## Declaration\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is currently viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `.read()` is a one-shot getter. Called on every `resources/read` the agent issues.\n- `.subscribe()` is optional. It registers an emitter; return an unsubscribe function so the SDK can clean up when the agent unsubscribes or the session closes.\n\n## URI convention\n\nResources are exposed to the agent with the URI `tesseron://<app_id>/<resource_name>`. For `app.id = \"shop\"` and `resource = \"currentRoute\"`, the agent sees `tesseron://shop/currentRoute`.\n\n## Reading from clients that don't speak MCP resources\n\nSome MCP clients don't surface `resources/read` to their model. The MCP gateway ships a meta-tool fallback:\n\n- **`tesseron__read_resource`** (`{ app_id, name }`) - returns the resource's current value as a tool-call result. Prefer this over the generic `ReadMcpResourceTool` because the agent doesn't have to know how the MCP server is namespaced on the client (e.g. `plugin:tesseron:tesseron` in Claude Code plugin installs vs. `tesseron` in a raw config).\n\n`tesseron__list_actions` enumerates every claimed session's resources and includes both the preferred `tesseron__read_resource` args and the `ReadMcpResourceTool` fallback.\n\n## Wire format\n\n### Read (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"method\": \"resources/read\", \"params\": { \"name\": \"currentRoute\" } }\n```\n\nResponse:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"result\": { \"value\": \"/checkout\" } }\n```\n\n### Subscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"method\": \"resources/subscribe\", \"params\": { \"name\": \"currentRoute\", \"subscriptionId\": \"sub_1\" } }\n```\n\nResponse is empty - the SDK just acknowledges and now holds the emitter callback.\n\n### Update (app → gateway, notification)\n\nEach time the emitter fires:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub_1\", \"value\": \"/cart\" }\n}\n```\n\nThe gateway forwards this as MCP `notifications/resources/updated` to the agent.\n\n### Unsubscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"method\": \"resources/unsubscribe\", \"params\": { \"subscriptionId\": \"sub_1\" } }\n```\n\nThe SDK calls the unsubscribe function returned by your `.subscribe()` handler.\n\n### List changed (app → gateway, notification)\n\nIf your app registers or removes resources after the initial `tesseron/hello`, the SDK emits `resources/list_changed` with the new manifest. The gateway forwards this as MCP `notifications/resources/list_changed` so agents can refetch the list. `actions/list_changed` follows the same pattern for dynamic action sets.\n\n## Patterns\n\n### Read-only projection\n\n```ts\ntesseron.resource('filterState').read(() => ({\n search: state.search,\n onlyDone: state.onlyDone,\n}));\n```\n\nPerfect for letting the agent reason about \"what's the user currently looking at\" before proposing actions.\n\n### Debounced subscription\n\nDon't emit on every keystroke - the agent can't meaningfully react at that rate.\n\n```ts\ntesseron.resource('search')\n .read(() => state.search)\n .subscribe((emit) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const onChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => emit(state.search), 250);\n };\n state.on('change', onChange);\n return () => { if (timer) clearTimeout(timer); state.off('change', onChange); };\n });\n```\n\n### Large or expensive resources\n\nIf the value is expensive to produce, remember that `.read()` runs every time the agent fetches. Cache inside the handler, or use `.subscribe()` as the source of truth and cache the latest emitted value in-memory.\n\n## Capability gate\n\nSubscriptions require `agentCapabilities.subscriptions`. Reads do not. If the agent can't subscribe, it will only call `resources/read` and your `.subscribe()` handler is never invoked.\n\nNext: the full [error catalog and capability negotiation](/protocol/errors/).\n","bodyText":"A **resource** is a named piece of app state the agent can read - and optionally subscribe to for push updates. Resources complement actions: actions cause changes, resources expose what changed.\n\n## Declaration\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is currently viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `.read()` is a one-shot getter. Called on every `resources/read` the agent issues.\n- `.subscribe()` is optional. It registers an emitter; return an unsubscribe function so the SDK can clean up when the agent unsubscribes or the session closes.\n\n## URI convention\n\nResources are exposed to the agent with the URI `tesseron://<app_id>/<resource_name>`. For `app.id = \"shop\"` and `resource = \"currentRoute\"`, the agent sees `tesseron://shop/currentRoute`.\n\n## Reading from clients that don't speak MCP resources\n\nSome MCP clients don't surface `resources/read` to their model. The MCP gateway ships a meta-tool fallback:\n\n- **`tesseron__read_resource`** (`{ app_id, name }`) - returns the resource's current value as a tool-call result. Prefer this over the generic `ReadMcpResourceTool` because the agent doesn't have to know how the MCP server is namespaced on the client (e.g. `plugin:tesseron:tesseron` in Claude Code plugin installs vs. `tesseron` in a raw config).\n\n`tesseron__list_actions` enumerates every claimed session's resources and includes both the preferred `tesseron__read_resource` args and the `ReadMcpResourceTool` fallback.\n\n## Wire format\n\n### Read (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"method\": \"resources/read\", \"params\": { \"name\": \"currentRoute\" } }\n```\n\nResponse:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"result\": { \"value\": \"/checkout\" } }\n```\n\n### Subscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"method\": \"resources/subscribe\", \"params\": { \"name\": \"currentRoute\", \"subscriptionId\": \"sub_1\" } }\n```\n\nResponse is empty - the SDK just acknowledges and now holds the emitter callback.\n\n### Update (app → gateway, notification)\n\nEach time the emitter fires:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub_1\", \"value\": \"/cart\" }\n}\n```\n\nThe gateway forwards this as MCP `notifications/resources/updated` to the agent.\n\n### Unsubscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"method\": \"resources/unsubscribe\", \"params\": { \"subscriptionId\": \"sub_1\" } }\n```\n\nThe SDK calls the unsubscribe function returned by your `.subscribe()` handler.\n\n### List changed (app → gateway, notification)\n\nIf your app registers or removes resources after the initial `tesseron/hello`, the SDK emits `resources/list_changed` with the new manifest. The gateway forwards this as MCP `notifications/resources/list_changed` so agents can refetch the list. `actions/list_changed` follows the same pattern for dynamic action sets.\n\n## Patterns\n\n### Read-only projection\n\n```ts\ntesseron.resource('filterState').read(() => ({\n search: state.search,\n onlyDone: state.onlyDone,\n}));\n```\n\nPerfect for letting the agent reason about \"what's the user currently looking at\" before proposing actions.\n\n### Debounced subscription\n\nDon't emit on every keystroke - the agent can't meaningfully react at that rate.\n\n```ts\ntesseron.resource('search')\n .read(() => state.search)\n .subscribe((emit) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const onChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => emit(state.search), 250);\n };\n state.on('change', onChange);\n return () => { if (timer) clearTimeout(timer); state.off('change', onChange); };\n });\n```\n\n### Large or expensive resources\n\nIf the value is expensive to produce, remember that `.read()` runs every time the agent fetches. Cache inside the handler, or use `.subscribe()` as the source of truth and cache the latest emitted value in-memory.\n\n## Capability gate\n\nSubscriptions require `agentCapabilities.subscriptions`. Reads do not. If the agent can't subscribe, it will only call `resources/read` and your `.subscribe()` handler is never invoked.\n\nNext: the full [error catalog and capability negotiation](/protocol/errors/)."},{"slug":"protocol/resume","title":"Session resume","description":"How a Tesseron app rejoins a previously-claimed session after a transport drop via tesseron/resume - protocol shape, gateway behaviour, and the 4-line localStorage recipe.","section":"protocol","related":["protocol/handshake","protocol/transport","protocol/wire-format","protocol/lifecycle"],"bodyRaw":"\nA 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.\n\nThe `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.\n\nBoth mint flows (gateway-minted and host-minted) honour resume:\n\n- **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 }`.\n- **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.\n\n## Flow\n\n1. On a fresh `tesseron/hello`, the gateway returns a `resumeToken` in the welcome. Stash it alongside the `sessionId` wherever fits your app.\n2. 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 })`).\n3. 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.\n4. The caller persists the **new** `resumeToken` from the resume response.\n\nResume 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.\n\n## The `tesseron/resume` request\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/resume\",\n \"params\": {\n \"protocolVersion\": \"1.1.0\",\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"resumeToken\": \"Xk9f3nN9kOeGqR7mWpLc2v\",\n \"app\": { \"id\": \"shop\", \"name\": \"Acme Shop\", \"origin\": \"http://localhost:3000\" },\n \"actions\": [ /* same shape as tesseron/hello */ ],\n \"resources\": [ /* same shape as tesseron/hello */ ],\n \"capabilities\": {\n \"streaming\": true,\n \"subscriptions\": true,\n \"sampling\": true,\n \"elicitation\": true\n }\n }\n}\n```\n\n`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.\n\n## The resume response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"protocolVersion\": \"1.1.0\",\n \"capabilities\": { \"streaming\": true, \"subscriptions\": true, \"sampling\": true, \"elicitation\": true },\n \"agent\": { \"id\": \"claude-ai\", \"name\": \"Claude Desktop\" },\n \"resumeToken\": \"NEW_ROTATED_TOKEN_VALUE\"\n }\n}\n```\n\n- `sessionId` matches the one in the request (same session, reattached to the fresh socket).\n- `resumeToken` is rotated. Overwrite whatever you stashed with this new value.\n- `claimCode` is **omitted** - the session is already claimed, no need for a re-pair.\n\n## Failure modes\n\nAll resume failures surface as a `TesseronError` with code `TesseronErrorCode.ResumeFailed` (`-32011`). Callers typically catch the error and fall back to a plain `tesseron/hello`.\n\n| Condition | Message pattern |\n|---|---|\n| Unknown `sessionId` | `No resumable session \"<id>\"` |\n| Cross-app resume | `Session \"<id>\" is owned by app \"<other>\"` |\n| Unclaimed zombie | `<id> was never claimed` |\n| TTL elapsed | Falls under \"no resumable session\" - the zombie was already evicted |\n| Wrong `resumeToken` | `Invalid resumeToken for session \"<id>\"` |\n| Malformed params (missing `app`, non-string `sessionId` / `resumeToken`, missing `actions` / `resources` / `capabilities`) | `Invalid tesseron/resume request: expected { protocolVersion, sessionId, resumeToken, app, actions, resources, capabilities }` |\n| Protocol major-version mismatch | Same rules as `tesseron/hello` - throws `ProtocolMismatch` |\n\nToken comparison uses `crypto.timingSafeEqual` with a length pre-check, so a wildly-wrong token doesn't leak timing information about the correct length.\n\n## Gateway configuration\n\nTwo knobs on `new TesseronGateway({ ... })` shape how aggressive resume is:\n\n```ts\nconst gateway = new TesseronGateway({\n resumeTtlMs: 300_000, // 5 minutes (default: 14_400_000 / 4 hours)\n maxZombies: 500, // cap on zombies held simultaneously (default: 100)\n});\n```\n\n- `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.\n- `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`).\n\n## Idiomatic SDK usage\n\n`@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:\n\n```ts\nimport { tesseron } from '@tesseron/web';\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\ntesseron.action('searchProducts').handler(/* ... */);\n\nconst welcome = await tesseron.connect();\n// Refresh the page → next connect resumes the same session,\n// agent stays paired, no new claim code.\n```\n\nRefresh 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`:\n\n```ts\nimport { tesseron, type ResumeStorage } from '@tesseron/web';\n\nconst keychainBackend: ResumeStorage = {\n load: () => ipc.invoke('tesseron:load'),\n save: (creds) => ipc.invoke('tesseron:save', creds),\n clear: () => ipc.invoke('tesseron:clear'),\n};\n\nawait tesseron.connect(undefined, { resume: keychainBackend });\n```\n\nIf 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):\n\n```ts\nawait tesseron.connect(undefined, {\n resume: { sessionId, resumeToken }, // explicit; SDK won't write to localStorage\n});\n```\n\nIf you're using [`@tesseron/react`](/sdk/typescript/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.\n\n### Falling back when resume fails\n\n```ts\ntry {\n await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);\n} catch (err) {\n if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {\n localStorage.removeItem('tesseron:shop');\n await tesseron.connect(url); // fresh hello\n } else {\n throw err;\n }\n}\n```\n\n## What resume does **not** do\n\n- 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](/protocol/lifecycle/)) and can retry at its own layer.\n- It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.\n- 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.\n- 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.\n\n## See also\n\n- [Handshake & claiming](/protocol/handshake/) - the `tesseron/hello` flow resume complements.\n- [Lifecycle & failure modes](/protocol/lifecycle/) - how the gateway behaves during drops, retries, and gateway restarts.\n- [Errors & capabilities](/protocol/errors/) - the full `TesseronErrorCode` table including `ResumeFailed`.\n","bodyText":"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.\n\nThe `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.\n\nBoth mint flows (gateway-minted and host-minted) honour resume:\n\n- **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 }`.\n- **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.\n\n## Flow\n\n1. On a fresh `tesseron/hello`, the gateway returns a `resumeToken` in the welcome. Stash it alongside the `sessionId` wherever fits your app.\n2. 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 })`).\n3. 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.\n4. The caller persists the **new** `resumeToken` from the resume response.\n\nResume 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.\n\n## The `tesseron/resume` request\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/resume\",\n \"params\": {\n \"protocolVersion\": \"1.1.0\",\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"resumeToken\": \"Xk9f3nN9kOeGqR7mWpLc2v\",\n \"app\": { \"id\": \"shop\", \"name\": \"Acme Shop\", \"origin\": \"http://localhost:3000\" },\n \"actions\": [ /* same shape as tesseron/hello */ ],\n \"resources\": [ /* same shape as tesseron/hello */ ],\n \"capabilities\": {\n \"streaming\": true,\n \"subscriptions\": true,\n \"sampling\": true,\n \"elicitation\": true\n }\n }\n}\n```\n\n`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.\n\n## The resume response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"sessionId\": \"s_a1b2c3de1234567\",\n \"protocolVersion\": \"1.1.0\",\n \"capabilities\": { \"streaming\": true, \"subscriptions\": true, \"sampling\": true, \"elicitation\": true },\n \"agent\": { \"id\": \"claude-ai\", \"name\": \"Claude Desktop\" },\n \"resumeToken\": \"NEW_ROTATED_TOKEN_VALUE\"\n }\n}\n```\n\n- `sessionId` matches the one in the request (same session, reattached to the fresh socket).\n- `resumeToken` is rotated. Overwrite whatever you stashed with this new value.\n- `claimCode` is **omitted** - the session is already claimed, no need for a re-pair.\n\n## Failure modes\n\nAll resume failures surface as a `TesseronError` with code `TesseronErrorCode.ResumeFailed` (`-32011`). Callers typically catch the error and fall back to a plain `tesseron/hello`.\n\n| Condition | Message pattern |\n|---|---|\n| Unknown `sessionId` | `No resumable session \"<id>\"` |\n| Cross-app resume | `Session \"<id>\" is owned by app \"<other>\"` |\n| Unclaimed zombie | `<id> was never claimed` |\n| TTL elapsed | Falls under \"no resumable session\" - the zombie was already evicted |\n| Wrong `resumeToken` | `Invalid resumeToken for session \"<id>\"` |\n| Malformed params (missing `app`, non-string `sessionId` / `resumeToken`, missing `actions` / `resources` / `capabilities`) | `Invalid tesseron/resume request: expected { protocolVersion, sessionId, resumeToken, app, actions, resources, capabilities }` |\n| Protocol major-version mismatch | Same rules as `tesseron/hello` - throws `ProtocolMismatch` |\n\nToken comparison uses `crypto.timingSafeEqual` with a length pre-check, so a wildly-wrong token doesn't leak timing information about the correct length.\n\n## Gateway configuration\n\nTwo knobs on `new TesseronGateway({ ... })` shape how aggressive resume is:\n\n```ts\nconst gateway = new TesseronGateway({\n resumeTtlMs: 300_000, // 5 minutes (default: 14_400_000 / 4 hours)\n maxZombies: 500, // cap on zombies held simultaneously (default: 100)\n});\n```\n\n- `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.\n- `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`).\n\n## Idiomatic SDK usage\n\n`@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:\n\n```ts\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\ntesseron.action('searchProducts').handler(/* ... */);\n\nconst welcome = await tesseron.connect();\n// Refresh the page → next connect resumes the same session,\n// agent stays paired, no new claim code.\n```\n\nRefresh 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`:\n\n```ts\n\nconst keychainBackend: ResumeStorage = {\n load: () => ipc.invoke('tesseron:load'),\n save: (creds) => ipc.invoke('tesseron:save', creds),\n clear: () => ipc.invoke('tesseron:clear'),\n};\n\nawait tesseron.connect(undefined, { resume: keychainBackend });\n```\n\nIf 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):\n\n```ts\nawait tesseron.connect(undefined, {\n resume: { sessionId, resumeToken }, // explicit; SDK won't write to localStorage\n});\n```\n\nIf you're using [`@tesseron/react`](/sdk/typescript/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.\n\n### Falling back when resume fails\n\n```ts\ntry {\n await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);\n} catch (err) {\n if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {\n localStorage.removeItem('tesseron:shop');\n await tesseron.connect(url); // fresh hello\n } else {\n throw err;\n }\n}\n```\n\n## What resume does **not** do\n\n- 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](/protocol/lifecycle/)) and can retry at its own layer.\n- It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.\n- 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.\n- 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.\n\n## See also\n\n- [Handshake & claiming](/protocol/handshake/) - the `tesseron/hello` flow resume complements.\n- [Lifecycle & failure modes](/protocol/lifecycle/) - how the gateway behaves during drops, retries, and gateway restarts.\n- [Errors & capabilities](/protocol/errors/) - the full `TesseronErrorCode` table including `ResumeFailed`."},{"slug":"protocol/sampling","title":"Sampling","description":"How a handler re-enters the agent's LLM for a reasoning step, and what the schema contract looks like.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\n**Sampling** lets an action handler ask the agent's LLM to produce a response mid-handler. The LLM is the agent's - not your own - so sampling doesn't require an API key from your side, and it counts against the user's agent budget.\n\n<Sequence\n caption=\"The handler re-enters the agent loop. The agent's LLM replies. The handler then validates the result against your schema.\"\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.sample(...)', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT + LLM', icon: 'agent' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'sampling/request { prompt, schema, maxTokens }' },\n { from: 'g', to: 'a', label: 'MCP sampling/createMessage' },\n { note: 'a', label: 'LLM generates response' },\n { from: 'a', to: 'g', label: 'sampling result', style: 'dashed' },\n { from: 'g', to: 's', label: '{ content }', style: 'dashed' },\n { note: 's', label: 'validate against schema -> parsed value', variant: 'accent' },\n ]}\n/>\n\n## When to use it\n\n- **Natural-language reformatting** - turn a list of rows into a readable summary.\n- **Classification** - given a free-text comment, pick a category from an enum.\n- **Structured extraction** - pull the fields your action needs out of a fuzzy input.\n\nDon't use sampling for:\n\n- Raw chatbot replies. Your action should have a clear return type.\n- Very long generations. Sampling is subject to depth limits (max 3 by default) and counts against the agent budget - keep it targeted.\n\n## Calling sample\n\n```ts\nimport { z } from 'zod';\n\ntesseron.action('classifyComment')\n .input(z.object({ text: z.string() }))\n .output(z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), confidence: z.number() }))\n .handler(async ({ text }, ctx) => {\n const result = await ctx.sample({\n prompt: `Classify the sentiment of this comment: \"\"\"${text}\"\"\"`,\n schema: z.object({\n sentiment: z.enum(['positive', 'neutral', 'negative']),\n confidence: z.number().min(0).max(1),\n }),\n maxTokens: 80,\n });\n return result;\n });\n```\n\n## Wire format\n\nRequest, app → gateway:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"method\": \"sampling/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"prompt\": \"Classify the sentiment …\",\n \"schema\": { \"type\": \"object\", \"properties\": { \"sentiment\": { \"enum\": [\"positive\", \"neutral\", \"negative\"] }, \"confidence\": { \"type\": \"number\" } } },\n \"maxTokens\": 80\n }\n}\n```\n\nResponse, gateway → app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"result\": { \"content\": { \"sentiment\": \"positive\", \"confidence\": 0.82 } }\n}\n```\n\nIf you passed a `schema`, the SDK validates `result.content` against it before returning from `ctx.sample`. If the model's response doesn't parse, you get a validation error and can retry.\n\n## Depth limit\n\nSampling is recursive by construction: the agent is a Claude session that called your action, and you're asking that same Claude to think again. Without a cap, a malicious or buggy chain could spiral.\n\nThe MCP gateway enforces `maxSamplingDepth = 3`. Each request from a handler that was itself invoked via sampling increments the counter. Exceeded → error `-32008 SamplingDepthExceeded`.\n\n## Capability gate\n\nNot every MCP client supports sampling. Before calling `ctx.sample`, check the capability:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n // Fall back: return something useful without the LLM.\n}\nconst result = await ctx.sample({ /* ... */ });\n```\n\nOr let the SDK throw `SamplingNotAvailableError` (error code `-32006`) and catch it. Pick whichever fits your UX.\n\n### Client compatibility\n\nSampling only works when the connected MCP client advertises `capabilities.sampling` during the MCP `initialize` handshake. Tesseron captures the client's capabilities at that point and flows them to every SDK session as `ctx.agentCapabilities.sampling` — so a handler always sees the real answer, even when a particular client (for example, Claude Code as of this writing) has not yet implemented `sampling/createMessage`. If a handler calls `ctx.sample()` anyway on such a client, the SDK throws a structured `SamplingNotAvailableError` including the client name (when available) instead of a raw JSON-RPC `-32601 Method not found`, so callers can branch on `error instanceof SamplingNotAvailableError` and return a graceful fallback.\n\nNext: [elicitation](/protocol/elicitation/) - same shape, but with the user instead of the model.\n","bodyText":"**Sampling** lets an action handler ask the agent's LLM to produce a response mid-handler. The LLM is the agent's - not your own - so sampling doesn't require an API key from your side, and it counts against the user's agent budget.\n\n<Sequence\n caption=\"The handler re-enters the agent loop. The agent's LLM replies. The handler then validates the result against your schema.\"\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.sample(...)', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT + LLM', icon: 'agent' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'sampling/request { prompt, schema, maxTokens }' },\n { from: 'g', to: 'a', label: 'MCP sampling/createMessage' },\n { note: 'a', label: 'LLM generates response' },\n { from: 'a', to: 'g', label: 'sampling result', style: 'dashed' },\n { from: 'g', to: 's', label: '{ content }', style: 'dashed' },\n { note: 's', label: 'validate against schema -> parsed value', variant: 'accent' },\n ]}\n/>\n\n## When to use it\n\n- **Natural-language reformatting** - turn a list of rows into a readable summary.\n- **Classification** - given a free-text comment, pick a category from an enum.\n- **Structured extraction** - pull the fields your action needs out of a fuzzy input.\n\nDon't use sampling for:\n\n- Raw chatbot replies. Your action should have a clear return type.\n- Very long generations. Sampling is subject to depth limits (max 3 by default) and counts against the agent budget - keep it targeted.\n\n## Calling sample\n\n```ts\n\ntesseron.action('classifyComment')\n .input(z.object({ text: z.string() }))\n .output(z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), confidence: z.number() }))\n .handler(async ({ text }, ctx) => {\n const result = await ctx.sample({\n prompt: `Classify the sentiment of this comment: \"\"\"${text}\"\"\"`,\n schema: z.object({\n sentiment: z.enum(['positive', 'neutral', 'negative']),\n confidence: z.number().min(0).max(1),\n }),\n maxTokens: 80,\n });\n return result;\n });\n```\n\n## Wire format\n\nRequest, app → gateway:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"method\": \"sampling/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"prompt\": \"Classify the sentiment …\",\n \"schema\": { \"type\": \"object\", \"properties\": { \"sentiment\": { \"enum\": [\"positive\", \"neutral\", \"negative\"] }, \"confidence\": { \"type\": \"number\" } } },\n \"maxTokens\": 80\n }\n}\n```\n\nResponse, gateway → app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"result\": { \"content\": { \"sentiment\": \"positive\", \"confidence\": 0.82 } }\n}\n```\n\nIf you passed a `schema`, the SDK validates `result.content` against it before returning from `ctx.sample`. If the model's response doesn't parse, you get a validation error and can retry.\n\n## Depth limit\n\nSampling is recursive by construction: the agent is a Claude session that called your action, and you're asking that same Claude to think again. Without a cap, a malicious or buggy chain could spiral.\n\nThe MCP gateway enforces `maxSamplingDepth = 3`. Each request from a handler that was itself invoked via sampling increments the counter. Exceeded → error `-32008 SamplingDepthExceeded`.\n\n## Capability gate\n\nNot every MCP client supports sampling. Before calling `ctx.sample`, check the capability:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n // Fall back: return something useful without the LLM.\n}\nconst result = await ctx.sample({ /* ... */ });\n```\n\nOr let the SDK throw `SamplingNotAvailableError` (error code `-32006`) and catch it. Pick whichever fits your UX.\n\n### Client compatibility\n\nSampling only works when the connected MCP client advertises `capabilities.sampling` during the MCP `initialize` handshake. Tesseron captures the client's capabilities at that point and flows them to every SDK session as `ctx.agentCapabilities.sampling` — so a handler always sees the real answer, even when a particular client (for example, Claude Code as of this writing) has not yet implemented `sampling/createMessage`. If a handler calls `ctx.sample()` anyway on such a client, the SDK throws a structured `SamplingNotAvailableError` including the client name (when available) instead of a raw JSON-RPC `-32601 Method not found`, so callers can branch on `error instanceof SamplingNotAvailableError` and return a graceful fallback.\n\nNext: [elicitation](/protocol/elicitation/) - same shape, but with the user instead of the model."},{"slug":"protocol/security","title":"Security model","description":"Origin allowlist, claim codes, multi-app namespacing, and the threats Tesseron does and does not defend against.","section":"protocol","related":["protocol/handshake","protocol/transport"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nTesseron's security model is **local-first, user-authorised**. The MCP gateway binds to localhost and won't expose any action until a human types a short code out-of-band. These are the two gates.\n\n## Gate 1 - loopback-only discovery\n\n<Diagram\n caption=\"Apps announce loopback URLs. The gateway only dials loopback. Nothing off the machine gets a connection.\"\n nodeWidth={200}\n spacing={80}\n nodes={[\n { id: 'app', label: 'YOUR APP', sub: 'binds loopback only', icon: 'window' },\n { id: 'file', label: 'INSTANCE', sub: '~/.tesseron/instances/', icon: 'bridge' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'transport client', icon: 'shield', variant: 'accent' },\n { id: 'evil', label: 'ATTACKER', sub: 'remote host', icon: 'x', variant: 'danger' },\n ]}\n edges={[\n { from: 'app', to: 'file', label: 'writes manifest', accent: true },\n { from: 'gw', to: 'file', label: 'reads', style: 'dashed' },\n { from: 'gw', to: 'app', label: 'dials advertised binding', accent: true },\n { from: 'evil', to: 'gw', label: 'no inbound port', danger: true, style: 'dashed' },\n ]}\n/>\n\nApps bind locally only - WebSocket servers on `127.0.0.1`, Unix domain sockets in private temp dirs. The gateway refuses non-loopback URLs read from `~/.tesseron/instances/` and rejects UDS paths it can't `connect()` to as the running user. The gateway itself binds no ports, so a remote attacker has nothing to dial. Every hop is on the machine.\n\nThis is defence-in-depth. A drive-by page on `evil.com` can't reach your app's server - it would have to resolve `127.0.0.1` from the browser's origin, which the Same-Origin Policy blocks by default, and even a successful connection attempt would still face the claim-code gate below.\n\n## Gate 2 - the claim code\n\nEven from a permitted origin, the session is inert until claimed. The flow:\n\n1. App connects and sends `tesseron/hello`.\n2. MCP gateway generates a random 6-char code (format `XXXX-YY`, excludes visually confusing characters) and returns it in `welcome`.\n3. The code is displayed out-of-band: gateway stderr, and wherever your app chooses to render it.\n4. The user types the code into the agent (\"connect Tesseron session AB3X-7K\").\n5. Agent calls `tesseron__claim_session`. If the code matches, the session transitions to `Claimed` and `tools/list_changed` fires.\n\nThe claim code is **never sent on the WebSocket from the gateway to the agent** - the user carries it across. That's the whole point: it's a human-performed authorisation gesture, not an electronic one.\n\nStrength: ~1.5 billion possible codes (6 positions × ~32 unambiguous alphanumerics), drawn uniformly from the platform CSPRNG (`crypto.getRandomValues`) with rejection sampling — not `Math.random()`, which is not cryptographically secure. A brute-force attacker would need ~750M tries for a 50% hit rate. Codes are single-use; a failed match does not retry.\n\n## Multi-app coexistence\n\n<Diagram\n caption=\"Two apps, one gateway. Tools are namespaced by app.id; actions route back to the declaring session.\"\n nodeWidth={200}\n spacing={80}\n nodes={[\n { id: 'appA', label: 'SHOP APP', sub: [\"app.id = 'shop'\", 'searchProducts, addItem'], icon: 'window' },\n { id: 'appB', label: 'ADMIN APP', sub: [\"app.id = 'admin'\", 'listUsers, banUser'], icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'routes by prefix', icon: 'bridge', variant: 'accent' },\n { id: 'agent', label: 'AGENT', sub: 'shop__* | admin__*', icon: 'agent' },\n ]}\n edges={[\n { from: 'appA', to: 'gw', label: 'hello [shop actions]' },\n { from: 'appB', to: 'gw', label: 'hello [admin actions]' },\n { from: 'gw', to: 'agent', label: 'tools/list', style: 'dashed', accent: true },\n { from: 'agent', to: 'gw', label: 'tools/call shop__addItem' },\n { from: 'gw', to: 'appA', label: 'actions/invoke' },\n { from: 'agent', to: 'gw', label: 'tools/call admin__banUser' },\n { from: 'gw', to: 'appB', label: 'actions/invoke' },\n ]}\n/>\n\nMultiple apps can be connected at once. Every MCP tool is prefixed with the `app.id`, so `shop__addItem` and `admin__banUser` never collide. Internally the gateway routes `tools/call name=shop__addItem` to the session whose `app.id === \"shop\"` - if that session has disconnected, the call errors with `-32003 ActionNotFound`.\n\nThis means you can, without coordinating, keep your dashboard and your customer app both connected to the same agent. Each has its own claim code, its own origin check, its own session.\n\n## What Tesseron does NOT defend against\n\n- **Malicious code running in your app's process.** If an attacker already executes JS in your tab or on your Node server, they can call your SDK and declare whatever actions they want. Tesseron is no worse - and no better - than the process it's embedded in.\n- **Malicious MCP clients on the same machine.** Any local process running as the same user can read `~/.tesseron/instances/` and dial one of the advertised endpoints. They would still need to send a valid `tesseron/hello` and convince the user to type the claim code into their agent. The claim code is the second gate, and it requires human cooperation - but it's the only thing stopping a rogue process from attaching to your session.\n- **Prompt injection.** If your handler's `description` or inputs are attacker-controlled, they can manipulate the agent's plans. Sanitise descriptions you show to the agent the same way you would sanitise HTML you show to users.\n- **Exfiltration via resources.** Anything you expose as a resource is readable by the claimed agent. Don't expose credentials, session tokens, or PII you haven't decided the user is okay sharing with Claude.\n\n## Operational tips\n\n- **Bind to `127.0.0.1`, never `0.0.0.0`.** The default in `@tesseron/server` and `@tesseron/vite` is loopback-only; don't override it unless you know exactly why. UDS hosts go through `os.tmpdir()` with a private (mode 0700) directory.\n- **`~/.tesseron/` files are written private (mode 0600 inside a 0700 directory).** Instance manifests and claim breadcrumbs are owner-only on POSIX. Sibling processes running as the same user can still open them — same-UID enforcement is the OS's job — but cross-user enumeration is closed. On Windows POSIX modes are advisory; the OS user model is the gate, same caveat as the UDS binding spec.\n- **Treat the claim code as short-lived.** Don't render it persistently in the UI after the session is claimed.\n- **Clean up instance manifests on app exit.** The built-in SDKs do this for you; if you port to a new runtime, handle the shutdown path so stale files don't pile up.\n- **Log the `agent.id` that claimed the session** - useful for auditing which agent actually ran which action.\n- **For production tools, use per-user app IDs.** `shop_kenny` vs `shop_sarah` prevents one user's agent from driving another user's tab, even if both are on the same machine.\n\nThat's the end of the Protocol section. The [SDK section](/sdk/) picks up from here - how to speak this protocol from TypeScript today and from other languages later.\n","bodyText":"Tesseron's security model is **local-first, user-authorised**. The MCP gateway binds to localhost and won't expose any action until a human types a short code out-of-band. These are the two gates.\n\n## Gate 1 - loopback-only discovery\n\nApps bind locally only - WebSocket servers on `127.0.0.1`, Unix domain sockets in private temp dirs. The gateway refuses non-loopback URLs read from `~/.tesseron/instances/` and rejects UDS paths it can't `connect()` to as the running user. The gateway itself binds no ports, so a remote attacker has nothing to dial. Every hop is on the machine.\n\nThis is defence-in-depth. A drive-by page on `evil.com` can't reach your app's server - it would have to resolve `127.0.0.1` from the browser's origin, which the Same-Origin Policy blocks by default, and even a successful connection attempt would still face the claim-code gate below.\n\n## Gate 2 - the claim code\n\nEven from a permitted origin, the session is inert until claimed. The flow:\n\n1. App connects and sends `tesseron/hello`.\n2. MCP gateway generates a random 6-char code (format `XXXX-YY`, excludes visually confusing characters) and returns it in `welcome`.\n3. The code is displayed out-of-band: gateway stderr, and wherever your app chooses to render it.\n4. The user types the code into the agent (\"connect Tesseron session AB3X-7K\").\n5. Agent calls `tesseron__claim_session`. If the code matches, the session transitions to `Claimed` and `tools/list_changed` fires.\n\nThe claim code is **never sent on the WebSocket from the gateway to the agent** - the user carries it across. That's the whole point: it's a human-performed authorisation gesture, not an electronic one.\n\nStrength: ~1.5 billion possible codes (6 positions × ~32 unambiguous alphanumerics), drawn uniformly from the platform CSPRNG (`crypto.getRandomValues`) with rejection sampling — not `Math.random()`, which is not cryptographically secure. A brute-force attacker would need ~750M tries for a 50% hit rate. Codes are single-use; a failed match does not retry.\n\n## Multi-app coexistence\n\nMultiple apps can be connected at once. Every MCP tool is prefixed with the `app.id`, so `shop__addItem` and `admin__banUser` never collide. Internally the gateway routes `tools/call name=shop__addItem` to the session whose `app.id === \"shop\"` - if that session has disconnected, the call errors with `-32003 ActionNotFound`.\n\nThis means you can, without coordinating, keep your dashboard and your customer app both connected to the same agent. Each has its own claim code, its own origin check, its own session.\n\n## What Tesseron does NOT defend against\n\n- **Malicious code running in your app's process.** If an attacker already executes JS in your tab or on your Node server, they can call your SDK and declare whatever actions they want. Tesseron is no worse - and no better - than the process it's embedded in.\n- **Malicious MCP clients on the same machine.** Any local process running as the same user can read `~/.tesseron/instances/` and dial one of the advertised endpoints. They would still need to send a valid `tesseron/hello` and convince the user to type the claim code into their agent. The claim code is the second gate, and it requires human cooperation - but it's the only thing stopping a rogue process from attaching to your session.\n- **Prompt injection.** If your handler's `description` or inputs are attacker-controlled, they can manipulate the agent's plans. Sanitise descriptions you show to the agent the same way you would sanitise HTML you show to users.\n- **Exfiltration via resources.** Anything you expose as a resource is readable by the claimed agent. Don't expose credentials, session tokens, or PII you haven't decided the user is okay sharing with Claude.\n\n## Operational tips\n\n- **Bind to `127.0.0.1`, never `0.0.0.0`.** The default in `@tesseron/server` and `@tesseron/vite` is loopback-only; don't override it unless you know exactly why. UDS hosts go through `os.tmpdir()` with a private (mode 0700) directory.\n- **`~/.tesseron/` files are written private (mode 0600 inside a 0700 directory).** Instance manifests and claim breadcrumbs are owner-only on POSIX. Sibling processes running as the same user can still open them — same-UID enforcement is the OS's job — but cross-user enumeration is closed. On Windows POSIX modes are advisory; the OS user model is the gate, same caveat as the UDS binding spec.\n- **Treat the claim code as short-lived.** Don't render it persistently in the UI after the session is claimed.\n- **Clean up instance manifests on app exit.** The built-in SDKs do this for you; if you port to a new runtime, handle the shutdown path so stale files don't pile up.\n- **Log the `agent.id` that claimed the session** - useful for auditing which agent actually ran which action.\n- **For production tools, use per-user app IDs.** `shop_kenny` vs `shop_sarah` prevents one user's agent from driving another user's tab, even if both are on the same machine.\n\nThat's the end of the Protocol section. The [SDK section](/sdk/) picks up from here - how to speak this protocol from TypeScript today and from other languages later."},{"slug":"protocol/transport","title":"Transport","description":"Tesseron speaks JSON-RPC 2.0 over any reliable, ordered, duplex channel. Bindings spec out concrete realisations.","section":"protocol","related":["protocol/transport-bindings/ws","protocol/transport-bindings/uds","protocol/handshake","protocol/wire-format","sdk/typescript/mcp"],"bodyRaw":"\n## What \"transport\" means here\n\nTesseron speaks **JSON-RPC 2.0 over a reliable, ordered, bidirectional channel**. That's the protocol-level commitment. Anything below it - WebSocket frames, Unix domain sockets, named pipes, in-memory pairs - is a **binding** the implementer picks. The MCP gateway dispatches to the right binding based on what the running app advertises in its instance manifest.\n\nThis page describes the contract every binding has to honour. The per-binding pages spec the wire details:\n\n- [WebSocket binding](/protocol/transport-bindings/ws/) - the default; browser apps use this via `@tesseron/vite`, Node apps use it via `@tesseron/server`'s `NodeWebSocketServerTransport`.\n- [Unix domain socket binding](/protocol/transport-bindings/uds/) - lower-overhead local IPC for Node apps that don't need a browser bridge. Linux + macOS in 1.1; Windows tracked separately.\n\nA new binding is a new instance-manifest discriminant plus a gateway dialer plus an SDK-side host transport. See [Port Tesseron to your language](/sdk/porting/) for the full conformance checklist.\n\n## Who binds, who dials\n\nApps bind. The gateway dials.\n\nEvery Tesseron app hosts its own endpoint - whatever shape the binding requires - and announces it by writing `~/.tesseron/instances/<instanceId>.json`:\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-mocythay-v0hh50\",\n \"appName\": \"node-prompts\",\n \"addedAt\": 1777038462692,\n \"pid\": 24837,\n \"transport\":\n | { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n | { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\nThe gateway watches that directory, reads each new file, picks the dialer matching `transport.kind`, and connects. The app accepts the one inbound connection; the standard handshake follows.\n\n`pid` is optional and identifies the SDK-side process that owns the instance. Gateways probe it with `process.kill(pid, 0)` and tombstone (unlink) manifests whose owner is gone, so a Vite dev server killed without a clean `httpServer.close` doesn't leave a corpse the gateway re-dials every poll tick. Manifests written by older SDKs (no `pid`) are still trusted.\n\nThere is no fixed gateway port. There is no `DEFAULT_GATEWAY_URL` apps dial out to. The gateway itself binds nothing.\n\n## What every binding has to do\n\nThe session/handshake/action layer cannot tell which binding it's running on. Every binding **must** preserve:\n\n- **Reliable, ordered delivery.** No best-effort, no reorderings, no gaps inside a session. TCP-ish guarantees.\n- **One JSON-RPC envelope per logical message.** No batching, no fragmentation visible to the protocol layer.\n- **Symmetric duplex.** Either side can send a request or a notification at any time; there is no fixed direction.\n- **Single connection per session.** `tesseron/hello` opens; close terminates the session (or zombifies it for [resume](/protocol/resume/)).\n- **Same-process / same-user threat model.** The binding is local IPC. Authentication is the [claim code](/protocol/handshake/) plus the OS's own user-isolation guarantees - origin enforcement on WS, file-mode-based UID gating on UDS.\n\nIf a binding can satisfy those, the rest of the protocol composes on top unchanged.\n\n## Compat: pre-1.1 `tabs/` directory\n\nApps built against TS SDKs at 1.0.x wrote v1 manifests to `~/.tesseron/tabs/<tabId>.json`:\n\n```json\n{ \"version\": 1, \"tabId\": \"tab-...\", \"appName\": \"...\", \"wsUrl\": \"ws://...\", \"addedAt\": 1777038462692 }\n```\n\nThe gateway at 1.1+ reads both `instances/` (v2) and `tabs/` (v1) for one minor version. v1 manifests are coerced to `{ kind: 'ws', url: <wsUrl> }` and dispatched to the WS dialer. New SDKs only ever write `instances/`. Drop scheduled for 2.0.\n\n## Heartbeat\n\nThere is no application-level ping. The protocol relies on the underlying binding (TCP keep-alive on WS, kernel-level UDS lifecycle) and per-action timeouts (60 s default) to detect dead peers. If your handler legitimately takes longer than 60 s, extend the timeout on the builder:\n\n```ts\ntesseron.action('bigReport').timeout({ ms: 300_000 }).input(...).handler(...);\n```\n\n## Reconnection\n\n**Reconnection is the app's responsibility, not the SDK's.** On transport close:\n\n- The SDK marks every pending request as failed with `TransportClosedError`.\n- Active invocations have their `AbortSignal` aborted.\n- Subscriptions are dropped.\n- The `sessionId` the gateway issued is gone unless the SDK resumes via [`tesseron/resume`](/protocol/resume/) inside the zombie TTL.\n\nTo recover: re-bind, write a fresh manifest, wait for the gateway to dial again. You will get a **new** `sessionId` and a **new** `claimCode` - the previous claim does not carry over unless you successfully resume.\n\n## Failure matrix\n\n| Event | App sees | MCP gateway does | Agent sees |\n|---|---|---|---|\n| Gateway shuts down cleanly | Channel close (binding-specific code) | Tears down outbound connections. | `tools/list_changed` drops those tools. |\n| Tab closes / app exits | - | Session removed, in-flight invocations cancelled, manifest cleaned up by the app. | `tools/list_changed`. |\n| Action timeout | `AbortSignal` fires with `TimeoutError`. | Error `-32002` returned. | Tool call errors with `-32002`. |\n| Agent cancels | `AbortSignal` fires. | Forwards `actions/cancel`. | Receives error `-32001`. |\n| Binding rejects connect | Bind/upgrade fails. | Gives up on this manifest (may retry on next watcher event). | N/A - session never existed. |\n\nNext: dig into a specific binding ([WebSocket](/protocol/transport-bindings/ws/), [UDS](/protocol/transport-bindings/uds/)) or read the [handshake and claim flow](/protocol/handshake/).\n","bodyText":"## What \"transport\" means here\n\nTesseron speaks **JSON-RPC 2.0 over a reliable, ordered, bidirectional channel**. That's the protocol-level commitment. Anything below it - WebSocket frames, Unix domain sockets, named pipes, in-memory pairs - is a **binding** the implementer picks. The MCP gateway dispatches to the right binding based on what the running app advertises in its instance manifest.\n\nThis page describes the contract every binding has to honour. The per-binding pages spec the wire details:\n\n- [WebSocket binding](/protocol/transport-bindings/ws/) - the default; browser apps use this via `@tesseron/vite`, Node apps use it via `@tesseron/server`'s `NodeWebSocketServerTransport`.\n- [Unix domain socket binding](/protocol/transport-bindings/uds/) - lower-overhead local IPC for Node apps that don't need a browser bridge. Linux + macOS in 1.1; Windows tracked separately.\n\nA new binding is a new instance-manifest discriminant plus a gateway dialer plus an SDK-side host transport. See [Port Tesseron to your language](/sdk/porting/) for the full conformance checklist.\n\n## Who binds, who dials\n\nApps bind. The gateway dials.\n\nEvery Tesseron app hosts its own endpoint - whatever shape the binding requires - and announces it by writing `~/.tesseron/instances/<instanceId>.json`:\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-mocythay-v0hh50\",\n \"appName\": \"node-prompts\",\n \"addedAt\": 1777038462692,\n \"pid\": 24837,\n \"transport\":\n | { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n | { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\nThe gateway watches that directory, reads each new file, picks the dialer matching `transport.kind`, and connects. The app accepts the one inbound connection; the standard handshake follows.\n\n`pid` is optional and identifies the SDK-side process that owns the instance. Gateways probe it with `process.kill(pid, 0)` and tombstone (unlink) manifests whose owner is gone, so a Vite dev server killed without a clean `httpServer.close` doesn't leave a corpse the gateway re-dials every poll tick. Manifests written by older SDKs (no `pid`) are still trusted.\n\nThere is no fixed gateway port. There is no `DEFAULT_GATEWAY_URL` apps dial out to. The gateway itself binds nothing.\n\n## What every binding has to do\n\nThe session/handshake/action layer cannot tell which binding it's running on. Every binding **must** preserve:\n\n- **Reliable, ordered delivery.** No best-effort, no reorderings, no gaps inside a session. TCP-ish guarantees.\n- **One JSON-RPC envelope per logical message.** No batching, no fragmentation visible to the protocol layer.\n- **Symmetric duplex.** Either side can send a request or a notification at any time; there is no fixed direction.\n- **Single connection per session.** `tesseron/hello` opens; close terminates the session (or zombifies it for [resume](/protocol/resume/)).\n- **Same-process / same-user threat model.** The binding is local IPC. Authentication is the [claim code](/protocol/handshake/) plus the OS's own user-isolation guarantees - origin enforcement on WS, file-mode-based UID gating on UDS.\n\nIf a binding can satisfy those, the rest of the protocol composes on top unchanged.\n\n## Compat: pre-1.1 `tabs/` directory\n\nApps built against TS SDKs at 1.0.x wrote v1 manifests to `~/.tesseron/tabs/<tabId>.json`:\n\n```json\n{ \"version\": 1, \"tabId\": \"tab-...\", \"appName\": \"...\", \"wsUrl\": \"ws://...\", \"addedAt\": 1777038462692 }\n```\n\nThe gateway at 1.1+ reads both `instances/` (v2) and `tabs/` (v1) for one minor version. v1 manifests are coerced to `{ kind: 'ws', url: <wsUrl> }` and dispatched to the WS dialer. New SDKs only ever write `instances/`. Drop scheduled for 2.0.\n\n## Heartbeat\n\nThere is no application-level ping. The protocol relies on the underlying binding (TCP keep-alive on WS, kernel-level UDS lifecycle) and per-action timeouts (60 s default) to detect dead peers. If your handler legitimately takes longer than 60 s, extend the timeout on the builder:\n\n```ts\ntesseron.action('bigReport').timeout({ ms: 300_000 }).input(...).handler(...);\n```\n\n## Reconnection\n\n**Reconnection is the app's responsibility, not the SDK's.** On transport close:\n\n- The SDK marks every pending request as failed with `TransportClosedError`.\n- Active invocations have their `AbortSignal` aborted.\n- Subscriptions are dropped.\n- The `sessionId` the gateway issued is gone unless the SDK resumes via [`tesseron/resume`](/protocol/resume/) inside the zombie TTL.\n\nTo recover: re-bind, write a fresh manifest, wait for the gateway to dial again. You will get a **new** `sessionId` and a **new** `claimCode` - the previous claim does not carry over unless you successfully resume.\n\n## Failure matrix\n\n| Event | App sees | MCP gateway does | Agent sees |\n|---|---|---|---|\n| Gateway shuts down cleanly | Channel close (binding-specific code) | Tears down outbound connections. | `tools/list_changed` drops those tools. |\n| Tab closes / app exits | - | Session removed, in-flight invocations cancelled, manifest cleaned up by the app. | `tools/list_changed`. |\n| Action timeout | `AbortSignal` fires with `TimeoutError`. | Error `-32002` returned. | Tool call errors with `-32002`. |\n| Agent cancels | `AbortSignal` fires. | Forwards `actions/cancel`. | Receives error `-32001`. |\n| Binding rejects connect | Bind/upgrade fails. | Gives up on this manifest (may retry on next watcher event). | N/A - session never existed. |\n\nNext: dig into a specific binding ([WebSocket](/protocol/transport-bindings/ws/), [UDS](/protocol/transport-bindings/uds/)) or read the [handshake and claim flow](/protocol/handshake/)."},{"slug":"protocol/transport-bindings/uds","title":"Unix domain socket binding","description":"NDJSON framing, file-mode-based UID enforcement, and lifecycle for the UDS transport binding.","section":"protocol","related":["protocol/transport","protocol/transport-bindings/ws","protocol/handshake","sdk/typescript/server"],"bodyRaw":"\nThe UDS binding speaks Tesseron over a Unix domain socket on the local filesystem. Lower per-message overhead than WebSocket and avoids the loopback TCP stack entirely. Available on Linux and macOS in 1.1; Windows tracks separately (see [Windows](#windows-known-limitations) below).\n\nThis page is the wire spec for that binding. The [transport overview](/protocol/transport/) covers the binding-neutral contract this binding satisfies.\n\n## Manifest discriminant\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-...\",\n \"appName\": \"...\",\n \"addedAt\": 1777038462692,\n \"transport\": { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\nThe `path` is the absolute filesystem path the gateway connects to. Apps SHOULD put the socket inside a per-process directory under `os.tmpdir()` (the reference SDK creates a `mkdtemp`-style 0700 dir, then binds `<dir>/sock` inside it). The directory mode is what gates same-UID access.\n\n## Framing\n\nNDJSON: one JSON-RPC envelope per **`\\n`-terminated line**.\n\n- Compact `JSON.stringify` never emits a raw `\\n` (newlines inside strings are escaped as `\\\\n`), so a line splitter recovers messages losslessly.\n- `JSON.stringify(msg) + '\\n'` on send.\n- Buffer inbound bytes and split on `\\n` on receive. Empty lines are ignored.\n- No batching, no fragmentation, no compression.\n\nThere is **no** subprotocol negotiation - bytes start flowing the moment `connect()` succeeds. The gateway sends `tesseron/hello` (or `tesseron/resume`) as its first message and the app responds.\n\n## Origin / access control\n\nApps **MUST** restrict the socket file so only the same UID can `connect()`. Two complementary mechanisms, both supported on Linux and macOS:\n\n1. **Parent directory mode `0700`.** Put the socket inside a private dir; the kernel's directory permission check rejects `connect()` from any other UID before it ever reaches the socket inode. The reference SDK does this via `mkdtemp` + `chmod 0700`.\n2. **Socket file mode `0600`.** Apply `chmod 0600` to the socket file itself after `bind()`. Belt-and-suspenders against directory misconfiguration; some kernels (macOS pre-10.10, Linux pre-3.9) ignore the socket-file mode and rely solely on the parent dir.\n\nThe threat model is identical to loopback WS plus the [claim code](/protocol/handshake/): any process running as the same OS user can connect; the [claim code](/protocol/handshake/) is what gates the privilege escalation from \"can talk to the socket\" to \"is bound to a session\". Cross-UID isolation is the OS's job.\n\n## Lifecycle\n\n- App creates a 0700 temp dir under `os.tmpdir()` (or wherever the OS lets the user write privately), `bind()`s a socket inside, optionally `chmod 0600`s the socket file.\n- App writes `~/.tesseron/instances/<instanceId>.json` with the path.\n- Gateway watches `~/.tesseron/instances/`, picks the manifest up, dials.\n- App accepts exactly one connection - the first peer wins; subsequent connect attempts are closed immediately.\n- On session close, the app deletes its manifest and the socket file, and removes the temp dir.\n\n## Failure matrix (UDS-specific)\n\n| Event | What you see | Notes |\n|---|---|---|\n| Same-host other-UID connect attempt | `EACCES` from `connect()` | The kernel rejects before any byte is exchanged. |\n| Stale socket file from prior run | `EADDRINUSE` on bind | Apps SHOULD `unlink` before `bind` if they pin a path. |\n| App crashes without cleanup | Stale manifest + stale socket file | Gateway dial hits `ECONNREFUSED`; manifest is harmless until manually swept. |\n| Gateway disconnects | `'close'` on the app side, no code | Treat as session end; rebind + re-announce to recover. |\n\n## Windows: known limitations\n\nWindows ≥ 1803 has an AF_UNIX implementation, but Node's `net.listen({ path })` on Windows actually creates a **named pipe** under the hood, not a filesystem socket. The path semantics differ (`\\\\.\\pipe\\<name>` instead of arbitrary filesystem paths) and the file-mode-based UID enforcement does not apply - Windows uses ACLs.\n\nThe 1.1 reference SDK skips the UDS binding on Windows. A separate `pipe` binding is tracked as follow-up work; until then, Windows apps should use the [WebSocket binding](./ws/).\n\n## SDK-side reference implementation\n\n- [`@tesseron/server` `UnixSocketServerTransport`](/sdk/typescript/server/) - select with `tesseron.connect({ transport: 'uds' })`.\n\n## Porting another language?\n\nImplement a UDS server that:\n\n1. Creates a private (mode `0700`) directory under `os.tmpdir()` (or equivalent), binds a socket inside.\n2. `chmod 0600`s the socket file after bind.\n3. Writes `~/.tesseron/instances/<instanceId>.json` with `{ kind: 'uds', path }`.\n4. Accepts exactly one connection; rejects subsequent connect attempts.\n5. Serialises outgoing JSON-RPC envelopes with `\\n` terminator; splits incoming bytes on `\\n`.\n6. Deletes its manifest, the socket file, and the temp dir on close.\n\nThe full conformance checklist lives in [Port Tesseron to your language](/sdk/porting/).\n","bodyText":"The UDS binding speaks Tesseron over a Unix domain socket on the local filesystem. Lower per-message overhead than WebSocket and avoids the loopback TCP stack entirely. Available on Linux and macOS in 1.1; Windows tracks separately (see [Windows](#windows-known-limitations) below).\n\nThis page is the wire spec for that binding. The [transport overview](/protocol/transport/) covers the binding-neutral contract this binding satisfies.\n\n## Manifest discriminant\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-...\",\n \"appName\": \"...\",\n \"addedAt\": 1777038462692,\n \"transport\": { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\nThe `path` is the absolute filesystem path the gateway connects to. Apps SHOULD put the socket inside a per-process directory under `os.tmpdir()` (the reference SDK creates a `mkdtemp`-style 0700 dir, then binds `<dir>/sock` inside it). The directory mode is what gates same-UID access.\n\n## Framing\n\nNDJSON: one JSON-RPC envelope per **`\\n`-terminated line**.\n\n- Compact `JSON.stringify` never emits a raw `\\n` (newlines inside strings are escaped as `\\\\n`), so a line splitter recovers messages losslessly.\n- `JSON.stringify(msg) + '\\n'` on send.\n- Buffer inbound bytes and split on `\\n` on receive. Empty lines are ignored.\n- No batching, no fragmentation, no compression.\n\nThere is **no** subprotocol negotiation - bytes start flowing the moment `connect()` succeeds. The gateway sends `tesseron/hello` (or `tesseron/resume`) as its first message and the app responds.\n\n## Origin / access control\n\nApps **MUST** restrict the socket file so only the same UID can `connect()`. Two complementary mechanisms, both supported on Linux and macOS:\n\n1. **Parent directory mode `0700`.** Put the socket inside a private dir; the kernel's directory permission check rejects `connect()` from any other UID before it ever reaches the socket inode. The reference SDK does this via `mkdtemp` + `chmod 0700`.\n2. **Socket file mode `0600`.** Apply `chmod 0600` to the socket file itself after `bind()`. Belt-and-suspenders against directory misconfiguration; some kernels (macOS pre-10.10, Linux pre-3.9) ignore the socket-file mode and rely solely on the parent dir.\n\nThe threat model is identical to loopback WS plus the [claim code](/protocol/handshake/): any process running as the same OS user can connect; the [claim code](/protocol/handshake/) is what gates the privilege escalation from \"can talk to the socket\" to \"is bound to a session\". Cross-UID isolation is the OS's job.\n\n## Lifecycle\n\n- App creates a 0700 temp dir under `os.tmpdir()` (or wherever the OS lets the user write privately), `bind()`s a socket inside, optionally `chmod 0600`s the socket file.\n- App writes `~/.tesseron/instances/<instanceId>.json` with the path.\n- Gateway watches `~/.tesseron/instances/`, picks the manifest up, dials.\n- App accepts exactly one connection - the first peer wins; subsequent connect attempts are closed immediately.\n- On session close, the app deletes its manifest and the socket file, and removes the temp dir.\n\n## Failure matrix (UDS-specific)\n\n| Event | What you see | Notes |\n|---|---|---|\n| Same-host other-UID connect attempt | `EACCES` from `connect()` | The kernel rejects before any byte is exchanged. |\n| Stale socket file from prior run | `EADDRINUSE` on bind | Apps SHOULD `unlink` before `bind` if they pin a path. |\n| App crashes without cleanup | Stale manifest + stale socket file | Gateway dial hits `ECONNREFUSED`; manifest is harmless until manually swept. |\n| Gateway disconnects | `'close'` on the app side, no code | Treat as session end; rebind + re-announce to recover. |\n\n## Windows: known limitations\n\nWindows ≥ 1803 has an AF_UNIX implementation, but Node's `net.listen({ path })` on Windows actually creates a **named pipe** under the hood, not a filesystem socket. The path semantics differ (`\\\\.\\pipe\\<name>` instead of arbitrary filesystem paths) and the file-mode-based UID enforcement does not apply - Windows uses ACLs.\n\nThe 1.1 reference SDK skips the UDS binding on Windows. A separate `pipe` binding is tracked as follow-up work; until then, Windows apps should use the [WebSocket binding](./ws/).\n\n## SDK-side reference implementation\n\n- [`@tesseron/server` `UnixSocketServerTransport`](/sdk/typescript/server/) - select with `tesseron.connect({ transport: 'uds' })`.\n\n## Porting another language?\n\nImplement a UDS server that:\n\n1. Creates a private (mode `0700`) directory under `os.tmpdir()` (or equivalent), binds a socket inside.\n2. `chmod 0600`s the socket file after bind.\n3. Writes `~/.tesseron/instances/<instanceId>.json` with `{ kind: 'uds', path }`.\n4. Accepts exactly one connection; rejects subsequent connect attempts.\n5. Serialises outgoing JSON-RPC envelopes with `\\n` terminator; splits incoming bytes on `\\n`.\n6. Deletes its manifest, the socket file, and the temp dir on close.\n\nThe full conformance checklist lives in [Port Tesseron to your language](/sdk/porting/)."},{"slug":"protocol/transport-bindings/ws","title":"WebSocket binding","description":"URL, framing, subprotocol, origin enforcement, and reconnection rules for the WebSocket transport binding.","section":"protocol","related":["protocol/transport","protocol/transport-bindings/uds","protocol/handshake","protocol/wire-format","sdk/typescript/server","sdk/typescript/web"],"bodyRaw":"\nThe WebSocket binding is the default Tesseron transport. Browser apps use it via the `@tesseron/vite` plugin, Node apps via `@tesseron/server`'s `NodeWebSocketServerTransport`. The MCP gateway dials with the `tesseron-gateway` subprotocol.\n\nThis page is the wire spec for that binding. The [transport overview](/protocol/transport/) covers the binding-neutral contract - reliable, ordered, single-connection-per-session, etc. - that this binding satisfies.\n\n## Manifest discriminant\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-...\",\n \"appName\": \"...\",\n \"addedAt\": 1777038462692,\n \"transport\": { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n}\n```\n\nThe `url` is what the gateway dials. Apps **MUST** bind to loopback (`127.0.0.1` or `::1`) - the threat model assumes same-host-same-user access.\n\n## Framing\n\n- One JSON-RPC envelope per WebSocket text frame.\n- `JSON.stringify` on send, `JSON.parse` on receive.\n- Binary frames are coerced to UTF-8 text and parsed anyway (defensive — gateway compatibility with non-conforming relays).\n- No fragmentation, no batching, no compression.\n\n## Subprotocol handshake\n\nThe gateway sends `Sec-WebSocket-Protocol: tesseron-gateway` on its upgrade request. Apps that host a Tesseron WS server **MUST** advertise this subprotocol in their handshake response and **MUST** reject upgrade requests that don't carry it - the app's WebSocket endpoint is only for the gateway, not for arbitrary clients.\n\nThe Vite plugin is the documented exception: it accepts plain (no-subprotocol) connections from the browser tab AND a separate `tesseron-gateway` connection from the gateway, and bridges them.\n\n## Origin enforcement\n\nWS upgrades carry an `Origin` header. The gateway treats whatever the upgrade request advertised as the authoritative origin for the lifetime of the session. SDK-declared `app.origin` values that disagree are overwritten with the upgrade-time value at `tesseron/hello` and `tesseron/resume`.\n\nApps that want stronger gating can install an `origin allowlist` in their HTTP server before the WS upgrade fires. The reference SDK leaves this to the app.\n\n## Reconnection\n\nSame as the binding-neutral [transport rules](/protocol/transport/#reconnection): close kills the session, the SDK rejects pending requests with `TransportClosedError`, and reconnection is the app's job. Use [`tesseron/resume`](/protocol/resume/) to rejoin a zombified session within its TTL.\n\n## Failure matrix (WS-specific)\n\n| Event | Code observed | Notes |\n|---|---|---|\n| Gateway shuts down cleanly | `1001 Going Away` | Standard WS close code. |\n| Bad subprotocol | Upgrade fails before WS open | Gateway gives up on this manifest until next watcher event. |\n| App rejects gateway origin | App's choice — typically 4xx | Any non-101 response means no session. |\n| Browser tab close (Vite) | Plugin tears down both sides | Manifest deleted, gateway sees normal `close`. |\n\n## SDK-side reference implementations\n\n- [`@tesseron/server` `NodeWebSocketServerTransport`](/sdk/typescript/server/) - Node apps host a loopback `ws://...` and write `instances/`.\n- [`@tesseron/web` `BrowserWebSocketTransport`](/sdk/typescript/web/) - browser apps dial `/@tesseron/ws` (served by `@tesseron/vite`).\n- [`@tesseron/vite`](/sdk/typescript/vite/) - dev-server bridge between the browser tab and the gateway.\n\n## Porting another language?\n\nImplement a WS server that:\n\n1. Binds loopback on an OS-picked port (or a pinned port if your runtime requires it).\n2. Writes `~/.tesseron/instances/<instanceId>.json` with `{ kind: 'ws', url }`.\n3. Accepts exactly one upgrade carrying the `tesseron-gateway` subprotocol; rejects every other upgrade.\n4. Serialises outgoing JSON-RPC envelopes as text frames; parses incoming text frames.\n5. Deletes its manifest on close.\n\nThe full conformance checklist lives in [Port Tesseron to your language](/sdk/porting/).\n","bodyText":"The WebSocket binding is the default Tesseron transport. Browser apps use it via the `@tesseron/vite` plugin, Node apps via `@tesseron/server`'s `NodeWebSocketServerTransport`. The MCP gateway dials with the `tesseron-gateway` subprotocol.\n\nThis page is the wire spec for that binding. The [transport overview](/protocol/transport/) covers the binding-neutral contract - reliable, ordered, single-connection-per-session, etc. - that this binding satisfies.\n\n## Manifest discriminant\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-...\",\n \"appName\": \"...\",\n \"addedAt\": 1777038462692,\n \"transport\": { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n}\n```\n\nThe `url` is what the gateway dials. Apps **MUST** bind to loopback (`127.0.0.1` or `::1`) - the threat model assumes same-host-same-user access.\n\n## Framing\n\n- One JSON-RPC envelope per WebSocket text frame.\n- `JSON.stringify` on send, `JSON.parse` on receive.\n- Binary frames are coerced to UTF-8 text and parsed anyway (defensive — gateway compatibility with non-conforming relays).\n- No fragmentation, no batching, no compression.\n\n## Subprotocol handshake\n\nThe gateway sends `Sec-WebSocket-Protocol: tesseron-gateway` on its upgrade request. Apps that host a Tesseron WS server **MUST** advertise this subprotocol in their handshake response and **MUST** reject upgrade requests that don't carry it - the app's WebSocket endpoint is only for the gateway, not for arbitrary clients.\n\nThe Vite plugin is the documented exception: it accepts plain (no-subprotocol) connections from the browser tab AND a separate `tesseron-gateway` connection from the gateway, and bridges them.\n\n## Origin enforcement\n\nWS upgrades carry an `Origin` header. The gateway treats whatever the upgrade request advertised as the authoritative origin for the lifetime of the session. SDK-declared `app.origin` values that disagree are overwritten with the upgrade-time value at `tesseron/hello` and `tesseron/resume`.\n\nApps that want stronger gating can install an `origin allowlist` in their HTTP server before the WS upgrade fires. The reference SDK leaves this to the app.\n\n## Reconnection\n\nSame as the binding-neutral [transport rules](/protocol/transport/#reconnection): close kills the session, the SDK rejects pending requests with `TransportClosedError`, and reconnection is the app's job. Use [`tesseron/resume`](/protocol/resume/) to rejoin a zombified session within its TTL.\n\n## Failure matrix (WS-specific)\n\n| Event | Code observed | Notes |\n|---|---|---|\n| Gateway shuts down cleanly | `1001 Going Away` | Standard WS close code. |\n| Bad subprotocol | Upgrade fails before WS open | Gateway gives up on this manifest until next watcher event. |\n| App rejects gateway origin | App's choice — typically 4xx | Any non-101 response means no session. |\n| Browser tab close (Vite) | Plugin tears down both sides | Manifest deleted, gateway sees normal `close`. |\n\n## SDK-side reference implementations\n\n- [`@tesseron/server` `NodeWebSocketServerTransport`](/sdk/typescript/server/) - Node apps host a loopback `ws://...` and write `instances/`.\n- [`@tesseron/web` `BrowserWebSocketTransport`](/sdk/typescript/web/) - browser apps dial `/@tesseron/ws` (served by `@tesseron/vite`).\n- [`@tesseron/vite`](/sdk/typescript/vite/) - dev-server bridge between the browser tab and the gateway.\n\n## Porting another language?\n\nImplement a WS server that:\n\n1. Binds loopback on an OS-picked port (or a pinned port if your runtime requires it).\n2. Writes `~/.tesseron/instances/<instanceId>.json` with `{ kind: 'ws', url }`.\n3. Accepts exactly one upgrade carrying the `tesseron-gateway` subprotocol; rejects every other upgrade.\n4. Serialises outgoing JSON-RPC envelopes as text frames; parses incoming text frames.\n5. Deletes its manifest on close.\n\nThe full conformance checklist lives in [Port Tesseron to your language](/sdk/porting/)."},{"slug":"protocol/wire-format","title":"Wire format (JSON-RPC)","description":"The envelope shapes Tesseron uses, the full method surface in both directions, and the ID-correlation rules.","section":"protocol","related":["protocol/transport","protocol/handshake","protocol/errors","protocol/actions"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nTesseron's app ↔ MCP gateway hop is **JSON-RPC 2.0**, one JSON object per WebSocket text frame. No batching, no binary, no compression - one message, one parse.\n\n<Diagram\n caption=\"Four envelope shapes. Every message on the wire is exactly one of these.\"\n nodeWidth={190}\n nodeHeight={150}\n spacing={50}\n nodes={[\n { id: 'req', label: 'REQUEST', sub: ['id + method', 'expects response'], code: 'jsonrpc: \"2.0\"', icon: 'arrow' },\n { id: 'ntf', label: 'NOTIFICATION', sub: ['method only', 'fire-and-forget'], code: 'no id', icon: 'arrow' },\n { id: 'ok', label: 'SUCCESS', sub: ['echoes request id', 'carries result'], code: 'result: R', icon: 'check', variant: 'accent' },\n { id: 'err', label: 'ERROR', sub: ['echoes request id', 'carries error object'], code: 'error: {...}', icon: 'x', variant: 'danger' },\n ]}\n edges={[]}\n/>\n\n## Envelope shapes\n\n### Request (expects a response)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"method\": \"actions/invoke\",\n \"params\": { /* method-specific */ }\n}\n```\n\n### Notification (fire-and-forget)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": { \"invocationId\": \"inv_1\", \"percent\": 40 }\n}\n```\n\n### Success response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"result\": { /* method-specific payload */ }\n}\n```\n\n### Error response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"error\": { \"code\": -32004, \"message\": \"Invalid input\", \"data\": [/* issues */] }\n}\n```\n\n`id` can be a string, number, or `null`. The SDK uses monotonically incrementing integers per connection; any JSON-RPC-compliant peer is welcome to do otherwise.\n\n## Method surface\n\n### App → Gateway (you send)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `tesseron/hello` | request | Register app, actions, resources, capabilities. First message. |\n| `actions/progress` | notification | Streaming update during an invocation. |\n| `actions/list_changed` | notification | App (re)registered / removed an action after hello. |\n| `resources/updated` | notification | Push a new value to a subscriber. |\n| `resources/list_changed` | notification | App (re)registered / removed a resource after hello. |\n| `sampling/request` | request | Ask the agent to run an LLM step. |\n| `elicitation/request` | request | Ask the user (confirm or elicit) via the agent UI. |\n| `log` | notification | Structured log forwarded to MCP logging. |\n\nPlus: the **response** for any `actions/invoke`, `resources/read`, `resources/subscribe`, `resources/unsubscribe` the gateway sent you.\n\n### Gateway → App (you handle)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `actions/invoke` | request | Agent called an action. Respond with `result` or `error`. |\n| `actions/cancel` | notification | Agent cancelled an in-flight invocation. |\n| `resources/read` | request | Agent requested current resource value. |\n| `resources/subscribe` | request | Agent subscribed to future updates. |\n| `resources/unsubscribe` | request | Agent unsubscribed. |\n\nAnd the **response** to the `tesseron/hello` you sent.\n\n## ID correlation\n\n- A peer that issues a request assigns the `id`. The other peer echoes the exact same `id` in the response.\n- The SDK keeps a `Map<id, { resolve, reject, timeoutHandle }>` of pending outbound requests. On response it looks up the id, clears the timer, and settles the promise.\n- On transport close, **every pending request is rejected** with `TransportClosedError`. There is no resumable queue; reconnect means re-send.\n- Notifications have no `id` - they never fail visibly and never get a response. Don't send data you care about as a notification.\n\n## Framing\n\n- Each JSON-RPC object is serialized with `JSON.stringify` and sent as **one text frame**.\n- Binary frames sent by the peer are coerced to text and parsed - tolerated but not idiomatic.\n- There is **no length prefix and no framing header**. WebSocket gives us message boundaries for free.\n- There is **no batching**. Every message is self-contained.\n\n## Versioning\n\n`tesseron/hello` includes `protocolVersion: \"1.1.0\"`. The gateway parses it as `major.minor`: a major mismatch is rejected with `-32000 ProtocolMismatch` and the channel is closed, a minor mismatch is accepted with a stderr warning (newer fields may be silently dropped), an exact match is silent. See [Handshake](/protocol/handshake/#protocol-version-mismatch).\n\nNext: how that WebSocket gets established - [Transport](/protocol/transport/).\n","bodyText":"Tesseron's app ↔ MCP gateway hop is **JSON-RPC 2.0**, one JSON object per WebSocket text frame. No batching, no binary, no compression - one message, one parse.\n\n## Envelope shapes\n\n### Request (expects a response)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"method\": \"actions/invoke\",\n \"params\": { /* method-specific */ }\n}\n```\n\n### Notification (fire-and-forget)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": { \"invocationId\": \"inv_1\", \"percent\": 40 }\n}\n```\n\n### Success response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"result\": { /* method-specific payload */ }\n}\n```\n\n### Error response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"error\": { \"code\": -32004, \"message\": \"Invalid input\", \"data\": [/* issues */] }\n}\n```\n\n`id` can be a string, number, or `null`. The SDK uses monotonically incrementing integers per connection; any JSON-RPC-compliant peer is welcome to do otherwise.\n\n## Method surface\n\n### App → Gateway (you send)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `tesseron/hello` | request | Register app, actions, resources, capabilities. First message. |\n| `actions/progress` | notification | Streaming update during an invocation. |\n| `actions/list_changed` | notification | App (re)registered / removed an action after hello. |\n| `resources/updated` | notification | Push a new value to a subscriber. |\n| `resources/list_changed` | notification | App (re)registered / removed a resource after hello. |\n| `sampling/request` | request | Ask the agent to run an LLM step. |\n| `elicitation/request` | request | Ask the user (confirm or elicit) via the agent UI. |\n| `log` | notification | Structured log forwarded to MCP logging. |\n\nPlus: the **response** for any `actions/invoke`, `resources/read`, `resources/subscribe`, `resources/unsubscribe` the gateway sent you.\n\n### Gateway → App (you handle)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `actions/invoke` | request | Agent called an action. Respond with `result` or `error`. |\n| `actions/cancel` | notification | Agent cancelled an in-flight invocation. |\n| `resources/read` | request | Agent requested current resource value. |\n| `resources/subscribe` | request | Agent subscribed to future updates. |\n| `resources/unsubscribe` | request | Agent unsubscribed. |\n\nAnd the **response** to the `tesseron/hello` you sent.\n\n## ID correlation\n\n- A peer that issues a request assigns the `id`. The other peer echoes the exact same `id` in the response.\n- The SDK keeps a `Map<id, { resolve, reject, timeoutHandle }>` of pending outbound requests. On response it looks up the id, clears the timer, and settles the promise.\n- On transport close, **every pending request is rejected** with `TransportClosedError`. There is no resumable queue; reconnect means re-send.\n- Notifications have no `id` - they never fail visibly and never get a response. Don't send data you care about as a notification.\n\n## Framing\n\n- Each JSON-RPC object is serialized with `JSON.stringify` and sent as **one text frame**.\n- Binary frames sent by the peer are coerced to text and parsed - tolerated but not idiomatic.\n- There is **no length prefix and no framing header**. WebSocket gives us message boundaries for free.\n- There is **no batching**. Every message is self-contained.\n\n## Versioning\n\n`tesseron/hello` includes `protocolVersion: \"1.1.0\"`. The gateway parses it as `major.minor`: a major mismatch is rejected with `-32000 ProtocolMismatch` and the channel is closed, a minor mismatch is accepted with a stderr warning (newer fields may be silently dropped), an exact match is silent. See [Handshake](/protocol/handshake/#protocol-version-mismatch).\n\nNext: how that WebSocket gets established - [Transport](/protocol/transport/)."},{"slug":"sdk/index","title":"SDK overview","description":"What a Tesseron SDK has to expose - in TypeScript today, in any other language tomorrow.","section":"sdk","related":["sdk/porting","sdk/typescript/index","protocol/index"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Mermaid from '../../../components/Mermaid.astro';\n\nAn SDK is the part of Tesseron that lives in **your** process. It serialises outgoing JSON-RPC, dispatches incoming method calls into your handlers, and wraps the per-invocation protocol glue (progress, cancel, sample, elicit) in a shape that feels natural in the host language.\n\nToday we ship five TypeScript packages. The surface they expose - the **SDK contract** - is the portable part. A Python or Go implementation reuses the same concepts.\n\n## The shipped TypeScript SDK\n\n<Mermaid\n caption=\"Five packages. core owns the protocol types and builder; the others wrap transport and framework integration.\"\n code={`\nflowchart LR\n core[\"@tesseron/core<br/>action & resource builders<br/>JSON-RPC dispatcher<br/>protocol types\"]\n web[\"@tesseron/web<br/>browser client<br/>WebSocket transport\"]\n server[\"@tesseron/server<br/>Node client<br/>ws transport\"]\n react[\"@tesseron/react<br/>useTesseronAction<br/>useTesseronResource<br/>useTesseronConnection\"]\n mcp[\"@tesseron/mcp<br/>MCP gateway<br/>MCP stdio bridge\"]\n web -- \"re-exports\" --> core\n server -- \"re-exports\" --> core\n react -- \"wraps\" --> web\n mcp -. \"shared types\" .-> core\n`}\n/>\n\n<CardGrid>\n <LinkCard title=\"Quickstart\" href=\"./typescript/\"\n description=\"Install one package, declare one action, connect.\" />\n <LinkCard title=\"@tesseron/core\" href=\"./typescript/core/\"\n description=\"Action & resource builders, JSON-RPC dispatcher, protocol types. Zero runtime deps beyond Standard Schema.\" />\n <LinkCard title=\"@tesseron/web\" href=\"./typescript/web/\"\n description=\"Browser WebSocket transport + singleton client.\" />\n <LinkCard title=\"@tesseron/server\" href=\"./typescript/server/\"\n description=\"Node `ws`-backed transport + singleton client.\" />\n <LinkCard title=\"@tesseron/react\" href=\"./typescript/react/\"\n description=\"`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`.\" />\n <LinkCard title=\"@tesseron/mcp\" href=\"./typescript/mcp/\"\n description=\"The MCP gateway itself. CLI, bundled into the Claude Code plugin.\" />\n</CardGrid>\n\n## The portable SDK contract\n\nWhatever language you implement Tesseron in, the SDK has to expose these primitives. They correspond 1:1 with the [protocol](/protocol/).\n\n| Primitive | In TypeScript | Covers |\n|---|---|---|\n| **Client lifecycle** | `tesseron.app({ id, name, … })` + `tesseron.connect()` | Handshake, session ID, claim code. |\n| **Action builder** | `.action(name).describe(…).input(…).output(…).handler(fn)` | Declaring a named, typed, handler-backed action. |\n| **Resource builder** | `.resource(name).read(fn).subscribe(emitter)` | Declaring readable + optionally subscribable state. |\n| **Standard Schema bridge** | Accepts any `StandardSchemaV1<T>` validator (Zod, Valibot, ArkType, …) | Input / output / sampling / elicitation validation. |\n| **Invocation context** | `(input, ctx)` passed to every handler | `ctx.signal`, `ctx.progress`, `ctx.sample`, `ctx.confirm`, `ctx.elicit`, `ctx.log`, `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`. |\n| **Transport abstraction** | `Transport { send, onMessage, onClose, close }` | WebSocket in practice, but the protocol is transport-agnostic. |\n| **JSON-RPC dispatcher** | `JsonRpcDispatcher` | Request/notification handling, ID correlation, timeout, error mapping. |\n| **Structured error model** | `TesseronError(code, message, data?)` | Mapping to / from JSON-RPC error objects with the error codes in the [catalog](/protocol/errors/). |\n\n## Other SDKs\n\n<CardGrid>\n <LinkCard title=\"Python SDK (planned)\" href=\"./python/\"\n description=\"Status, intended shape, timeline.\" />\n <LinkCard title=\"Port Tesseron to your language\" href=\"./porting/\"\n description=\"Step-by-step guide, protocol conformance checklist, test strategy.\" />\n</CardGrid>\n","bodyText":"An SDK is the part of Tesseron that lives in **your** process. It serialises outgoing JSON-RPC, dispatches incoming method calls into your handlers, and wraps the per-invocation protocol glue (progress, cancel, sample, elicit) in a shape that feels natural in the host language.\n\nToday we ship five TypeScript packages. The surface they expose - the **SDK contract** - is the portable part. A Python or Go implementation reuses the same concepts.\n\n## The shipped TypeScript SDK\n\naction & resource builders<br/>JSON-RPC dispatcher<br/>protocol types\"]\n web[\"@tesseron/web<br/>browser client<br/>WebSocket transport\"]\n server[\"@tesseron/server<br/>Node client<br/>ws transport\"]\n react[\"@tesseron/react<br/>useTesseronAction<br/>useTesseronResource<br/>useTesseronConnection\"]\n mcp[\"@tesseron/mcp<br/>MCP gateway<br/>MCP stdio bridge\"]\n web -- \"re-exports\" --> core\n server -- \"re-exports\" --> core\n react -- \"wraps\" --> web\n mcp -. \"shared types\" .-> core\n`}\n/>\n\n## The portable SDK contract\n\nWhatever language you implement Tesseron in, the SDK has to expose these primitives. They correspond 1:1 with the [protocol](/protocol/).\n\n| Primitive | In TypeScript | Covers |\n|---|---|---|\n| **Client lifecycle** | `tesseron.app({ id, name, … })` + `tesseron.connect()` | Handshake, session ID, claim code. |\n| **Action builder** | `.action(name).describe(…).input(…).output(…).handler(fn)` | Declaring a named, typed, handler-backed action. |\n| **Resource builder** | `.resource(name).read(fn).subscribe(emitter)` | Declaring readable + optionally subscribable state. |\n| **Standard Schema bridge** | Accepts any `StandardSchemaV1<T>` validator (Zod, Valibot, ArkType, …) | Input / output / sampling / elicitation validation. |\n| **Invocation context** | `(input, ctx)` passed to every handler | `ctx.signal`, `ctx.progress`, `ctx.sample`, `ctx.confirm`, `ctx.elicit`, `ctx.log`, `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`. |\n| **Transport abstraction** | `Transport { send, onMessage, onClose, close }` | WebSocket in practice, but the protocol is transport-agnostic. |\n| **JSON-RPC dispatcher** | `JsonRpcDispatcher` | Request/notification handling, ID correlation, timeout, error mapping. |\n| **Structured error model** | `TesseronError(code, message, data?)` | Mapping to / from JSON-RPC error objects with the error codes in the [catalog](/protocol/errors/). |\n\n## Other SDKs"},{"slug":"sdk/porting","title":"Port Tesseron to your language","description":"Step-by-step guide to writing a new Tesseron SDK and a conformance checklist for testing it.","section":"sdk","related":["sdk/index","protocol/index","protocol/wire-format","sdk/typescript/core"],"bodyRaw":"\nTesseron's wire protocol is small enough that a competent engineer can implement an SDK for a new language in a couple of days. This page is your map.\n\n## What you're actually building\n\nA Tesseron SDK is three things glued together:\n\n1. **A WebSocket client** that speaks JSON-RPC 2.0.\n2. **A builder DSL** for declaring actions and resources.\n3. **A bridge** between the JSON-RPC dispatcher and the builder's registered handlers.\n\nThe full [protocol spec](/protocol/) is the source of truth. If anything on this page contradicts it, the protocol wins.\n\n## Step 1 - pick a runtime model\n\nTwo choices decide the shape of everything that follows.\n\n- **Async model.** Native `async`/`await` / futures / goroutines - whatever your language uses for concurrency. All SDK methods that may block (connect, sample, elicit, progress-waiting action handlers) should be async. Synchronous handlers are fine, but the client's I/O loop must not be.\n- **Schema library.** You need a way for users to express typed inputs. Pick one well-known library (Pydantic in Python, `go-playground/validator` in Go, Serde+schemars in Rust), and accept any user-provided schema that can round-trip to JSON Schema.\n\n## Step 2 - model the protocol types\n\nPort these from the [wire format page](/protocol/wire-format/):\n\n- JSON-RPC 2.0 request / notification / success / error envelopes.\n- `HelloParams`, `WelcomeResult`, `ActionAnnotations`, `InvokeParams`, `ProgressParams`, `CancelParams`, `SampleParams`, `ElicitParams`, `ReadParams`, `SubscribeParams`, `UpdatedParams`.\n- The error-code enum from the [errors page](/protocol/errors/).\n\nGive the error codes first-class names. Don't pass bare integers around - they accumulate magic.\n\n## Step 3 - write the dispatcher\n\nA bidirectional JSON-RPC dispatcher with:\n\n- `on(method, handler)` - respond to incoming requests.\n- `onNotification(method, handler)` - respond to incoming notifications.\n- `request(method, params, { timeoutMs })` - send a request, await the response. ID generation, timeout handling, rejection on close.\n- `notify(method, params)` - send a fire-and-forget notification.\n- `receive(message)` - given a parsed JSON-RPC envelope, dispatch to a handler or resolve a pending request.\n\nTest this in isolation against a pair of in-memory dispatchers. No networking yet.\n\n## Step 4 - write the transport\n\nYou're picking a **binding** (which wire format) and writing the SDK-side host. Tesseron's protocol layer is binding-neutral; pick from the [documented bindings](/protocol/transport/) or design a new one.\n\nFor a WebSocket binding:\n\n- Bind `127.0.0.1` on an OS-picked port.\n- Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'ws', url } }` where `url` is the URL just bound.\n- Accept exactly one upgrade request that advertises the `tesseron-gateway` WebSocket subprotocol; reject every other attempt.\n- Serialise outgoing objects with the language's standard JSON library and parse incoming text frames as JSON.\n- Delete the manifest on close.\n\nFor a UDS binding (Linux / macOS):\n\n- Create a private (mode `0700`) directory under `os.tmpdir()`-equivalent. Bind a socket inside it, `chmod 0600` the socket file.\n- Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'uds', path } }`.\n- Accept exactly one connection; reject subsequent connect attempts.\n- Frame messages as NDJSON (`JSON.stringify(msg) + '\\n'`); split incoming bytes on `\\n`.\n- Delete the manifest, the socket file, and the temp dir on close.\n\nThe gateway is always the **client** - it watches `~/.tesseron/instances/`, picks a dialer matching `transport.kind`, and connects. Your runtime never opens an outbound connection; it binds, announces, and waits.\n\nTo add a binding the gateway doesn't yet know about, you also need to ship a `GatewayDialer` for the new `kind` (in TypeScript: `packages/mcp/src/dialer.ts`) and document the wire format under `/protocol/transport-bindings/<kind>/`.\n\nDon't reinvent backoff or reconnect inside the transport - that's the user's job.\n\n## Step 5 - builder DSL\n\nWhatever shape is idiomatic. In TypeScript we use a fluent builder (`action(...).describe(...).input(...).handler(...)`). In Python, decorators. In Rust, probably a struct with a method-chain pattern. What matters is that it ultimately produces an `ActionDefinition`:\n\n```\nActionDefinition {\n name: string;\n description?: string;\n inputSchema?: StandardJsonSchema;\n outputSchema?: StandardJsonSchema;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput: boolean;\n handler: (input, ctx) => output;\n}\n```\n\nSame for `ResourceDefinition`.\n\n## Step 6 - bind it together\n\n```\nclass TesseronClient {\n constructor(transport, dispatcher) { … }\n app(info) { … } // records app manifest for hello\n action(name) { return new Builder(this, name) }\n resource(name) { return new ResBuilder(this, name) }\n async connect() {\n await transport.open();\n dispatcher.on('actions/invoke', this._onInvoke);\n dispatcher.onNotification('actions/cancel', this._onCancel);\n dispatcher.on('resources/read', this._onRead);\n dispatcher.on('resources/subscribe', this._onSub);\n dispatcher.on('resources/unsubscribe', this._onUnsub);\n return await dispatcher.request('tesseron/hello', this._manifest());\n }\n}\n```\n\nEach `on(...)` handler maps to the corresponding builder. Implement progress / sample / elicit / log on the `ActionContext` the same way.\n\n## Step 7 - conformance checklist\n\nBefore you ship, make sure the SDK passes every line of this list. An SDK that fails any line is not Tesseron-compliant.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after the binding's connection becomes ready.\n- [ ] Sends `protocolVersion = \"1.1.0\"` exactly.\n- [ ] Sends `app.id` that matches `/^[a-z][a-z0-9_]*$/`.\n- [ ] Surfaces `welcome.claimCode` to the caller (stdout, event, return value - your choice).\n- [ ] Surfaces `welcome.capabilities` as the authoritative agent capability set to handlers.\n\n**Actions**\n- [ ] Validates action input against the Standard-Schema-equivalent schema before the handler runs.\n- [ ] Returns `-32004 InputValidation` with issues on failure.\n- [ ] Passes output through unchanged by default; validates and returns `-32005` when strict output is enabled and validation fails.\n- [ ] Supports per-invocation timeouts, default 60 000 ms, configurable per action.\n- [ ] Aborts via idiomatic cancellation primitive when the MCP gateway sends `actions/cancel`.\n- [ ] Returns `-32001 Cancelled` on explicit cancel; `-32002 Timeout` on timer.\n- [ ] Emits `actions/progress` notifications from `ctx.progress(...)`.\n\n**Sampling / Confirmation / Elicitation**\n- [ ] Sends `sampling/request` / `elicitation/request` as requests (not notifications).\n- [ ] `ctx.confirm` sends an elicit with an empty-properties object schema and collapses decline / cancel / missing-capability to `false`.\n- [ ] `ctx.elicit` validates the response against the supplied Standard Schema and returns `null` on decline / cancel.\n- [ ] Raises a typed error (`SamplingNotAvailable`, `ElicitationNotAvailable`) when capabilities don't include them - except `ctx.confirm`, which swallows missing elicitation and returns `false`.\n- [ ] Rejects top-level non-object / `oneOf` / `anyOf` / nested-object elicit schemas with `-32602 InvalidParams` at the call site.\n- [ ] Caps sampling depth at 3 (or honours the gateway's cap).\n\n**Resources**\n- [ ] Responds to `resources/read` with `{ value }`.\n- [ ] Accepts `resources/subscribe` and returns the emitter callback's unsubscribe.\n- [ ] Sends `resources/updated` notifications on change.\n- [ ] Cleans up subscriptions on `resources/unsubscribe` and on transport close.\n\n**Lifecycle**\n- [ ] On transport close: rejects all pending outbound requests, aborts all in-flight invocations, clears all subscriptions.\n- [ ] Does not auto-reconnect silently.\n\n**Error model**\n- [ ] Uses exactly the Tesseron error codes from [the errors catalog](/protocol/errors/).\n- [ ] Preserves `error.data` verbatim when surfacing errors to handlers / users.\n\n**Interop**\n- [ ] Round-trips with the reference `@tesseron/mcp` gateway against at least one real MCP client (Claude Code, Cursor, Claude Desktop).\n\n## Step 8 - publish + link\n\nOpen a PR against the main Tesseron repo adding your SDK to the README. Add a page to this docs site under `/sdk/<your-language>/` mirroring the Python skeleton.\n\nOnce your SDK has shipped a 1.0 that passes the checklist on real agents, we'll happily link it as a first-class implementation.\n","bodyText":"Tesseron's wire protocol is small enough that a competent engineer can implement an SDK for a new language in a couple of days. This page is your map.\n\n## What you're actually building\n\nA Tesseron SDK is three things glued together:\n\n1. **A WebSocket client** that speaks JSON-RPC 2.0.\n2. **A builder DSL** for declaring actions and resources.\n3. **A bridge** between the JSON-RPC dispatcher and the builder's registered handlers.\n\nThe full [protocol spec](/protocol/) is the source of truth. If anything on this page contradicts it, the protocol wins.\n\n## Step 1 - pick a runtime model\n\nTwo choices decide the shape of everything that follows.\n\n- **Async model.** Native `async`/`await` / futures / goroutines - whatever your language uses for concurrency. All SDK methods that may block (connect, sample, elicit, progress-waiting action handlers) should be async. Synchronous handlers are fine, but the client's I/O loop must not be.\n- **Schema library.** You need a way for users to express typed inputs. Pick one well-known library (Pydantic in Python, `go-playground/validator` in Go, Serde+schemars in Rust), and accept any user-provided schema that can round-trip to JSON Schema.\n\n## Step 2 - model the protocol types\n\nPort these from the [wire format page](/protocol/wire-format/):\n\n- JSON-RPC 2.0 request / notification / success / error envelopes.\n- `HelloParams`, `WelcomeResult`, `ActionAnnotations`, `InvokeParams`, `ProgressParams`, `CancelParams`, `SampleParams`, `ElicitParams`, `ReadParams`, `SubscribeParams`, `UpdatedParams`.\n- The error-code enum from the [errors page](/protocol/errors/).\n\nGive the error codes first-class names. Don't pass bare integers around - they accumulate magic.\n\n## Step 3 - write the dispatcher\n\nA bidirectional JSON-RPC dispatcher with:\n\n- `on(method, handler)` - respond to incoming requests.\n- `onNotification(method, handler)` - respond to incoming notifications.\n- `request(method, params, { timeoutMs })` - send a request, await the response. ID generation, timeout handling, rejection on close.\n- `notify(method, params)` - send a fire-and-forget notification.\n- `receive(message)` - given a parsed JSON-RPC envelope, dispatch to a handler or resolve a pending request.\n\nTest this in isolation against a pair of in-memory dispatchers. No networking yet.\n\n## Step 4 - write the transport\n\nYou're picking a **binding** (which wire format) and writing the SDK-side host. Tesseron's protocol layer is binding-neutral; pick from the [documented bindings](/protocol/transport/) or design a new one.\n\nFor a WebSocket binding:\n\n- Bind `127.0.0.1` on an OS-picked port.\n- Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'ws', url } }` where `url` is the URL just bound.\n- Accept exactly one upgrade request that advertises the `tesseron-gateway` WebSocket subprotocol; reject every other attempt.\n- Serialise outgoing objects with the language's standard JSON library and parse incoming text frames as JSON.\n- Delete the manifest on close.\n\nFor a UDS binding (Linux / macOS):\n\n- Create a private (mode `0700`) directory under `os.tmpdir()`-equivalent. Bind a socket inside it, `chmod 0600` the socket file.\n- Write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, transport: { kind: 'uds', path } }`.\n- Accept exactly one connection; reject subsequent connect attempts.\n- Frame messages as NDJSON (`JSON.stringify(msg) + '\\n'`); split incoming bytes on `\\n`.\n- Delete the manifest, the socket file, and the temp dir on close.\n\nThe gateway is always the **client** - it watches `~/.tesseron/instances/`, picks a dialer matching `transport.kind`, and connects. Your runtime never opens an outbound connection; it binds, announces, and waits.\n\nTo add a binding the gateway doesn't yet know about, you also need to ship a `GatewayDialer` for the new `kind` (in TypeScript: `packages/mcp/src/dialer.ts`) and document the wire format under `/protocol/transport-bindings/<kind>/`.\n\nDon't reinvent backoff or reconnect inside the transport - that's the user's job.\n\n## Step 5 - builder DSL\n\nWhatever shape is idiomatic. In TypeScript we use a fluent builder (`action(...).describe(...).input(...).handler(...)`). In Python, decorators. In Rust, probably a struct with a method-chain pattern. What matters is that it ultimately produces an `ActionDefinition`:\n\n```\nActionDefinition {\n name: string;\n description?: string;\n inputSchema?: StandardJsonSchema;\n outputSchema?: StandardJsonSchema;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput: boolean;\n handler: (input, ctx) => output;\n}\n```\n\nSame for `ResourceDefinition`.\n\n## Step 6 - bind it together\n\n```\nclass TesseronClient {\n constructor(transport, dispatcher) { … }\n app(info) { … } // records app manifest for hello\n action(name) { return new Builder(this, name) }\n resource(name) { return new ResBuilder(this, name) }\n async connect() {\n await transport.open();\n dispatcher.on('actions/invoke', this._onInvoke);\n dispatcher.onNotification('actions/cancel', this._onCancel);\n dispatcher.on('resources/read', this._onRead);\n dispatcher.on('resources/subscribe', this._onSub);\n dispatcher.on('resources/unsubscribe', this._onUnsub);\n return await dispatcher.request('tesseron/hello', this._manifest());\n }\n}\n```\n\nEach `on(...)` handler maps to the corresponding builder. Implement progress / sample / elicit / log on the `ActionContext` the same way.\n\n## Step 7 - conformance checklist\n\nBefore you ship, make sure the SDK passes every line of this list. An SDK that fails any line is not Tesseron-compliant.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after the binding's connection becomes ready.\n- [ ] Sends `protocolVersion = \"1.1.0\"` exactly.\n- [ ] Sends `app.id` that matches `/^[a-z][a-z0-9_]*$/`.\n- [ ] Surfaces `welcome.claimCode` to the caller (stdout, event, return value - your choice).\n- [ ] Surfaces `welcome.capabilities` as the authoritative agent capability set to handlers.\n\n**Actions**\n- [ ] Validates action input against the Standard-Schema-equivalent schema before the handler runs.\n- [ ] Returns `-32004 InputValidation` with issues on failure.\n- [ ] Passes output through unchanged by default; validates and returns `-32005` when strict output is enabled and validation fails.\n- [ ] Supports per-invocation timeouts, default 60 000 ms, configurable per action.\n- [ ] Aborts via idiomatic cancellation primitive when the MCP gateway sends `actions/cancel`.\n- [ ] Returns `-32001 Cancelled` on explicit cancel; `-32002 Timeout` on timer.\n- [ ] Emits `actions/progress` notifications from `ctx.progress(...)`.\n\n**Sampling / Confirmation / Elicitation**\n- [ ] Sends `sampling/request` / `elicitation/request` as requests (not notifications).\n- [ ] `ctx.confirm` sends an elicit with an empty-properties object schema and collapses decline / cancel / missing-capability to `false`.\n- [ ] `ctx.elicit` validates the response against the supplied Standard Schema and returns `null` on decline / cancel.\n- [ ] Raises a typed error (`SamplingNotAvailable`, `ElicitationNotAvailable`) when capabilities don't include them - except `ctx.confirm`, which swallows missing elicitation and returns `false`.\n- [ ] Rejects top-level non-object / `oneOf` / `anyOf` / nested-object elicit schemas with `-32602 InvalidParams` at the call site.\n- [ ] Caps sampling depth at 3 (or honours the gateway's cap).\n\n**Resources**\n- [ ] Responds to `resources/read` with `{ value }`.\n- [ ] Accepts `resources/subscribe` and returns the emitter callback's unsubscribe.\n- [ ] Sends `resources/updated` notifications on change.\n- [ ] Cleans up subscriptions on `resources/unsubscribe` and on transport close.\n\n**Lifecycle**\n- [ ] On transport close: rejects all pending outbound requests, aborts all in-flight invocations, clears all subscriptions.\n- [ ] Does not auto-reconnect silently.\n\n**Error model**\n- [ ] Uses exactly the Tesseron error codes from [the errors catalog](/protocol/errors/).\n- [ ] Preserves `error.data` verbatim when surfacing errors to handlers / users.\n\n**Interop**\n- [ ] Round-trips with the reference `@tesseron/mcp` gateway against at least one real MCP client (Claude Code, Cursor, Claude Desktop).\n\n## Step 8 - publish + link\n\nOpen a PR against the main Tesseron repo adding your SDK to the README. Add a page to this docs site under `/sdk/<your-language>/` mirroring the Python skeleton.\n\nOnce your SDK has shipped a 1.0 that passes the checklist on real agents, we'll happily link it as a first-class implementation."},{"slug":"sdk/python/index","title":"Python SDK (planned)","description":"Status and intended shape of a Python implementation of the Tesseron SDK.","section":"sdk","related":["sdk/index","sdk/porting"],"bodyRaw":"\nA Python SDK is on the roadmap but **not yet shipped**.\n\nWhen it lands, it will implement the same [portable SDK contract](/sdk/#the-portable-sdk-contract) as `@tesseron/core`:\n\n- An action builder that accepts any Python validator (Pydantic v2, `msgspec`, `attrs`+`cattrs`) and produces JSON Schema.\n- An invocation context object with `progress`, `sample`, `confirm`, `elicit`, `log`, and an `asyncio.CancelledError`-based cancellation contract.\n- A resource builder with `.read()` and `.subscribe()`.\n- A WebSocket transport using `websockets` or `aiohttp`.\n- A CLI and an optional `FastAPI` integration helper.\n\n## Why Python at all\n\nTwo use cases we hear most:\n\n1. **Backend services already written in Python.** You have a Flask / FastAPI / Django app and don't want to proxy everything through a Node service just to expose it to Claude.\n2. **Local Python tooling.** Jupyter notebooks, data-analysis scripts, personal CLIs - all things where exposing half a dozen actions to Claude adds real leverage.\n\nBoth are better served by a native Python SDK than by shelling out to Node.\n\n## Design notes\n\nRough shape, subject to change:\n\n```python\nfrom tesseron import Tesseron\nfrom pydantic import BaseModel\n\ntesseron = Tesseron(app={\"id\": \"notes\", \"name\": \"Notes\"})\n\nclass CreateNoteInput(BaseModel):\n title: str\n body: str = \"\"\n\n@tesseron.action(\"createNote\", input=CreateNoteInput)\nasync def create_note(input: CreateNoteInput, ctx):\n note = {\"id\": new_id(), \"title\": input.title, \"body\": input.body}\n store.add(note)\n ctx.progress(message=\"saved\", percent=100)\n return note\n\nawait tesseron.connect()\n```\n\nDecorator-flavoured where it fits the ecosystem better than the fluent builder. The wire contract is identical - any Tesseron SDK must produce the same `tesseron/hello` envelope and respond to the same `actions/invoke` request.\n\n## Roadmap\n\n- Early spike: TBD, tracked in the [Tesseron repo](https://github.com/BrainBlend-AI/tesseron).\n- 1.0 target: feature-parity with `@tesseron/core` + `@tesseron/server`.\n\nIf you want to contribute or help shape the API, open a discussion on GitHub.\n","bodyText":"A Python SDK is on the roadmap but **not yet shipped**.\n\nWhen it lands, it will implement the same [portable SDK contract](/sdk/#the-portable-sdk-contract) as `@tesseron/core`:\n\n- An action builder that accepts any Python validator (Pydantic v2, `msgspec`, `attrs`+`cattrs`) and produces JSON Schema.\n- An invocation context object with `progress`, `sample`, `confirm`, `elicit`, `log`, and an `asyncio.CancelledError`-based cancellation contract.\n- A resource builder with `.read()` and `.subscribe()`.\n- A WebSocket transport using `websockets` or `aiohttp`.\n- A CLI and an optional `FastAPI` integration helper.\n\n## Why Python at all\n\nTwo use cases we hear most:\n\n1. **Backend services already written in Python.** You have a Flask / FastAPI / Django app and don't want to proxy everything through a Node service just to expose it to Claude.\n2. **Local Python tooling.** Jupyter notebooks, data-analysis scripts, personal CLIs - all things where exposing half a dozen actions to Claude adds real leverage.\n\nBoth are better served by a native Python SDK than by shelling out to Node.\n\n## Design notes\n\nRough shape, subject to change:\n\n```python\nfrom tesseron import Tesseron\nfrom pydantic import BaseModel\n\ntesseron = Tesseron(app={\"id\": \"notes\", \"name\": \"Notes\"})\n\nclass CreateNoteInput(BaseModel):\n title: str\n body: str = \"\"\n\n@tesseron.action(\"createNote\", input=CreateNoteInput)\nasync def create_note(input: CreateNoteInput, ctx):\n note = {\"id\": new_id(), \"title\": input.title, \"body\": input.body}\n store.add(note)\n ctx.progress(message=\"saved\", percent=100)\n return note\n\nawait tesseron.connect()\n```\n\nDecorator-flavoured where it fits the ecosystem better than the fluent builder. The wire contract is identical - any Tesseron SDK must produce the same `tesseron/hello` envelope and respond to the same `actions/invoke` request.\n\n## Roadmap\n\n- Early spike: TBD, tracked in the [Tesseron repo](https://github.com/BrainBlend-AI/tesseron).\n- 1.0 target: feature-parity with `@tesseron/core` + `@tesseron/server`.\n\nIf you want to contribute or help shape the API, open a discussion on GitHub."},{"slug":"sdk/typescript/action-builder","title":"Action builder","description":"Every step of the fluent builder, what it does, and when to use it.","section":"sdk","related":["protocol/actions","sdk/typescript/standard-schema","sdk/typescript/context"],"bodyRaw":"\nThe action builder is the fluent API on `tesseron.action(name)`. It chains until `.handler(fn)` terminates it with an `ActionDefinition<I, O>`.\n\n## Signature\n\n```ts\ninterface ActionBuilder<I = unknown, O = unknown> {\n describe(description: string): ActionBuilder<I, O>;\n input<NewI>(schema: StandardSchemaV1<NewI>, jsonSchema?: unknown): ActionBuilder<NewI, O>;\n output<NewO>(schema: StandardSchemaV1<NewO>, jsonSchema?: unknown): ActionBuilder<I, NewO>;\n annotate(annotations: ActionAnnotations): ActionBuilder<I, O>;\n timeout(options: { ms: number }): ActionBuilder<I, O>;\n strictOutput(): ActionBuilder<I, O>;\n handler(fn: (input: I, ctx: ActionContext) => O | Promise<O>): ActionDefinition<I, O>;\n}\n```\n\n## `.describe(string)`\n\nHuman-readable description. Shown to the agent's LLM verbatim as the MCP tool description. This is the single biggest lever for getting the agent to call your action correctly; write it as you would write a function docstring for a teammate.\n\n```ts\ntesseron.action('searchProducts')\n .describe(\n 'Search the product catalog. Returns up to `limit` products ordered by ' +\n 'relevance. Use when the user is trying to find items to buy.'\n );\n```\n\n## `.input(schema)` and `.input(schema, jsonSchema)`\n\nBind a Standard Schema validator for input. The schema is used for:\n\n1. **Runtime validation** - invalid input fails with code `-32004` before the handler runs.\n2. **Type inference** - `I` in `handler: (input: I, ctx) => …`.\n3. **JSON Schema export** - for the MCP tool's `inputSchema`.\n\nMost Standard Schema libraries expose JSON-Schema conversion utilities; the SDK uses whatever your validator provides. If the conversion is missing or inadequate, pass a hand-written JSON Schema as the second argument:\n\n```ts\n.input(\n z.object({ sku: z.string(), qty: z.number().int().positive() }),\n { type: 'object', properties: { sku: { type: 'string' }, qty: { type: 'integer', minimum: 1 } }, required: ['sku', 'qty'] },\n)\n```\n\n## `.output(schema)` / `.output(schema, jsonSchema)`\n\nBind a Standard Schema for the return value. By default **this is informational** - the value is passed through unchanged. Call `.strictOutput()` to enforce.\n\n```ts\n.output(z.object({ id: z.string(), itemId: z.string() }))\n```\n\n## `.annotate({…})`\n\nAdvisory metadata surfaced to the agent.\n\n```ts\ninterface ActionAnnotations {\n readOnly?: boolean;\n destructive?: boolean;\n requiresConfirmation?: boolean;\n}\n```\n\n| Field | Use for |\n|---|---|\n| `readOnly: true` | Pure reads. Agent may parallelise. |\n| `destructive: true` | Mutates persistent state. Agent SHOULD warn the user. |\n| `requiresConfirmation: true` | Agent MUST NOT call without explicit user confirmation. Often paired with `ctx.confirm` inside the handler as a second gate. |\n\n## `.timeout({ ms })`\n\nPer-invocation timeout. Default 60 000 ms. When exceeded, the handler's `ctx.signal` aborts and the invocation returns error `-32002 Timeout`.\n\n```ts\n.timeout({ ms: 5 * 60 * 1000 }) // big report, 5 minutes\n```\n\nThe SDK races the handler against the deadline, so the agent receives the timeout response even if the handler is stuck in a non-`AbortSignal`-aware promise. To bound a single inner call from inside the handler, use [`ctx.withTimeout(p, ms)`](/sdk/typescript/context/#ctxwithtimeout-drop-stuck-inner-promises).\n\n## `.strictOutput()`\n\nTurns `.output(schema)` from documentation into enforcement. Validation failure becomes `-32005 HandlerError` with `issues` in `error.data`.\n\n```ts\n.output(z.object({ id: z.string() }))\n.strictOutput()\n```\n\n## `.handler(fn)`\n\nThe actual function. Terminates the builder. Returns an `ActionDefinition<I, O>` that you normally discard - the SDK keeps a reference internally.\n\n```ts\n.handler(async ({ sku, qty }, ctx) => {\n ctx.progress({ message: 'adding', percent: 50 });\n const item = await cart.add(sku, qty);\n return { id: cart.id, itemId: item.id };\n});\n```\n\nThe handler receives `(input: I, ctx: ActionContext)`. See [context API](/sdk/typescript/context/) for what's on `ctx`.\n\n## Full example\n\n```ts\ntesseron\n .action('importCsv')\n .describe('Import products from a remote CSV. Emits progress updates while running.')\n .input(z.object({ url: z.string().url() }))\n .output(z.object({ imported: z.number().int().nonnegative() }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .timeout({ ms: 5 * 60 * 1000 })\n .strictOutput()\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url, { signal: ctx.signal });\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('cancelled');\n ctx.progress({ message: `${i}/${rows.length}`, percent: 5 + Math.floor(i / rows.length * 90) });\n await importBatch(rows.slice(i, i + 100));\n }\n return { imported: rows.length };\n });\n```\n","bodyText":"The action builder is the fluent API on `tesseron.action(name)`. It chains until `.handler(fn)` terminates it with an `ActionDefinition<I, O>`.\n\n## Signature\n\n```ts\ninterface ActionBuilder<I = unknown, O = unknown> {\n describe(description: string): ActionBuilder<I, O>;\n input<NewI>(schema: StandardSchemaV1<NewI>, jsonSchema?: unknown): ActionBuilder<NewI, O>;\n output<NewO>(schema: StandardSchemaV1<NewO>, jsonSchema?: unknown): ActionBuilder<I, NewO>;\n annotate(annotations: ActionAnnotations): ActionBuilder<I, O>;\n timeout(options: { ms: number }): ActionBuilder<I, O>;\n strictOutput(): ActionBuilder<I, O>;\n handler(fn: (input: I, ctx: ActionContext) => O | Promise<O>): ActionDefinition<I, O>;\n}\n```\n\n## `.describe(string)`\n\nHuman-readable description. Shown to the agent's LLM verbatim as the MCP tool description. This is the single biggest lever for getting the agent to call your action correctly; write it as you would write a function docstring for a teammate.\n\n```ts\ntesseron.action('searchProducts')\n .describe(\n 'Search the product catalog. Returns up to `limit` products ordered by ' +\n 'relevance. Use when the user is trying to find items to buy.'\n );\n```\n\n## `.input(schema)` and `.input(schema, jsonSchema)`\n\nBind a Standard Schema validator for input. The schema is used for:\n\n1. **Runtime validation** - invalid input fails with code `-32004` before the handler runs.\n2. **Type inference** - `I` in `handler: (input: I, ctx) => …`.\n3. **JSON Schema export** - for the MCP tool's `inputSchema`.\n\nMost Standard Schema libraries expose JSON-Schema conversion utilities; the SDK uses whatever your validator provides. If the conversion is missing or inadequate, pass a hand-written JSON Schema as the second argument:\n\n```ts\n.input(\n z.object({ sku: z.string(), qty: z.number().int().positive() }),\n { type: 'object', properties: { sku: { type: 'string' }, qty: { type: 'integer', minimum: 1 } }, required: ['sku', 'qty'] },\n)\n```\n\n## `.output(schema)` / `.output(schema, jsonSchema)`\n\nBind a Standard Schema for the return value. By default **this is informational** - the value is passed through unchanged. Call `.strictOutput()` to enforce.\n\n```ts\n.output(z.object({ id: z.string(), itemId: z.string() }))\n```\n\n## `.annotate({…})`\n\nAdvisory metadata surfaced to the agent.\n\n```ts\ninterface ActionAnnotations {\n readOnly?: boolean;\n destructive?: boolean;\n requiresConfirmation?: boolean;\n}\n```\n\n| Field | Use for |\n|---|---|\n| `readOnly: true` | Pure reads. Agent may parallelise. |\n| `destructive: true` | Mutates persistent state. Agent SHOULD warn the user. |\n| `requiresConfirmation: true` | Agent MUST NOT call without explicit user confirmation. Often paired with `ctx.confirm` inside the handler as a second gate. |\n\n## `.timeout({ ms })`\n\nPer-invocation timeout. Default 60 000 ms. When exceeded, the handler's `ctx.signal` aborts and the invocation returns error `-32002 Timeout`.\n\n```ts\n.timeout({ ms: 5 * 60 * 1000 }) // big report, 5 minutes\n```\n\nThe SDK races the handler against the deadline, so the agent receives the timeout response even if the handler is stuck in a non-`AbortSignal`-aware promise. To bound a single inner call from inside the handler, use [`ctx.withTimeout(p, ms)`](/sdk/typescript/context/#ctxwithtimeout-drop-stuck-inner-promises).\n\n## `.strictOutput()`\n\nTurns `.output(schema)` from documentation into enforcement. Validation failure becomes `-32005 HandlerError` with `issues` in `error.data`.\n\n```ts\n.output(z.object({ id: z.string() }))\n.strictOutput()\n```\n\n## `.handler(fn)`\n\nThe actual function. Terminates the builder. Returns an `ActionDefinition<I, O>` that you normally discard - the SDK keeps a reference internally.\n\n```ts\n.handler(async ({ sku, qty }, ctx) => {\n ctx.progress({ message: 'adding', percent: 50 });\n const item = await cart.add(sku, qty);\n return { id: cart.id, itemId: item.id };\n});\n```\n\nThe handler receives `(input: I, ctx: ActionContext)`. See [context API](/sdk/typescript/context/) for what's on `ctx`.\n\n## Full example\n\n```ts\ntesseron\n .action('importCsv')\n .describe('Import products from a remote CSV. Emits progress updates while running.')\n .input(z.object({ url: z.string().url() }))\n .output(z.object({ imported: z.number().int().nonnegative() }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .timeout({ ms: 5 * 60 * 1000 })\n .strictOutput()\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url, { signal: ctx.signal });\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('cancelled');\n ctx.progress({ message: `${i}/${rows.length}`, percent: 5 + Math.floor(i / rows.length * 90) });\n await importBatch(rows.slice(i, i + 100));\n }\n return { imported: rows.length };\n });\n```"},{"slug":"sdk/typescript/context","title":"Context API (progress, sampling, elicit)","description":"Everything available on the `ctx` argument of an action handler.","section":"sdk","related":["protocol/elicitation","protocol/sampling","protocol/progress-cancellation"],"bodyRaw":"\nEvery action handler receives `(input, ctx)`. `ctx: ActionContext` is where the protocol-level capabilities are exposed as methods.\n\n## Shape\n\n```ts\ninterface ActionContext {\n // Identity\n readonly agent: { id: string; name: string };\n readonly agentCapabilities: {\n sampling: boolean;\n elicitation: boolean;\n subscriptions: boolean;\n };\n readonly client: { origin: string; route?: string; userAgent?: string };\n\n // Lifecycle\n readonly signal: AbortSignal;\n withTimeout<T>(value: Promise<T> | T, ms: number): Promise<T>;\n\n // Messaging\n progress(update: { message?: string; percent?: number; data?: unknown }): void;\n sample<T>(req: { prompt: string; schema?: StandardSchemaV1<T>; maxTokens?: number }): Promise<T>;\n confirm(req: { question: string }): Promise<boolean>;\n elicit<T>(req: {\n question: string;\n schema: StandardSchemaV1<T>;\n jsonSchema?: unknown;\n }): Promise<T | null>;\n log(level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: Record<string, unknown>): void;\n}\n```\n\n## `ctx.signal` - cancel & timeout\n\nStandard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Fires when the agent cancels the invocation or the action's timeout expires. The two cases are indistinguishable from the handler - cleanup and yield either way.\n\n```ts\n.handler(async (input, ctx) => {\n const res = await fetch(url, { signal: ctx.signal });\n if (ctx.signal.aborted) throw new Error('cancelled');\n return await res.json();\n});\n```\n\nPass `ctx.signal` to everything that accepts one: `fetch`, `setTimeout`, database drivers, nested `ctx.sample` calls.\n\nThe SDK guarantees the wire is freed at the deadline regardless of whether the handler observes `ctx.signal`. Once the timer fires, the agent receives `-32002 Timeout` (or `-32001 Cancelled` on agent cancellation) immediately. A handler stuck in a non-signal-aware promise keeps running orphaned — that's the app's problem to clean up, but the agent isn't held hostage. See [`ctx.withTimeout`](#ctxwithtimeout-drop-stuck-inner-promises) for the in-handler companion.\n\n## `ctx.withTimeout(value, ms)` - drop stuck inner promises\n\nA small race helper for handlers that wrap browser APIs which don't accept an `AbortSignal` — `modern-screenshot.domToPng`, `<canvas>.toBlob`, `<img>.decode`, `document.fonts.ready`, `Audio.play`, `MediaRecorder`. Resolves with `value` if it settles within `ms`, otherwise rejects with `TimeoutError`. Also rejects if `ctx.signal` aborts first (with the abort reason — `TimeoutError` or `CancelledError`).\n\n```ts\n.handler(async (_input, ctx) => {\n const dataUrl = await ctx.withTimeout(domToPng(document.body), 8_000);\n return { dataUrl };\n});\n```\n\nThe original promise keeps running orphaned; the handler moves on. Use this to bound a single problematic call without giving the whole action a tighter `.timeout({ ms })` than it actually needs.\n\n## `ctx.progress(update)` - streaming updates\n\nFire-and-forget notification. Any combination of the three payload fields works:\n\n```ts\nctx.progress({ message: 'searching' });\nctx.progress({ percent: 40 });\nctx.progress({ message: 'merging results', percent: 80, data: { batchesDone: 3 } });\n```\n\n`ctx.progress` is a fire-and-forget JSON-RPC notification; it never throws. Safe to call unconditionally - when no one is observing (agent didn't supply a `progressToken`, or the MCP client drops them), the MCP gateway just doesn't forward the notification.\n\nKeep the rate reasonable (≤ 2/sec). Progress is rendered in the agent UI; faster rates spam without adding information.\n\n## `ctx.sample(req)` - ask the LLM\n\nRe-enter the agent's LLM for a reasoning step.\n\n```ts\nconst { summary } = await ctx.sample({\n prompt: `Summarise these bug reports in one sentence each:\\n${JSON.stringify(bugs)}`,\n schema: z.object({ summary: z.array(z.string()) }),\n maxTokens: 400,\n});\n```\n\n- `schema` is optional. Without it, you get `string`. With it, the SDK validates and returns the parsed `T`.\n- `maxTokens` is a hint to the agent; honoured at its discretion.\n- Throws `SamplingNotAvailableError` (code `-32006`) if `agentCapabilities.sampling` is false.\n- Throws `SamplingDepthExceededError` (code `-32008`) if you've nested past `maxSamplingDepth` (3).\n\nSee [the sampling protocol page](/protocol/sampling/) for wire format.\n\n## `ctx.confirm(req)` - ask the user yes/no\n\nFor safety gates on destructive actions. Returns `true` only on explicit accept; decline, cancel, and missing elicitation capability all collapse to `false`.\n\n```ts\nconst ok = await ctx.confirm({\n question: `Delete order ${order.number}? This cannot be undone.`,\n});\nif (!ok) return { cancelled: true };\nawait orders.delete(order.id);\n```\n\n- No schema - the Accept/Decline action is the answer.\n- Safe to call unconditionally: when the connected MCP client doesn't advertise elicitation, `confirm` returns `false` (the safe default for destructive gates). You don't need to guard on `ctx.agentCapabilities.elicitation`.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render pure Accept/Decline without an input field.\n\n## `ctx.elicit(req)` - ask the user for structured content\n\nWhen you need a value from the user - a warehouse ID, a new filename, a grace-period choice. The agent renders a form; you get the typed value back.\n\n```ts\nimport { z } from 'zod';\n\nconst nameSchema = z.object({ newName: z.string().min(1) });\n\nconst answer = await ctx.elicit({\n question: `Rename \"${file.name}\" to?`,\n schema: nameSchema,\n jsonSchema: z.toJSONSchema(nameSchema),\n});\nif (answer === null) return { cancelled: true };\nawait file.rename(answer.newName);\n```\n\n- `schema` is the runtime validator (any Standard Schema v1 - Zod, Valibot, ArkType, ...).\n- `jsonSchema` is what the MCP client renders. Optional; if omitted, a permissive single-text-input fallback is sent. For real UX always derive it from your validator (Zod 4: `z.toJSONSchema(schema)`).\n- Returns the validated value on accept, `null` on decline or cancel.\n- Throws `ElicitationNotAvailableError` (code `-32007`) if the agent doesn't support elicitation - structured data has no safe default.\n\nMCP elicit requires the `requestedSchema` to be a flat object of primitive-typed leaves (`string`, `number`, `integer`, `boolean`). The SDK asserts this at the call site and surfaces a clear `InvalidParams` (code `-32602`) error if you send a nested object, array, or `oneOf` / `anyOf` at the top level.\n\n### Which to pick\n\n- Yes/no on a destructive op → `ctx.confirm`.\n- \"Which of these?\" / \"What's the new name?\" → `ctx.elicit` with a schema.\n- Multi-step wizards → separate actions, one question each.\n\n## `ctx.log({ level, message, meta? })` - structured logs\n\n```ts\nctx.log({ level: 'info', message: 'imported CSV', meta: { rows: 1200, durationMs: 4830 } });\nctx.log({ level: 'warn', message: 'column name mismatch, falling back', meta: { column: 'sku_new' } });\nctx.log({ level: 'error', message: 'remote returned 500', meta: { url, status: 500 } });\n```\n\nForwarded to MCP `sendLoggingMessage` with `logger: <app_id>`. Useful because:\n\n- The user sees them in the agent's log panel - helpful context when the invocation succeeds but something went sideways.\n- They're notifications, not requests - never back-pressure the handler.\n\n## `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`\n\nRead-only identity + capability info.\n\n- `ctx.agent.id` - one of `claude-code`, `claude-desktop`, `cursor`, an agent-provided identifier.\n- `ctx.client.origin` - the origin of the app. On the server SDK this is typically a fabricated identifier; on the web SDK it's `window.location.origin`.\n- `ctx.client.route` - the app's current route, if set at `app({})`-time. Useful for routing context into the handler.\n\nGuard feature calls on these before using them:\n\n```ts\nif (ctx.agentCapabilities.sampling) {\n const extracted = await ctx.sample({ prompt, schema });\n return { items: extracted };\n}\nreturn { items: await fallbackSearch(...) };\n```\n","bodyText":"Every action handler receives `(input, ctx)`. `ctx: ActionContext` is where the protocol-level capabilities are exposed as methods.\n\n## Shape\n\n```ts\ninterface ActionContext {\n // Identity\n readonly agent: { id: string; name: string };\n readonly agentCapabilities: {\n sampling: boolean;\n elicitation: boolean;\n subscriptions: boolean;\n };\n readonly client: { origin: string; route?: string; userAgent?: string };\n\n // Lifecycle\n readonly signal: AbortSignal;\n withTimeout<T>(value: Promise<T> | T, ms: number): Promise<T>;\n\n // Messaging\n progress(update: { message?: string; percent?: number; data?: unknown }): void;\n sample<T>(req: { prompt: string; schema?: StandardSchemaV1<T>; maxTokens?: number }): Promise<T>;\n confirm(req: { question: string }): Promise<boolean>;\n elicit<T>(req: {\n question: string;\n schema: StandardSchemaV1<T>;\n jsonSchema?: unknown;\n }): Promise<T | null>;\n log(level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: Record<string, unknown>): void;\n}\n```\n\n## `ctx.signal` - cancel & timeout\n\nStandard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Fires when the agent cancels the invocation or the action's timeout expires. The two cases are indistinguishable from the handler - cleanup and yield either way.\n\n```ts\n.handler(async (input, ctx) => {\n const res = await fetch(url, { signal: ctx.signal });\n if (ctx.signal.aborted) throw new Error('cancelled');\n return await res.json();\n});\n```\n\nPass `ctx.signal` to everything that accepts one: `fetch`, `setTimeout`, database drivers, nested `ctx.sample` calls.\n\nThe SDK guarantees the wire is freed at the deadline regardless of whether the handler observes `ctx.signal`. Once the timer fires, the agent receives `-32002 Timeout` (or `-32001 Cancelled` on agent cancellation) immediately. A handler stuck in a non-signal-aware promise keeps running orphaned — that's the app's problem to clean up, but the agent isn't held hostage. See [`ctx.withTimeout`](#ctxwithtimeout-drop-stuck-inner-promises) for the in-handler companion.\n\n## `ctx.withTimeout(value, ms)` - drop stuck inner promises\n\nA small race helper for handlers that wrap browser APIs which don't accept an `AbortSignal` — `modern-screenshot.domToPng`, `<canvas>.toBlob`, `<img>.decode`, `document.fonts.ready`, `Audio.play`, `MediaRecorder`. Resolves with `value` if it settles within `ms`, otherwise rejects with `TimeoutError`. Also rejects if `ctx.signal` aborts first (with the abort reason — `TimeoutError` or `CancelledError`).\n\n```ts\n.handler(async (_input, ctx) => {\n const dataUrl = await ctx.withTimeout(domToPng(document.body), 8_000);\n return { dataUrl };\n});\n```\n\nThe original promise keeps running orphaned; the handler moves on. Use this to bound a single problematic call without giving the whole action a tighter `.timeout({ ms })` than it actually needs.\n\n## `ctx.progress(update)` - streaming updates\n\nFire-and-forget notification. Any combination of the three payload fields works:\n\n```ts\nctx.progress({ message: 'searching' });\nctx.progress({ percent: 40 });\nctx.progress({ message: 'merging results', percent: 80, data: { batchesDone: 3 } });\n```\n\n`ctx.progress` is a fire-and-forget JSON-RPC notification; it never throws. Safe to call unconditionally - when no one is observing (agent didn't supply a `progressToken`, or the MCP client drops them), the MCP gateway just doesn't forward the notification.\n\nKeep the rate reasonable (≤ 2/sec). Progress is rendered in the agent UI; faster rates spam without adding information.\n\n## `ctx.sample(req)` - ask the LLM\n\nRe-enter the agent's LLM for a reasoning step.\n\n```ts\nconst { summary } = await ctx.sample({\n prompt: `Summarise these bug reports in one sentence each:\\n${JSON.stringify(bugs)}`,\n schema: z.object({ summary: z.array(z.string()) }),\n maxTokens: 400,\n});\n```\n\n- `schema` is optional. Without it, you get `string`. With it, the SDK validates and returns the parsed `T`.\n- `maxTokens` is a hint to the agent; honoured at its discretion.\n- Throws `SamplingNotAvailableError` (code `-32006`) if `agentCapabilities.sampling` is false.\n- Throws `SamplingDepthExceededError` (code `-32008`) if you've nested past `maxSamplingDepth` (3).\n\nSee [the sampling protocol page](/protocol/sampling/) for wire format.\n\n## `ctx.confirm(req)` - ask the user yes/no\n\nFor safety gates on destructive actions. Returns `true` only on explicit accept; decline, cancel, and missing elicitation capability all collapse to `false`.\n\n```ts\nconst ok = await ctx.confirm({\n question: `Delete order ${order.number}? This cannot be undone.`,\n});\nif (!ok) return { cancelled: true };\nawait orders.delete(order.id);\n```\n\n- No schema - the Accept/Decline action is the answer.\n- Safe to call unconditionally: when the connected MCP client doesn't advertise elicitation, `confirm` returns `false` (the safe default for destructive gates). You don't need to guard on `ctx.agentCapabilities.elicitation`.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render pure Accept/Decline without an input field.\n\n## `ctx.elicit(req)` - ask the user for structured content\n\nWhen you need a value from the user - a warehouse ID, a new filename, a grace-period choice. The agent renders a form; you get the typed value back.\n\n```ts\n\nconst nameSchema = z.object({ newName: z.string().min(1) });\n\nconst answer = await ctx.elicit({\n question: `Rename \"${file.name}\" to?`,\n schema: nameSchema,\n jsonSchema: z.toJSONSchema(nameSchema),\n});\nif (answer === null) return { cancelled: true };\nawait file.rename(answer.newName);\n```\n\n- `schema` is the runtime validator (any Standard Schema v1 - Zod, Valibot, ArkType, ...).\n- `jsonSchema` is what the MCP client renders. Optional; if omitted, a permissive single-text-input fallback is sent. For real UX always derive it from your validator (Zod 4: `z.toJSONSchema(schema)`).\n- Returns the validated value on accept, `null` on decline or cancel.\n- Throws `ElicitationNotAvailableError` (code `-32007`) if the agent doesn't support elicitation - structured data has no safe default.\n\nMCP elicit requires the `requestedSchema` to be a flat object of primitive-typed leaves (`string`, `number`, `integer`, `boolean`). The SDK asserts this at the call site and surfaces a clear `InvalidParams` (code `-32602`) error if you send a nested object, array, or `oneOf` / `anyOf` at the top level.\n\n### Which to pick\n\n- Yes/no on a destructive op → `ctx.confirm`.\n- \"Which of these?\" / \"What's the new name?\" → `ctx.elicit` with a schema.\n- Multi-step wizards → separate actions, one question each.\n\n## `ctx.log({ level, message, meta? })` - structured logs\n\n```ts\nctx.log({ level: 'info', message: 'imported CSV', meta: { rows: 1200, durationMs: 4830 } });\nctx.log({ level: 'warn', message: 'column name mismatch, falling back', meta: { column: 'sku_new' } });\nctx.log({ level: 'error', message: 'remote returned 500', meta: { url, status: 500 } });\n```\n\nForwarded to MCP `sendLoggingMessage` with `logger: <app_id>`. Useful because:\n\n- The user sees them in the agent's log panel - helpful context when the invocation succeeds but something went sideways.\n- They're notifications, not requests - never back-pressure the handler.\n\n## `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`\n\nRead-only identity + capability info.\n\n- `ctx.agent.id` - one of `claude-code`, `claude-desktop`, `cursor`, an agent-provided identifier.\n- `ctx.client.origin` - the origin of the app. On the server SDK this is typically a fabricated identifier; on the web SDK it's `window.location.origin`.\n- `ctx.client.route` - the app's current route, if set at `app({})`-time. Useful for routing context into the handler.\n\nGuard feature calls on these before using them:\n\n```ts\nif (ctx.agentCapabilities.sampling) {\n const extracted = await ctx.sample({ prompt, schema });\n return { items: extracted };\n}\nreturn { items: await fallbackSearch(...) };\n```"},{"slug":"sdk/typescript/core","title":"@tesseron/core","description":"The protocol types, builder, JSON-RPC dispatcher, and abstract client that every runtime adapter extends.","section":"sdk","related":["protocol/wire-format","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/core` is the runtime-independent layer. It has **zero runtime dependencies beyond Standard Schema spec types**. If you're writing a custom transport - Bun, Deno, a browser extension background worker, a native WebSocket implementation - you extend `core` directly.\n\nMost consumers don't need this package; they use `@tesseron/web`, `/server`, or `/react`. Use `core` when those don't fit.\n\n## Exports\n\n```ts\nimport {\n // The abstract client (extended by @tesseron/web and @tesseron/server).\n TesseronClient,\n // Builders.\n ActionBuilder, ActionDefinition, ActionHandler,\n ResourceBuilder, ResourceDefinition, ResourceReader, ResourceSubscriber,\n TimeoutOptions,\n // Per-invocation context.\n ActionContext, AgentCapabilities, InvokingAgent, ClientContext,\n ProgressUpdate, SampleRequest, ConfirmRequest, ElicitRequest, LogEntry,\n // Transport contract.\n Transport, TransportClosedError,\n // Wire envelope (JSON-RPC).\n JsonRpcRequest, JsonRpcNotification, JsonRpcResponse, JsonRpcErrorPayload,\n // Error model.\n TesseronError,\n SamplingNotAvailableError, ElicitationNotAvailableError, SamplingDepthExceededError,\n CancelledError, TimeoutError,\n TesseronErrorCode, // numeric enum: InputValidation = -32004, etc.\n // Protocol constants & types.\n PROTOCOL_VERSION, // '1.1.0'\n HelloParams, WelcomeResult, TesseronCapabilities,\n AppMetadata, AgentIdentity, ActionAnnotations,\n ActionInvokeParams, ActionProgressParams, ActionCancelParams,\n ResourceReadParams, ResourceSubscribeParams, ResourceUpdatedParams,\n // v1.1 multi-binding additions.\n TransportSpec, InstanceManifest,\n} from '@tesseron/core';\n```\n\nSibling-package helpers (`JsonRpcDispatcher`, `SDK_CAPABILITIES`, schema helpers, builder implementation classes) live under `@tesseron/core/internal`. They are deliberately excluded from the main entry point and are **not** part of the v1.0 semver contract — treat them as subject to change. Only the `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, and `@tesseron/mcp` packages should import from that subpath.\n\n## `TesseronClient` (abstract)\n\n`@tesseron/web` and `@tesseron/server` each extend this with a transport. The base class's `connect(transport)` takes a concrete `Transport`. The web / server subclasses override it to accept `Transport | string | undefined` so users can pass a URL (or nothing) and get a default WebSocket transport. The subclassing contract:\n\n```ts\nclass MyTesseronClient extends TesseronClient {\n override async connect(target?: Transport | string): Promise<WelcomeResult> {\n if (target && typeof target !== 'string') return super.connect(target);\n const transport = new MyTransport(target ?? DEFAULT_GATEWAY_URL);\n await transport.ready();\n return super.connect(transport);\n }\n}\n```\n\n`super.connect(transport)` wires the dispatcher, sends `tesseron/hello`, handles `actions/invoke`, and returns the `welcome` result.\n\n## `Transport`\n\n```ts\ninterface Transport {\n send(message: unknown): void;\n onMessage(handler: (message: unknown) => void): void;\n onClose(handler: (reason?: string) => void): void;\n close(reason?: string): void;\n}\n```\n\nThe core client assumes the transport passes objects (not strings). If your transport is string-oriented, JSON.parse / stringify at the boundary. WebSocket-based transports in `@tesseron/web` and `@tesseron/server` already do this.\n\n## `JsonRpcDispatcher`\n\nLow-level bidirectional JSON-RPC router:\n\n```ts\ninterface JsonRpcDispatcher {\n on<M>(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void;\n onNotification<N>(method: string, handler: (params: unknown) => void): void;\n request<R>(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise<R>;\n notify(method: string, params?: unknown): void;\n receive(message: unknown): void;\n}\n```\n\nYou typically only use this directly when implementing extension methods. Day-to-day use of Tesseron goes through the builder, not the dispatcher.\n\n## `TesseronError`\n\n```ts\nclass TesseronError extends Error {\n readonly code: number;\n readonly data?: unknown;\n constructor(code: number, message: string, data?: unknown);\n}\n```\n\nThe dispatcher maps it to / from the `{ code, message, data }` JSON-RPC error object automatically. Throw it from handlers to produce a specific JSON-RPC error:\n\n```ts\nimport { TesseronError, TesseronErrorCode } from '@tesseron/core';\n\n.handler(async ({ orderId }, ctx) => {\n const order = await orders.find(orderId);\n if (!order) throw new TesseronError(TesseronErrorCode.ActionNotFound, `no order ${orderId}`, { orderId });\n // …\n});\n```\n\nCatching `TesseronError` is also useful around `ctx.sample` / `ctx.elicit` to pivot on capability errors (note: `ctx.confirm` doesn't throw — it returns `false` when elicitation isn't available, which is the safe default for destructive gates):\n\n```ts\nimport { SamplingNotAvailableError, TesseronError, TesseronErrorCode } from '@tesseron/core';\n\ntry {\n const r = await ctx.sample({ prompt });\n} catch (err) {\n if (err instanceof SamplingNotAvailableError) return fallback();\n // equivalent by code:\n if (err instanceof TesseronError && err.code === TesseronErrorCode.SamplingNotAvailable) {\n return fallback();\n }\n throw err;\n}\n```\n\n## Bringing your own transport\n\nA minimal example, for clarity - a loopback transport pair for tests:\n\n```ts\nimport { Transport, TesseronClient } from '@tesseron/core';\n\nfunction pair(): [Transport, Transport] {\n const aInbox: Array<(m: unknown) => void> = [];\n const bInbox: Array<(m: unknown) => void> = [];\n const a: Transport = {\n send: (m) => bInbox.forEach((h) => h(m)),\n onMessage: (h) => aInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n const b: Transport = {\n send: (m) => aInbox.forEach((h) => h(m)),\n onMessage: (h) => bInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n return [a, b];\n}\n```\n\nYou can attach a `TesseronClient` subclass to one side and a mock gateway to the other. Both `@tesseron/mcp` and the SDK test suites rely on patterns like this.\n","bodyText":"`@tesseron/core` is the runtime-independent layer. It has **zero runtime dependencies beyond Standard Schema spec types**. If you're writing a custom transport - Bun, Deno, a browser extension background worker, a native WebSocket implementation - you extend `core` directly.\n\nMost consumers don't need this package; they use `@tesseron/web`, `/server`, or `/react`. Use `core` when those don't fit.\n\n## Exports\n\n```ts\n\n // The abstract client (extended by @tesseron/web and @tesseron/server).\n TesseronClient,\n // Builders.\n ActionBuilder, ActionDefinition, ActionHandler,\n ResourceBuilder, ResourceDefinition, ResourceReader, ResourceSubscriber,\n TimeoutOptions,\n // Per-invocation context.\n ActionContext, AgentCapabilities, InvokingAgent, ClientContext,\n ProgressUpdate, SampleRequest, ConfirmRequest, ElicitRequest, LogEntry,\n // Transport contract.\n Transport, TransportClosedError,\n // Wire envelope (JSON-RPC).\n JsonRpcRequest, JsonRpcNotification, JsonRpcResponse, JsonRpcErrorPayload,\n // Error model.\n TesseronError,\n SamplingNotAvailableError, ElicitationNotAvailableError, SamplingDepthExceededError,\n CancelledError, TimeoutError,\n TesseronErrorCode, // numeric enum: InputValidation = -32004, etc.\n // Protocol constants & types.\n PROTOCOL_VERSION, // '1.1.0'\n HelloParams, WelcomeResult, TesseronCapabilities,\n AppMetadata, AgentIdentity, ActionAnnotations,\n ActionInvokeParams, ActionProgressParams, ActionCancelParams,\n ResourceReadParams, ResourceSubscribeParams, ResourceUpdatedParams,\n // v1.1 multi-binding additions.\n TransportSpec, InstanceManifest,\n} from '@tesseron/core';\n```\n\nSibling-package helpers (`JsonRpcDispatcher`, `SDK_CAPABILITIES`, schema helpers, builder implementation classes) live under `@tesseron/core/internal`. They are deliberately excluded from the main entry point and are **not** part of the v1.0 semver contract — treat them as subject to change. Only the `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, and `@tesseron/mcp` packages should import from that subpath.\n\n## `TesseronClient` (abstract)\n\n`@tesseron/web` and `@tesseron/server` each extend this with a transport. The base class's `connect(transport)` takes a concrete `Transport`. The web / server subclasses override it to accept `Transport | string | undefined` so users can pass a URL (or nothing) and get a default WebSocket transport. The subclassing contract:\n\n```ts\nclass MyTesseronClient extends TesseronClient {\n override async connect(target?: Transport | string): Promise<WelcomeResult> {\n if (target && typeof target !== 'string') return super.connect(target);\n const transport = new MyTransport(target ?? DEFAULT_GATEWAY_URL);\n await transport.ready();\n return super.connect(transport);\n }\n}\n```\n\n`super.connect(transport)` wires the dispatcher, sends `tesseron/hello`, handles `actions/invoke`, and returns the `welcome` result.\n\n## `Transport`\n\n```ts\ninterface Transport {\n send(message: unknown): void;\n onMessage(handler: (message: unknown) => void): void;\n onClose(handler: (reason?: string) => void): void;\n close(reason?: string): void;\n}\n```\n\nThe core client assumes the transport passes objects (not strings). If your transport is string-oriented, JSON.parse / stringify at the boundary. WebSocket-based transports in `@tesseron/web` and `@tesseron/server` already do this.\n\n## `JsonRpcDispatcher`\n\nLow-level bidirectional JSON-RPC router:\n\n```ts\ninterface JsonRpcDispatcher {\n on<M>(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void;\n onNotification<N>(method: string, handler: (params: unknown) => void): void;\n request<R>(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise<R>;\n notify(method: string, params?: unknown): void;\n receive(message: unknown): void;\n}\n```\n\nYou typically only use this directly when implementing extension methods. Day-to-day use of Tesseron goes through the builder, not the dispatcher.\n\n## `TesseronError`\n\n```ts\nclass TesseronError extends Error {\n readonly code: number;\n readonly data?: unknown;\n constructor(code: number, message: string, data?: unknown);\n}\n```\n\nThe dispatcher maps it to / from the `{ code, message, data }` JSON-RPC error object automatically. Throw it from handlers to produce a specific JSON-RPC error:\n\n```ts\n\n.handler(async ({ orderId }, ctx) => {\n const order = await orders.find(orderId);\n if (!order) throw new TesseronError(TesseronErrorCode.ActionNotFound, `no order ${orderId}`, { orderId });\n // …\n});\n```\n\nCatching `TesseronError` is also useful around `ctx.sample` / `ctx.elicit` to pivot on capability errors (note: `ctx.confirm` doesn't throw — it returns `false` when elicitation isn't available, which is the safe default for destructive gates):\n\n```ts\n\ntry {\n const r = await ctx.sample({ prompt });\n} catch (err) {\n if (err instanceof SamplingNotAvailableError) return fallback();\n // equivalent by code:\n if (err instanceof TesseronError && err.code === TesseronErrorCode.SamplingNotAvailable) {\n return fallback();\n }\n throw err;\n}\n```\n\n## Bringing your own transport\n\nA minimal example, for clarity - a loopback transport pair for tests:\n\n```ts\n\nfunction pair(): [Transport, Transport] {\n const aInbox: Array<(m: unknown) => void> = [];\n const bInbox: Array<(m: unknown) => void> = [];\n const a: Transport = {\n send: (m) => bInbox.forEach((h) => h(m)),\n onMessage: (h) => aInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n const b: Transport = {\n send: (m) => aInbox.forEach((h) => h(m)),\n onMessage: (h) => bInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n return [a, b];\n}\n```\n\nYou can attach a `TesseronClient` subclass to one side and a mock gateway to the other. Both `@tesseron/mcp` and the SDK test suites rely on patterns like this."},{"slug":"sdk/typescript/index","title":"Install & first action","description":"The minimum code to go from zero to a working Tesseron integration in TypeScript.","section":"sdk","related":["sdk/typescript/action-builder","sdk/typescript/web","sdk/typescript/standard-schema","overview/quickstart"],"bodyRaw":"\nimport { Tabs, TabItem, Steps } from '@astrojs/starlight/components';\n\n<Steps>\n\n1. **Install the package that matches your runtime.**\n\n <Tabs>\n <TabItem label=\"Browser (Vite)\">\n ```bash\n pnpm add @tesseron/web zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Vue\">\n ```bash\n pnpm add @tesseron/vue zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Svelte\">\n ```bash\n pnpm add @tesseron/svelte zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"React\">\n ```bash\n pnpm add @tesseron/react zod\n pnpm add -D @tesseron/vite\n ```\n </TabItem>\n <TabItem label=\"Node (server, worker)\">\n ```bash\n pnpm add @tesseron/server zod\n ```\n </TabItem>\n <TabItem label=\"Bring-your-own transport\">\n ```bash\n pnpm add @tesseron/core zod\n ```\n </TabItem>\n </Tabs>\n\n Browser apps also need the [`@tesseron/vite`](/sdk/typescript/vite/) plugin registered in `vite.config.ts` to serve `/@tesseron/ws`. Node apps don't - `@tesseron/server` binds and announces itself automatically.\n\n `zod` is used in the examples here; swap it for any [Standard Schema](https://standardschema.dev)-compatible validator - Valibot, ArkType, Effect Schema.\n\n2. **Name your app.** `app.id` is what the agent's tool names get prefixed with. It must match `/^[a-z][a-z0-9_]*$/`.\n\n ```ts\n import { tesseron } from '@tesseron/web';\n\n tesseron.app({\n id: 'notes',\n name: 'My Notes App',\n description: 'Create and organise notes',\n });\n ```\n\n3. **Declare an action.**\n\n ```ts\n import { z } from 'zod';\n\n tesseron\n .action('createNote')\n .describe('Create a new note')\n .input(z.object({ title: z.string().min(1), body: z.string().default('') }))\n .handler(({ title, body }) => {\n const note = { id: crypto.randomUUID(), title, body, createdAt: Date.now() };\n store.add(note);\n return note;\n });\n ```\n\n4. **Connect.**\n\n ```ts\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n ```\n\n The MCP gateway prints the claim code to its stderr; you can also surface it in your UI.\n\n5. **Claim from the agent.** In Claude Code: *\"Claim Tesseron session &lt;code&gt;.\"* Once claimed, `notes__createNote` appears in the tool list.\n\n</Steps>\n\n## Typical entry-point layouts\n\n<Tabs>\n <TabItem label=\"Browser SPA\">\n ```ts title=\"src/tesseron.ts\"\n import { tesseron } from '@tesseron/web';\n import { z } from 'zod';\n\n tesseron.app({ id: 'notes', name: 'Notes' });\n\n tesseron.action('createNote')\n .input(z.object({ title: z.string(), body: z.string() }))\n .handler(({ title, body }) => notesStore.add({ title, body }));\n\n export const connect = () => tesseron.connect();\n ```\n\n ```ts title=\"src/main.ts\"\n import { connect } from './tesseron';\n connect(); // fire and forget; retry in UI if you like.\n ```\n </TabItem>\n\n <TabItem label=\"React\">\n ```tsx title=\"src/app.tsx\"\n import { useTesseronAction, useTesseronConnection } from '@tesseron/react';\n import { z } from 'zod';\n\n export function App() {\n const { claimCode, status } = useTesseronConnection();\n\n useTesseronAction('createNote', {\n input: z.object({ title: z.string(), body: z.string() }),\n handler: ({ title, body }) => notesStore.add({ title, body }),\n });\n\n return (\n <div>\n {status === 'open' && claimCode && <ClaimCodeBanner code={claimCode} />}\n <Notes />\n </div>\n );\n }\n ```\n </TabItem>\n\n <TabItem label=\"Node server\">\n ```ts title=\"src/index.ts\"\n import { tesseron } from '@tesseron/server';\n import { z } from 'zod';\n\n tesseron.app({ id: 'notes_api', name: 'Notes API' });\n\n tesseron.action('createNote')\n .input(z.object({ title: z.string(), body: z.string() }))\n .handler(async ({ title, body }) => db.notes.insert({ title, body }));\n\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n\n process.on('SIGINT', async () => {\n await tesseron.disconnect();\n process.exit(0);\n });\n ```\n </TabItem>\n</Tabs>\n\n## Where to go next\n\n- The [action builder](/sdk/typescript/action-builder/) in full - chaining, output validation, annotations, timeouts.\n- [Standard Schema validators](/sdk/typescript/standard-schema/) - Zod vs Valibot vs ArkType, and JSON Schema interop.\n- The [context API](/sdk/typescript/context/) - progress, sampling, elicitation, logging.\n- Framework adapters: [@tesseron/react](/sdk/typescript/react/), [@tesseron/svelte](/sdk/typescript/svelte/), [@tesseron/vue](/sdk/typescript/vue/).\n- [@tesseron/vite](/sdk/typescript/vite/) - the dev-server bridge that makes browser apps reachable.\n","bodyText":"## Typical entry-point layouts\n\n## Where to go next\n\n- The [action builder](/sdk/typescript/action-builder/) in full - chaining, output validation, annotations, timeouts.\n- [Standard Schema validators](/sdk/typescript/standard-schema/) - Zod vs Valibot vs ArkType, and JSON Schema interop.\n- The [context API](/sdk/typescript/context/) - progress, sampling, elicitation, logging.\n- Framework adapters: [@tesseron/react](/sdk/typescript/react/), [@tesseron/svelte](/sdk/typescript/svelte/), [@tesseron/vue](/sdk/typescript/vue/).\n- [@tesseron/vite](/sdk/typescript/vite/) - the dev-server bridge that makes browser apps reachable."},{"slug":"sdk/typescript/mcp","title":"@tesseron/mcp (MCP gateway)","description":"The MCP gateway process - a transport-agnostic dialer that discovers apps via ~/.tesseron/instances/ and bridges them to an MCP stdio transport. Bundled into the Claude Code plugin; you rarely run it by hand.","section":"sdk","related":["protocol/handshake","protocol/security","protocol/transport","protocol/transport-bindings/ws","protocol/transport-bindings/uds"],"bodyRaw":"\n`@tesseron/mcp` is the MCP gateway. It:\n\n- Watches `~/.tesseron/instances/` (and the legacy `~/.tesseron/tabs/` for one minor) for per-app instance manifests, picks a dialer matching the manifest's `transport.kind`, and connects.\n- Runs an MCP stdio server that the agent connects to.\n- Translates between the two, maintains session state, handles claim codes, fans out progress / sampling / elicitation across the boundary.\n\nThe gateway itself binds no ports. It is always a transport client — apps host, the gateway dials. This is what makes the same gateway work for browser tabs (via `@tesseron/vite`), Node processes over WebSocket or Unix domain sockets (via `@tesseron/server`), and anything else that can host one of the documented [transport bindings](/protocol/transport/) and write an instance manifest.\n\n99% of users never invoke it directly - the Claude Code plugin spawns it automatically. This page is for the 1%.\n\n## Running it manually\n\n```bash\npnpm dlx @tesseron/mcp\n```\n\nIt starts, listens on stdio for MCP, and begins watching `~/.tesseron/instances/`. Kill it with Ctrl-C.\n\n## Environment\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TESSERON_TOOL_SURFACE` | `both` | `dynamic` / `meta` / `both`. Controls which MCP tools the bridge advertises (per-app tools, meta-dispatcher tools, or both). |\n| `TESSERON_RESUME_TTL_MS` | `14_400_000` (4 hours) | How long a closed session is retained as a resumable zombie before the gateway evicts it. Non-negative integer milliseconds; `0` disables resume entirely. Invalid values log a warning to stderr and fall through to the default. Matches the `resumeTtlMs` constructor option for embedders. |\n\nNo ports, no hosts, no allowlists - the gateway has nothing to bind, so it has nothing to configure beyond the two surface knobs above.\n\nThe advertised protocol version is pinned to `PROTOCOL_VERSION` in `@tesseron/core` and is not configurable at runtime.\n\n## Discovery\n\nApps announce themselves by writing a JSON v2 manifest to `~/.tesseron/instances/<instanceId>.json`:\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-abc123\",\n \"appName\": \"vue-todo\",\n \"addedAt\": 1777038462692,\n \"pid\": 24837,\n \"transport\":\n | { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n | { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\n`pid` is optional. Gateways probe `process.kill(pid, 0)` on each manifest before dialing and tombstone manifests whose owner is gone, so a dev server killed without a clean shutdown doesn't leave a corpse the gateway re-dials forever. Older SDKs that omit the field stay trusted.\n\nThe gateway watches the directory (inotify / `fs.watch`, with a 2-second poll as a platform fallback), notices the new file, picks the dialer matching `transport.kind`, and connects. The app accepts that one connection; the standard `tesseron/hello` → `welcome` handshake follows.\n\nFor one minor version (1.1.x), the gateway also reads the legacy v1 directory `~/.tesseron/tabs/<tabId>.json` and coerces those manifests to `{ kind: 'ws', url: <wsUrl> }`. New SDKs only ever write `instances/`.\n\nA v1.2-aware host (the `@tesseron/vite` plugin since 2.2.0) writes two extra optional fields alongside the v2 baseline: `helloHandledByHost: true` and `hostMintedClaim: { code, sessionId, mintedAt, boundAgent }`. The gateway treats these as the signal \"don't auto-dial; wait for `tesseron__claim_session`\". When the user pastes the host-minted code, the gateway scans every host-mint manifest for a matching `hostMintedClaim.code`, dials only that one with a `tesseron-bind.<code>` subprotocol element on the upgrade, and the host validates the bind in constant time before accepting. v1.1 gateways ignore the new fields and fall back to legacy auto-dial; v1.2 hosts paired with v1.1 gateways detect the absent bind subprotocol and serve the legacy gateway-mints flow. See [tesseron#60](https://github.com/BrainBlend-AI/tesseron/issues/60).\n\nWhen the app process dies, the channel closes and the gateway drops the session. The app is also expected to delete its own manifest on graceful shutdown.\n\nDiscovery and dial outcomes (connect successes, connect failures, stale-manifest tombstones, foreign-claim probe results) are forwarded to the connected MCP client via `notifications/message` (`logger: \"tesseron.discovery\"`), so a developer running Claude Code sees them inline rather than having to grep `~/.claude/`. Set the level on the client side via `logging/setLevel` to filter. Stderr still receives the same lines for grep-ability.\n\nShipping support for a new runtime is three steps:\n\n1. Bind whichever [transport binding](/protocol/transport/) fits the runtime (WS, UDS, …).\n2. Write `~/.tesseron/instances/<instanceId>.json` with the matching `transport` spec.\n3. Accept the gateway's inbound connection and speak the [Tesseron wire protocol](/protocol/).\n\nThe SDK packages `@tesseron/vite` and `@tesseron/server` are reference implementations.\n\n## MCP stdio channel\n\nWhen an MCP client spawns the gateway, the gateway exposes:\n\n- One built-in tool: `tesseron__claim_session`. Always present.\n- One tool per registered action across all connected sessions, named `<app_id>__<action_name>`.\n- One resource per registered resource, URI `tesseron://<app_id>/<resource_name>`.\n- Four meta-dispatcher tools in the default `both` / `meta` surface modes:\n - `tesseron__list_actions` — enumerates every claimed session's actions and resources, plus the gateway's advertised MCP server name.\n - `tesseron__invoke_action({ app_id, action, args })` — calls any action without needing the per-app tool to be in the client's tool list.\n - `tesseron__read_resource({ app_id, name })` — reads a resource without needing the agent to know the client-side MCP server identifier (which varies by how the server is mounted; e.g. `plugin:tesseron:tesseron` under a Claude Code plugin vs. `tesseron` in a raw config). Prefer this over the generic `ReadMcpResourceTool`.\n - `tesseron__list_pending_claims` — lists every claim code the gateway can currently redeem (gateway-minted sessions waiting for claim, plus host-minted manifests with an unconsumed code). Recovery path when a previously-claimed session is invalidated mid-conversation (browser refresh, dev-server reload, resume failure) and a tools/call returns \"No claimed session found\" — call this, pick the entry whose `app_id` matches, then call `tesseron__claim_session({ code })` to re-pair without asking the user to read the new code from the app UI. See [tesseron#69](https://github.com/BrainBlend-AI/tesseron/issues/69).\n- Full MCP logging (`sendLoggingMessage`), progress (`notifications/progress`), sampling (`createMessage`), and elicitation (`elicitInput`).\n\nWhenever a session connects, claims, or drops, the gateway emits `notifications/tools/list_changed` and `notifications/resources/list_changed`. The agent refreshes automatically.\n\n## Multiple sessions\n\nThe gateway keeps a `Map<sessionId, Session>` internally. Each session has:\n\n- The registered app manifest (actions + resources).\n- A `pendingClaim` until claimed.\n- The outbound transport the gateway dialed.\n- In-flight invocation state.\n\nRouting: `tools/call shop__searchProducts` finds the session whose `app.id === \"shop\"`, dispatches `actions/invoke`, waits for the response, maps it back to an MCP tool result. If the session dropped between listing and call, the gateway returns error `-32003 ActionNotFound`.\n\n## Claim code generation\n\nCodes are six alphanumeric characters minus confusables (no `0`, `1`, `I`, `L`, `O`), formatted `AAAA-BB`. Drawn from the platform CSPRNG (`crypto.getRandomValues`) with rejection sampling so the distribution across the 31-character alphabet is uniform. Stored on the session, claimed via `gateway.claimSession(code)`, cleaned on claim or session close.\n\nEach minted code also drops a breadcrumb at `~/.tesseron/claims/<CODE>.json` so a sibling gateway (a parallel Claude Code session, a leftover dev gateway) that receives `tesseron__claim_session` for a code it doesn't own locally can surface a \"claim code belongs to gateway pid N\" error instead of a flat \"no pending session\". The breadcrumb is removed on successful claim, on unclaimed close, and on `gateway.stop()`. Embedders building their own claim UI can call `gateway.describeForeignClaim(code)` to drive the same behaviour. See the [handshake page](/protocol/handshake/#multiple-gateways-on-one-machine) for the full picture.\n\n## Where the plugin bundles it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo bundles the gateway as `plugin/server/index.cjs`, built via:\n\n```bash\npnpm --filter @tesseron/mcp build:plugin\n```\n\nThis esbuild bundle is what ships to plugin installers. If you're hacking on the gateway, rebuild the plugin bundle before testing against Claude Code.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `packages/mcp/src/cli.ts` - entry point.\n- `packages/mcp/src/gateway.ts` - session management, dialer dispatcher, instances-directory watcher.\n- `packages/mcp/src/dialer.ts` - per-binding dialers (`WsDialer`, `UdsDialer`).\n- `packages/mcp/src/session.ts` - a single session's state + claim code.\n- `packages/mcp/src/mcp-bridge.ts` - MCP stdio server + protocol translation.\n\nAdding a new method (e.g., a custom `tesseron__debug_dump` tool) means editing `mcp-bridge.ts` for the MCP side and routing through `gateway.ts` if it also crosses the SDK channel. Keep new methods under a `tesseron__` prefix to avoid colliding with app action tools.\n\nAdding a new **transport binding**: implement `GatewayDialer` for the new `kind`, register it in the gateway constructor, ship a host transport on the SDK side, document the wire format under `/protocol/transport-bindings/`. See [Port Tesseron to your language](/sdk/porting/) for the full rubric.\n\n## Not for production agents\n\nThis is a local developer tool. Apps bind locally only; the gateway only dials local endpoints. If you need remote-agent support, build a reverse-tunnel with explicit authentication in front.\n","bodyText":"`@tesseron/mcp` is the MCP gateway. It:\n\n- Watches `~/.tesseron/instances/` (and the legacy `~/.tesseron/tabs/` for one minor) for per-app instance manifests, picks a dialer matching the manifest's `transport.kind`, and connects.\n- Runs an MCP stdio server that the agent connects to.\n- Translates between the two, maintains session state, handles claim codes, fans out progress / sampling / elicitation across the boundary.\n\nThe gateway itself binds no ports. It is always a transport client — apps host, the gateway dials. This is what makes the same gateway work for browser tabs (via `@tesseron/vite`), Node processes over WebSocket or Unix domain sockets (via `@tesseron/server`), and anything else that can host one of the documented [transport bindings](/protocol/transport/) and write an instance manifest.\n\n99% of users never invoke it directly - the Claude Code plugin spawns it automatically. This page is for the 1%.\n\n## Running it manually\n\n```bash\npnpm dlx @tesseron/mcp\n```\n\nIt starts, listens on stdio for MCP, and begins watching `~/.tesseron/instances/`. Kill it with Ctrl-C.\n\n## Environment\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TESSERON_TOOL_SURFACE` | `both` | `dynamic` / `meta` / `both`. Controls which MCP tools the bridge advertises (per-app tools, meta-dispatcher tools, or both). |\n| `TESSERON_RESUME_TTL_MS` | `14_400_000` (4 hours) | How long a closed session is retained as a resumable zombie before the gateway evicts it. Non-negative integer milliseconds; `0` disables resume entirely. Invalid values log a warning to stderr and fall through to the default. Matches the `resumeTtlMs` constructor option for embedders. |\n\nNo ports, no hosts, no allowlists - the gateway has nothing to bind, so it has nothing to configure beyond the two surface knobs above.\n\nThe advertised protocol version is pinned to `PROTOCOL_VERSION` in `@tesseron/core` and is not configurable at runtime.\n\n## Discovery\n\nApps announce themselves by writing a JSON v2 manifest to `~/.tesseron/instances/<instanceId>.json`:\n\n```jsonc\n{\n \"version\": 2,\n \"instanceId\": \"inst-abc123\",\n \"appName\": \"vue-todo\",\n \"addedAt\": 1777038462692,\n \"pid\": 24837,\n \"transport\":\n | { \"kind\": \"ws\", \"url\": \"ws://127.0.0.1:64872/\" }\n | { \"kind\": \"uds\", \"path\": \"/tmp/tesseron-Xy7/sock\" }\n}\n```\n\n`pid` is optional. Gateways probe `process.kill(pid, 0)` on each manifest before dialing and tombstone manifests whose owner is gone, so a dev server killed without a clean shutdown doesn't leave a corpse the gateway re-dials forever. Older SDKs that omit the field stay trusted.\n\nThe gateway watches the directory (inotify / `fs.watch`, with a 2-second poll as a platform fallback), notices the new file, picks the dialer matching `transport.kind`, and connects. The app accepts that one connection; the standard `tesseron/hello` → `welcome` handshake follows.\n\nFor one minor version (1.1.x), the gateway also reads the legacy v1 directory `~/.tesseron/tabs/<tabId>.json` and coerces those manifests to `{ kind: 'ws', url: <wsUrl> }`. New SDKs only ever write `instances/`.\n\nA v1.2-aware host (the `@tesseron/vite` plugin since 2.2.0) writes two extra optional fields alongside the v2 baseline: `helloHandledByHost: true` and `hostMintedClaim: { code, sessionId, mintedAt, boundAgent }`. The gateway treats these as the signal \"don't auto-dial; wait for `tesseron__claim_session`\". When the user pastes the host-minted code, the gateway scans every host-mint manifest for a matching `hostMintedClaim.code`, dials only that one with a `tesseron-bind.<code>` subprotocol element on the upgrade, and the host validates the bind in constant time before accepting. v1.1 gateways ignore the new fields and fall back to legacy auto-dial; v1.2 hosts paired with v1.1 gateways detect the absent bind subprotocol and serve the legacy gateway-mints flow. See [tesseron#60](https://github.com/BrainBlend-AI/tesseron/issues/60).\n\nWhen the app process dies, the channel closes and the gateway drops the session. The app is also expected to delete its own manifest on graceful shutdown.\n\nDiscovery and dial outcomes (connect successes, connect failures, stale-manifest tombstones, foreign-claim probe results) are forwarded to the connected MCP client via `notifications/message` (`logger: \"tesseron.discovery\"`), so a developer running Claude Code sees them inline rather than having to grep `~/.claude/`. Set the level on the client side via `logging/setLevel` to filter. Stderr still receives the same lines for grep-ability.\n\nShipping support for a new runtime is three steps:\n\n1. Bind whichever [transport binding](/protocol/transport/) fits the runtime (WS, UDS, …).\n2. Write `~/.tesseron/instances/<instanceId>.json` with the matching `transport` spec.\n3. Accept the gateway's inbound connection and speak the [Tesseron wire protocol](/protocol/).\n\nThe SDK packages `@tesseron/vite` and `@tesseron/server` are reference implementations.\n\n## MCP stdio channel\n\nWhen an MCP client spawns the gateway, the gateway exposes:\n\n- One built-in tool: `tesseron__claim_session`. Always present.\n- One tool per registered action across all connected sessions, named `<app_id>__<action_name>`.\n- One resource per registered resource, URI `tesseron://<app_id>/<resource_name>`.\n- Four meta-dispatcher tools in the default `both` / `meta` surface modes:\n - `tesseron__list_actions` — enumerates every claimed session's actions and resources, plus the gateway's advertised MCP server name.\n - `tesseron__invoke_action({ app_id, action, args })` — calls any action without needing the per-app tool to be in the client's tool list.\n - `tesseron__read_resource({ app_id, name })` — reads a resource without needing the agent to know the client-side MCP server identifier (which varies by how the server is mounted; e.g. `plugin:tesseron:tesseron` under a Claude Code plugin vs. `tesseron` in a raw config). Prefer this over the generic `ReadMcpResourceTool`.\n - `tesseron__list_pending_claims` — lists every claim code the gateway can currently redeem (gateway-minted sessions waiting for claim, plus host-minted manifests with an unconsumed code). Recovery path when a previously-claimed session is invalidated mid-conversation (browser refresh, dev-server reload, resume failure) and a tools/call returns \"No claimed session found\" — call this, pick the entry whose `app_id` matches, then call `tesseron__claim_session({ code })` to re-pair without asking the user to read the new code from the app UI. See [tesseron#69](https://github.com/BrainBlend-AI/tesseron/issues/69).\n- Full MCP logging (`sendLoggingMessage`), progress (`notifications/progress`), sampling (`createMessage`), and elicitation (`elicitInput`).\n\nWhenever a session connects, claims, or drops, the gateway emits `notifications/tools/list_changed` and `notifications/resources/list_changed`. The agent refreshes automatically.\n\n## Multiple sessions\n\nThe gateway keeps a `Map<sessionId, Session>` internally. Each session has:\n\n- The registered app manifest (actions + resources).\n- A `pendingClaim` until claimed.\n- The outbound transport the gateway dialed.\n- In-flight invocation state.\n\nRouting: `tools/call shop__searchProducts` finds the session whose `app.id === \"shop\"`, dispatches `actions/invoke`, waits for the response, maps it back to an MCP tool result. If the session dropped between listing and call, the gateway returns error `-32003 ActionNotFound`.\n\n## Claim code generation\n\nCodes are six alphanumeric characters minus confusables (no `0`, `1`, `I`, `L`, `O`), formatted `AAAA-BB`. Drawn from the platform CSPRNG (`crypto.getRandomValues`) with rejection sampling so the distribution across the 31-character alphabet is uniform. Stored on the session, claimed via `gateway.claimSession(code)`, cleaned on claim or session close.\n\nEach minted code also drops a breadcrumb at `~/.tesseron/claims/<CODE>.json` so a sibling gateway (a parallel Claude Code session, a leftover dev gateway) that receives `tesseron__claim_session` for a code it doesn't own locally can surface a \"claim code belongs to gateway pid N\" error instead of a flat \"no pending session\". The breadcrumb is removed on successful claim, on unclaimed close, and on `gateway.stop()`. Embedders building their own claim UI can call `gateway.describeForeignClaim(code)` to drive the same behaviour. See the [handshake page](/protocol/handshake/#multiple-gateways-on-one-machine) for the full picture.\n\n## Where the plugin bundles it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo bundles the gateway as `plugin/server/index.cjs`, built via:\n\n```bash\npnpm --filter @tesseron/mcp build:plugin\n```\n\nThis esbuild bundle is what ships to plugin installers. If you're hacking on the gateway, rebuild the plugin bundle before testing against Claude Code.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `packages/mcp/src/cli.ts` - entry point.\n- `packages/mcp/src/gateway.ts` - session management, dialer dispatcher, instances-directory watcher.\n- `packages/mcp/src/dialer.ts` - per-binding dialers (`WsDialer`, `UdsDialer`).\n- `packages/mcp/src/session.ts` - a single session's state + claim code.\n- `packages/mcp/src/mcp-bridge.ts` - MCP stdio server + protocol translation.\n\nAdding a new method (e.g., a custom `tesseron__debug_dump` tool) means editing `mcp-bridge.ts` for the MCP side and routing through `gateway.ts` if it also crosses the SDK channel. Keep new methods under a `tesseron__` prefix to avoid colliding with app action tools.\n\nAdding a new **transport binding**: implement `GatewayDialer` for the new `kind`, register it in the gateway constructor, ship a host transport on the SDK side, document the wire format under `/protocol/transport-bindings/`. See [Port Tesseron to your language](/sdk/porting/) for the full rubric.\n\n## Not for production agents\n\nThis is a local developer tool. Apps bind locally only; the gateway only dials local endpoints. If you need remote-agent support, build a reverse-tunnel with explicit authentication in front."},{"slug":"sdk/typescript/react","title":"@tesseron/react","description":"Hooks for declarative action and resource registration inside React components.","section":"sdk","related":["sdk/typescript/web","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/react` wraps `@tesseron/web` in three hooks. Registration becomes a declarative part of your component tree; unmount tears down cleanly.\n\nNo `<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.\n\n## Exports\n\n```ts\nimport {\n useTesseronAction,\n useTesseronResource,\n useTesseronConnection,\n // Option types\n UseTesseronActionOptions,\n UseTesseronResourceOptions,\n UseTesseronConnectionOptions,\n // State\n TesseronConnectionState,\n} from '@tesseron/react';\n```\n\nThe full `@tesseron/web` surface is re-exported too.\n\n## `useTesseronConnection`\n\nManages the WebSocket for the component's lifetime.\n\n```tsx\nfunction App() {\n const { status, claimCode, welcome, error } = useTesseronConnection();\n\n if (status === 'connecting') return <p>Connecting to Tesseron…</p>;\n if (status === 'error') return <p>Gateway unavailable: {error?.message}</p>;\n if (status === 'open') return <ClaimBanner code={claimCode!} />;\n return null;\n}\n```\n\nState shape:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n resumeStatus?: 'none' | 'resumed' | 'failed';\n}\n```\n\n`resumeStatus` is set when `status === 'open'`:\n\n- `'none'` - no resume was attempted (no stored creds, or `resume` disabled).\n- `'resumed'` - `tesseron/resume` succeeded; the prior session was reattached.\n- `'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.\n\n`claimCode` clears automatically once the session has been claimed by an agent. The gateway sends a `tesseron/claimed` notification (see [protocol/handshake](/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.\n\nOptions:\n\n```ts\ninterface UseTesseronConnectionOptions {\n url?: string; // defaults to `<location.origin>/@tesseron/ws` (served by @tesseron/vite)\n enabled?: boolean; // gate the connect, e.g. only when logged in\n resume?: boolean | string | ResumeStorage;\n}\n```\n\nOnly one component should call `useTesseronConnection` per client - it owns the WebSocket. Most apps put it at the root.\n\n### Surviving page refresh / HMR with `resume`\n\nSince `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:\n\n```tsx\nconst conn = useTesseronConnection(); // resume: true is the default\n```\n\nThe hook handles the backing protocol details for you - token rotation, the [`ResumeFailed`](/protocol/resume/) 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](/protocol/resume/) for the underlying primitives.\n\nThe `resume` option accepts four forms:\n\n| Form | Behaviour |\n|---|---|\n| `true` *(default)* | Persist in `localStorage` under `'tesseron:resume'`. |\n| `false` | No persistence. Every connect is a fresh hello with a new claim code. Use for incognito-style flows. |\n| `string` | Persist in `localStorage` under that exact key. Use a per-app value if you mount multiple `WebTesseronClient` instances on one page. |\n| `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. |\n\n```ts\ninterface ResumeStorage {\n load: () =>\n | ResumeCredentials\n | null\n | undefined\n | Promise<ResumeCredentials | null | undefined>;\n save: (credentials: ResumeCredentials) => void | Promise<void>;\n clear: () => void | Promise<void>;\n}\n```\n\nResume 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.\n\nResume 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.\n\nStorage 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.\n\n## `useTesseronAction`\n\nRegisters a typed action for the component's lifetime.\n\n```tsx\nuseTesseronAction('addTodo', {\n description: 'Add a new todo',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: uuid(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n});\n```\n\nOptions:\n\n```ts\ninterface UseTesseronActionOptions<I, O> {\n description?: string;\n input?: StandardSchemaV1<I>;\n inputJsonSchema?: unknown;\n output?: StandardSchemaV1<O>;\n outputJsonSchema?: unknown;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput?: boolean;\n handler: (input: I, ctx: ActionContext) => O | Promise<O>;\n}\n```\n\nNotes:\n\n- The handler is held via a ref internally, so calling state setters from inside works without stale closures.\n- 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.\n- The hook returns nothing. The action is invoked by the agent, not by your component.\n\n## `useTesseronResource`\n\nRegisters a resource for the component's lifetime. Two call shapes, same result.\n\n```tsx\n// Short form - read-only resource\nuseTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n}));\n```\n\n```tsx\n// Full form - with description + subscribe\nuseTesseronResource('filterState', {\n description: 'Current todo filter',\n read: () => ({ search, onlyDone }),\n subscribe: (emit) => {\n const onChange = () => emit({ search, onlyDone });\n store.on('filter', onChange);\n return () => store.off('filter', onChange);\n },\n});\n```\n\nOptions:\n\n```ts\ninterface UseTesseronResourceOptions<T> {\n description?: string;\n output?: StandardSchemaV1<T>;\n outputJsonSchema?: unknown;\n read?: () => T | Promise<T>;\n subscribe?: (emit: (value: T) => void) => () => void;\n}\n```\n\n## Conditional registration\n\n`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:\n\n```tsx\nreturn (\n <>\n {user && <ActionsForLoggedInUsers />}\n <GlobalActions />\n </>\n);\n```\n\nDon't try to conditionally call the hooks themselves - that breaks the Rules of Hooks.\n\n## Full component example\n\nPulled from `examples/react-todo/src/app.tsx`:\n\n```tsx\nimport { useTesseronAction, useTesseronConnection, useTesseronResource } from '@tesseron/react';\nimport { z } from 'zod';\nimport { useState } from 'react';\n\ntype Todo = { id: string; text: string; done: boolean };\n\nexport function TodoApp() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: crypto.randomUUID(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronAction('toggleTodo', {\n input: z.object({ id: z.string() }),\n annotations: { destructive: true },\n handler: ({ id }) => {\n setTodos((prev) =>\n prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),\n );\n return { id };\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.status === 'open' && conn.claimCode && (\n <ClaimBanner code={conn.claimCode} />\n )}\n <TodoList todos={todos} />\n </>\n );\n}\n```\n","bodyText":"`@tesseron/react` wraps `@tesseron/web` in three hooks. Registration becomes a declarative part of your component tree; unmount tears down cleanly.\n\nNo `<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.\n\n## Exports\n\n```ts\n\n useTesseronAction,\n useTesseronResource,\n useTesseronConnection,\n // Option types\n UseTesseronActionOptions,\n UseTesseronResourceOptions,\n UseTesseronConnectionOptions,\n // State\n TesseronConnectionState,\n} from '@tesseron/react';\n```\n\nThe full `@tesseron/web` surface is re-exported too.\n\n## `useTesseronConnection`\n\nManages the WebSocket for the component's lifetime.\n\n```tsx\nfunction App() {\n const { status, claimCode, welcome, error } = useTesseronConnection();\n\n if (status === 'connecting') return <p>Connecting to Tesseron…</p>;\n if (status === 'error') return <p>Gateway unavailable: {error?.message}</p>;\n if (status === 'open') return ;\n return null;\n}\n```\n\nState shape:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n resumeStatus?: 'none' | 'resumed' | 'failed';\n}\n```\n\n`resumeStatus` is set when `status === 'open'`:\n\n- `'none'` - no resume was attempted (no stored creds, or `resume` disabled).\n- `'resumed'` - `tesseron/resume` succeeded; the prior session was reattached.\n- `'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.\n\n`claimCode` clears automatically once the session has been claimed by an agent. The gateway sends a `tesseron/claimed` notification (see [protocol/handshake](/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.\n\nOptions:\n\n```ts\ninterface UseTesseronConnectionOptions {\n url?: string; // defaults to `<location.origin>/@tesseron/ws` (served by @tesseron/vite)\n enabled?: boolean; // gate the connect, e.g. only when logged in\n resume?: boolean | string | ResumeStorage;\n}\n```\n\nOnly one component should call `useTesseronConnection` per client - it owns the WebSocket. Most apps put it at the root.\n\n### Surviving page refresh / HMR with `resume`\n\nSince `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:\n\n```tsx\nconst conn = useTesseronConnection(); // resume: true is the default\n```\n\nThe hook handles the backing protocol details for you - token rotation, the [`ResumeFailed`](/protocol/resume/) 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](/protocol/resume/) for the underlying primitives.\n\nThe `resume` option accepts four forms:\n\n| Form | Behaviour |\n|---|---|\n| `true` *(default)* | Persist in `localStorage` under `'tesseron:resume'`. |\n| `false` | No persistence. Every connect is a fresh hello with a new claim code. Use for incognito-style flows. |\n| `string` | Persist in `localStorage` under that exact key. Use a per-app value if you mount multiple `WebTesseronClient` instances on one page. |\n| `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. |\n\n```ts\ninterface ResumeStorage {\n load: () =>\n | ResumeCredentials\n | null\n | undefined\n | Promise<ResumeCredentials | null | undefined>;\n save: (credentials: ResumeCredentials) => void | Promise<void>;\n clear: () => void | Promise<void>;\n}\n```\n\nResume 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.\n\nResume 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.\n\nStorage 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.\n\n## `useTesseronAction`\n\nRegisters a typed action for the component's lifetime.\n\n```tsx\nuseTesseronAction('addTodo', {\n description: 'Add a new todo',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: uuid(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n});\n```\n\nOptions:\n\n```ts\ninterface UseTesseronActionOptions<I, O> {\n description?: string;\n input?: StandardSchemaV1<I>;\n inputJsonSchema?: unknown;\n output?: StandardSchemaV1<O>;\n outputJsonSchema?: unknown;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput?: boolean;\n handler: (input: I, ctx: ActionContext) => O | Promise<O>;\n}\n```\n\nNotes:\n\n- The handler is held via a ref internally, so calling state setters from inside works without stale closures.\n- 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.\n- The hook returns nothing. The action is invoked by the agent, not by your component.\n\n## `useTesseronResource`\n\nRegisters a resource for the component's lifetime. Two call shapes, same result.\n\n```tsx\n// Short form - read-only resource\nuseTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n}));\n```\n\n```tsx\n// Full form - with description + subscribe\nuseTesseronResource('filterState', {\n description: 'Current todo filter',\n read: () => ({ search, onlyDone }),\n subscribe: (emit) => {\n const onChange = () => emit({ search, onlyDone });\n store.on('filter', onChange);\n return () => store.off('filter', onChange);\n },\n});\n```\n\nOptions:\n\n```ts\ninterface UseTesseronResourceOptions<T> {\n description?: string;\n output?: StandardSchemaV1<T>;\n outputJsonSchema?: unknown;\n read?: () => T | Promise<T>;\n subscribe?: (emit: (value: T) => void) => () => void;\n}\n```\n\n## Conditional registration\n\n`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:\n\n```tsx\nreturn (\n <>\n {user && }\n \n </>\n);\n```\n\nDon't try to conditionally call the hooks themselves - that breaks the Rules of Hooks.\n\n## Full component example\n\nPulled from `examples/react-todo/src/app.tsx`:\n\n```tsx\n\ntype Todo = { id: string; text: string; done: boolean };\n\nexport function TodoApp() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: crypto.randomUUID(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronAction('toggleTodo', {\n input: z.object({ id: z.string() }),\n annotations: { destructive: true },\n handler: ({ id }) => {\n setTodos((prev) =>\n prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),\n );\n return { id };\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.status === 'open' && conn.claimCode && (\n \n )}\n \n </>\n );\n}\n```"},{"slug":"sdk/typescript/resources","title":"Resources","description":"Declaring readable and subscribable state for the agent to observe.","section":"sdk","related":["protocol/resources","sdk/typescript/core"],"bodyRaw":"\nA resource is a named piece of app state. The agent can read it on demand and, if your resource supports it, subscribe for live updates.\n\nSee the [protocol resources page](/protocol/resources/) for wire format. This page focuses on the builder API.\n\n## Builder shape\n\n```ts\ninterface ResourceBuilder<T> {\n describe(description: string): ResourceBuilder<T>;\n output<NewT>(schema: StandardSchemaV1<NewT>, jsonSchema?: unknown): ResourceBuilder<NewT>;\n read(fn: () => T | Promise<T>): ResourceBuilder<T>;\n subscribe(setup: (emit: (value: T) => void) => () => void): ResourceBuilder<T>;\n}\n```\n\nEither `.read()` or `.subscribe()` commits the resource to the client's registry - you can call both (in any order) and the registered entry is updated in place. The agent sees the resource as subscribable as soon as `.subscribe()` is called.\n\n## Read-only resource\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is viewing')\n .read(() => window.location.pathname);\n```\n\nThe agent calls `resources/read tesseron://<app_id>/currentRoute` whenever it wants the value. `.read()` runs on each request.\n\n## Subscribable resource\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `setup` runs once, at subscription time.\n- Call `emit(value)` whenever the value changes.\n- Return an unsubscribe function; the SDK calls it on `resources/unsubscribe` or when the session closes.\n- `.subscribe()` does not terminate the builder - it returns `ResourceBuilder<T>` so you can keep chaining if you want.\n\n## Patterns\n\n### Debounce emissions\n\nThe agent can't usefully consume 60 emissions per second. Debounce:\n\n```ts\n.subscribe((emit) => {\n let t: ReturnType<typeof setTimeout> | null = null;\n const push = () => {\n if (t) clearTimeout(t);\n t = setTimeout(() => emit(stateSnapshot()), 200);\n };\n store.on('change', push);\n return () => { if (t) clearTimeout(t); store.off('change', push); };\n});\n```\n\n### Memoise the read\n\nIf `.read()` is expensive and you also have `.subscribe()`, hold the latest emitted value and serve `.read()` from it:\n\n```ts\nlet latest = initialValue();\n\ntesseron.resource('filterState')\n .read(() => latest)\n .subscribe((emit) => {\n const onChange = () => { latest = compute(); emit(latest); };\n store.on('change', onChange);\n return () => store.off('change', onChange);\n });\n```\n\n### Typed schema\n\nSchemas on resources feed into the MCP descriptor, same as actions:\n\n```ts\n.output(z.object({ search: z.string(), onlyDone: z.boolean() }))\n.read(() => ({ search: state.search, onlyDone: state.onlyDone }))\n```\n\nReads are not schema-validated at runtime by default - the schema is documentation. If you need enforcement, do it yourself inside `.read()` and `.subscribe()` emit.\n\n## What to expose (and what not to)\n\nGood resources:\n\n- User's current route, selected item, filter state.\n- \"What's on screen right now\" - the agent uses these to reason before acting.\n- Counts and summaries - `todoStats`, `unreadCount`.\n- Document content the agent is editing.\n\nBad resources:\n\n- Credentials, session tokens, PII the user hasn't consented to share.\n- Full database dumps - reads happen on demand and can be expensive.\n- High-frequency streams (mouse position, scroll offset) - debounce or expose a summary instead.\n\n## React adapter\n\n`@tesseron/react` wraps the same builder as a hook:\n\n```tsx\nimport { useTesseronResource } from '@tesseron/react';\n\nuseTesseronResource('currentRoute', () => window.location.pathname);\n// or with options:\nuseTesseronResource('currentRoute', {\n description: 'Route',\n read: () => window.location.pathname,\n subscribe: (emit) => { /* … */ return () => {}; },\n});\n```\n\nSee [the react adapter page](/sdk/typescript/react/) for full hook docs.\n","bodyText":"A resource is a named piece of app state. The agent can read it on demand and, if your resource supports it, subscribe for live updates.\n\nSee the [protocol resources page](/protocol/resources/) for wire format. This page focuses on the builder API.\n\n## Builder shape\n\n```ts\ninterface ResourceBuilder<T> {\n describe(description: string): ResourceBuilder<T>;\n output<NewT>(schema: StandardSchemaV1<NewT>, jsonSchema?: unknown): ResourceBuilder<NewT>;\n read(fn: () => T | Promise<T>): ResourceBuilder<T>;\n subscribe(setup: (emit: (value: T) => void) => () => void): ResourceBuilder<T>;\n}\n```\n\nEither `.read()` or `.subscribe()` commits the resource to the client's registry - you can call both (in any order) and the registered entry is updated in place. The agent sees the resource as subscribable as soon as `.subscribe()` is called.\n\n## Read-only resource\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is viewing')\n .read(() => window.location.pathname);\n```\n\nThe agent calls `resources/read tesseron://<app_id>/currentRoute` whenever it wants the value. `.read()` runs on each request.\n\n## Subscribable resource\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `setup` runs once, at subscription time.\n- Call `emit(value)` whenever the value changes.\n- Return an unsubscribe function; the SDK calls it on `resources/unsubscribe` or when the session closes.\n- `.subscribe()` does not terminate the builder - it returns `ResourceBuilder<T>` so you can keep chaining if you want.\n\n## Patterns\n\n### Debounce emissions\n\nThe agent can't usefully consume 60 emissions per second. Debounce:\n\n```ts\n.subscribe((emit) => {\n let t: ReturnType<typeof setTimeout> | null = null;\n const push = () => {\n if (t) clearTimeout(t);\n t = setTimeout(() => emit(stateSnapshot()), 200);\n };\n store.on('change', push);\n return () => { if (t) clearTimeout(t); store.off('change', push); };\n});\n```\n\n### Memoise the read\n\nIf `.read()` is expensive and you also have `.subscribe()`, hold the latest emitted value and serve `.read()` from it:\n\n```ts\nlet latest = initialValue();\n\ntesseron.resource('filterState')\n .read(() => latest)\n .subscribe((emit) => {\n const onChange = () => { latest = compute(); emit(latest); };\n store.on('change', onChange);\n return () => store.off('change', onChange);\n });\n```\n\n### Typed schema\n\nSchemas on resources feed into the MCP descriptor, same as actions:\n\n```ts\n.output(z.object({ search: z.string(), onlyDone: z.boolean() }))\n.read(() => ({ search: state.search, onlyDone: state.onlyDone }))\n```\n\nReads are not schema-validated at runtime by default - the schema is documentation. If you need enforcement, do it yourself inside `.read()` and `.subscribe()` emit.\n\n## What to expose (and what not to)\n\nGood resources:\n\n- User's current route, selected item, filter state.\n- \"What's on screen right now\" - the agent uses these to reason before acting.\n- Counts and summaries - `todoStats`, `unreadCount`.\n- Document content the agent is editing.\n\nBad resources:\n\n- Credentials, session tokens, PII the user hasn't consented to share.\n- Full database dumps - reads happen on demand and can be expensive.\n- High-frequency streams (mouse position, scroll offset) - debounce or expose a summary instead.\n\n## React adapter\n\n`@tesseron/react` wraps the same builder as a hook:\n\n```tsx\n\nuseTesseronResource('currentRoute', () => window.location.pathname);\n// or with options:\nuseTesseronResource('currentRoute', {\n description: 'Route',\n read: () => window.location.pathname,\n subscribe: (emit) => { /* … */ return () => {}; },\n});\n```\n\nSee [the react adapter page](/sdk/typescript/react/) for full hook docs."},{"slug":"sdk/typescript/server","title":"@tesseron/server","description":"The Node SDK. Hosts a loopback WebSocket or Unix domain socket, announces itself via ~/.tesseron/instances/, and waits for the gateway to dial in.","section":"sdk","related":["sdk/typescript/core","protocol/transport","protocol/transport-bindings/ws","protocol/transport-bindings/uds","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/server` is what you use in a Node process - an Express server, a NestJS app, a CLI tool, a background worker, an Electron main process. The builder API is identical to `@tesseron/web`; the transport is what's different.\n\n## How it connects\n\nUnlike the browser SDK, Node can host its own listener. `@tesseron/server` ships two transport bindings and picks one based on `connect()` options:\n\n- **WebSocket on loopback** (default). Binds `127.0.0.1` on an OS-picked port.\n- **Unix domain socket**, opt-in via `tesseron.connect({ transport: 'uds' })`. Linux + macOS only; falls back to WS on Windows.\n\nEither way the connection flow is the same:\n\n1. On `tesseron.connect()` the SDK creates the host endpoint.\n2. Writes `~/.tesseron/instances/<instanceId>.json` with a `{ kind, url | path }` spec.\n3. Waits for the gateway to dial in.\n4. On the first and only accepted connection, sends `tesseron/hello` and runs the normal Tesseron handshake.\n\nNo environment variables, no fixed ports, no client URL. The instance manifest does everything.\n\n## When to use server vs web\n\n| Use server when | Use web when |\n|---|---|\n| The handler's work lives on the backend (DB writes, queue jobs, filesystem). | The handler's work needs DOM or browser APIs. |\n| You don't need the user's tab to be open. | The agent should only work while the user is viewing the page. |\n| You want a headless service that Claude can drive. | You want Claude to drive the UI the user is already looking at. |\n\nBoth can run at the same time against the same MCP gateway - [multi-app coexistence](/protocol/security/#multi-app-coexistence) is first-class.\n\n## Exports\n\n```ts\nimport {\n // Singleton client - pre-constructed, use directly.\n tesseron,\n // Class (if you need multiple clients per process).\n ServerTesseronClient,\n // WS-binding transport — WS server + manifest writer.\n NodeWebSocketServerTransport,\n type NodeWebSocketServerTransportOptions,\n // UDS-binding transport — net server + manifest writer.\n UnixSocketServerTransport,\n type UnixSocketServerTransportOptions,\n} from '@tesseron/server';\n```\n\n## Typical process layout\n\n```ts\nimport { tesseron } from '@tesseron/server';\nimport { z } from 'zod';\n\ntesseron.app({\n id: 'notes_api',\n name: 'Notes API',\n description: 'CRUD over the notes store',\n});\n\ntesseron\n .action('createNote')\n .input(z.object({ title: z.string(), body: z.string() }))\n .handler(async ({ title, body }) => {\n return db.notes.insert({ title, body });\n });\n\ntesseron.resource('noteCount').read(() => db.notes.count());\n\nasync function main() {\n const welcome = await tesseron.connect();\n console.log(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n}\n\nmain().catch((err) => {\n console.error(err);\n process.exit(1);\n});\n\nasync function shutdown() {\n await tesseron.disconnect();\n process.exit(0);\n}\nprocess.on('SIGINT', shutdown);\nprocess.on('SIGTERM', shutdown);\n```\n\n## Customising the bind\n\n### WebSocket binding (default)\n\n```ts\nawait tesseron.connect({ appName: 'notes_api', host: '127.0.0.1', port: 0 });\n```\n\n- `appName` - stamped into the instance manifest so the gateway log names your app usefully. Defaults to `'node'`.\n- `host` - always `127.0.0.1` in practice; exposed for tests that need `::1`.\n- `port` - `0` (OS picks) is almost always what you want. Setting a fixed port only matters if you're reverse-tunnelling the transport.\n\n### UDS binding\n\n```ts\nawait tesseron.connect({ transport: 'uds', appName: 'notes_api' });\n// or, override the socket path:\nawait tesseron.connect({ transport: 'uds', path: '/tmp/notes.sock' });\n```\n\n- `appName` - same as WS.\n- `path` - omit to let the SDK create a per-process 0700 temp dir under `os.tmpdir()` and bind `<dir>/sock` inside (recommended; the parent dir is the access gate). Pin a path only if you need to coordinate with another process that expects it.\n\nPass a `Transport` instead to bypass bind-and-announce entirely - useful in tests or when you're piping frames through some other channel.\n\n## Express example\n\nThe [`express-prompts` example](/examples/express-prompts/) shows the canonical \"HTTP + Tesseron on one Node process\" pattern. Keep the shared state outside both entry points; each channel calls the same functions:\n\n```ts\nconst prompts = new Map<string, Prompt>();\n\n// REST surface\napp.post('/prompts', (req, res) => {\n const p = createPrompt(prompts, req.body);\n res.status(201).json(p);\n});\n\n// Tesseron surface - same underlying function\ntesseron.action('addPrompt')\n .input(z.object({ name: z.string(), template: z.string() }))\n .handler((input) => createPrompt(prompts, input));\n```\n\n## Transport details\n\n### `NodeWebSocketServerTransport` (WS binding)\n\nWraps the [`ws`](https://github.com/websockets/ws) npm package (v8). It:\n\n- Binds a WebSocket server via Node's built-in `http.createServer`.\n- Accepts exactly one upgrade request that advertises the `tesseron-gateway` subprotocol; every other upgrade attempt is destroyed.\n- Tolerates every frame shape `ws` hands back - `string`, `Buffer`, `Buffer[]`, `ArrayBuffer` - and coerces to UTF-8 before parsing.\n- Writes its instance manifest on `listen()` and deletes it on `close()`.\n\nSee the [WebSocket binding spec](/protocol/transport-bindings/ws/) for the wire-level rules.\n\n### `UnixSocketServerTransport` (UDS binding)\n\nWraps Node's `net` module. It:\n\n- Creates a private (mode `0700`) directory under `os.tmpdir()` and binds a socket inside it (or uses the path you supplied).\n- `chmod 0600`s the socket file after bind, so the inode rejects connect attempts from other UIDs.\n- Accepts exactly one connection; rejects subsequent connect attempts.\n- Frames messages as NDJSON: `JSON.stringify(msg) + '\\n'` per outbound, `\\n`-split on inbound.\n- Writes its instance manifest on bind and deletes it (plus the temp dir) on `close()`.\n\nSee the [UDS binding spec](/protocol/transport-bindings/uds/) for the wire-level rules and the Windows limitation.\n\n## Running under Docker / systemd\n\nTwo things to get right:\n\n1. **Same HOME dir as the gateway.** The gateway reads `~/.tesseron/instances/`; your Node process has to write there. In containers, mount `~/.tesseron` into the container's `$HOME`.\n2. **Signal handling.** `process.on('SIGTERM', …)` to call `tesseron.disconnect()` before exit cleans up the manifest and gives the gateway a clean close (code 1001 on WS, normal `'close'` on UDS) so the agent doesn't see abrupt tool failures.\n\nClaim codes surface on stdout/stderr of your Node process, not the gateway's. Plan how you expose them to humans - a web UI endpoint, a file you rotate, whatever fits.\n\n## Capabilities\n\nServer handlers get the same `ActionContext` as browser handlers. Two differences to know:\n\n- `ctx.client.origin` - fabricated. Typically the string `\"node:<app.id>\"` or similar. Don't use it for auth.\n- `ctx.client.route` - always `undefined`. There's no \"current route\" on the server.\n\nEverything else - `progress`, `sample`, `elicit`, `log`, `signal` - behaves the same.\n","bodyText":"`@tesseron/server` is what you use in a Node process - an Express server, a NestJS app, a CLI tool, a background worker, an Electron main process. The builder API is identical to `@tesseron/web`; the transport is what's different.\n\n## How it connects\n\nUnlike the browser SDK, Node can host its own listener. `@tesseron/server` ships two transport bindings and picks one based on `connect()` options:\n\n- **WebSocket on loopback** (default). Binds `127.0.0.1` on an OS-picked port.\n- **Unix domain socket**, opt-in via `tesseron.connect({ transport: 'uds' })`. Linux + macOS only; falls back to WS on Windows.\n\nEither way the connection flow is the same:\n\n1. On `tesseron.connect()` the SDK creates the host endpoint.\n2. Writes `~/.tesseron/instances/<instanceId>.json` with a `{ kind, url | path }` spec.\n3. Waits for the gateway to dial in.\n4. On the first and only accepted connection, sends `tesseron/hello` and runs the normal Tesseron handshake.\n\nNo environment variables, no fixed ports, no client URL. The instance manifest does everything.\n\n## When to use server vs web\n\n| Use server when | Use web when |\n|---|---|\n| The handler's work lives on the backend (DB writes, queue jobs, filesystem). | The handler's work needs DOM or browser APIs. |\n| You don't need the user's tab to be open. | The agent should only work while the user is viewing the page. |\n| You want a headless service that Claude can drive. | You want Claude to drive the UI the user is already looking at. |\n\nBoth can run at the same time against the same MCP gateway - [multi-app coexistence](/protocol/security/#multi-app-coexistence) is first-class.\n\n## Exports\n\n```ts\n\n // Singleton client - pre-constructed, use directly.\n tesseron,\n // Class (if you need multiple clients per process).\n ServerTesseronClient,\n // WS-binding transport — WS server + manifest writer.\n NodeWebSocketServerTransport,\n type NodeWebSocketServerTransportOptions,\n // UDS-binding transport — net server + manifest writer.\n UnixSocketServerTransport,\n type UnixSocketServerTransportOptions,\n} from '@tesseron/server';\n```\n\n## Typical process layout\n\n```ts\n\ntesseron.app({\n id: 'notes_api',\n name: 'Notes API',\n description: 'CRUD over the notes store',\n});\n\ntesseron\n .action('createNote')\n .input(z.object({ title: z.string(), body: z.string() }))\n .handler(async ({ title, body }) => {\n return db.notes.insert({ title, body });\n });\n\ntesseron.resource('noteCount').read(() => db.notes.count());\n\nasync function main() {\n const welcome = await tesseron.connect();\n console.log(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n}\n\nmain().catch((err) => {\n console.error(err);\n process.exit(1);\n});\n\nasync function shutdown() {\n await tesseron.disconnect();\n process.exit(0);\n}\nprocess.on('SIGINT', shutdown);\nprocess.on('SIGTERM', shutdown);\n```\n\n## Customising the bind\n\n### WebSocket binding (default)\n\n```ts\nawait tesseron.connect({ appName: 'notes_api', host: '127.0.0.1', port: 0 });\n```\n\n- `appName` - stamped into the instance manifest so the gateway log names your app usefully. Defaults to `'node'`.\n- `host` - always `127.0.0.1` in practice; exposed for tests that need `::1`.\n- `port` - `0` (OS picks) is almost always what you want. Setting a fixed port only matters if you're reverse-tunnelling the transport.\n\n### UDS binding\n\n```ts\nawait tesseron.connect({ transport: 'uds', appName: 'notes_api' });\n// or, override the socket path:\nawait tesseron.connect({ transport: 'uds', path: '/tmp/notes.sock' });\n```\n\n- `appName` - same as WS.\n- `path` - omit to let the SDK create a per-process 0700 temp dir under `os.tmpdir()` and bind `<dir>/sock` inside (recommended; the parent dir is the access gate). Pin a path only if you need to coordinate with another process that expects it.\n\nPass a `Transport` instead to bypass bind-and-announce entirely - useful in tests or when you're piping frames through some other channel.\n\n## Express example\n\nThe [`express-prompts` example](/examples/express-prompts/) shows the canonical \"HTTP + Tesseron on one Node process\" pattern. Keep the shared state outside both entry points; each channel calls the same functions:\n\n```ts\nconst prompts = new Map<string, Prompt>();\n\n// REST surface\napp.post('/prompts', (req, res) => {\n const p = createPrompt(prompts, req.body);\n res.status(201).json(p);\n});\n\n// Tesseron surface - same underlying function\ntesseron.action('addPrompt')\n .input(z.object({ name: z.string(), template: z.string() }))\n .handler((input) => createPrompt(prompts, input));\n```\n\n## Transport details\n\n### `NodeWebSocketServerTransport` (WS binding)\n\nWraps the [`ws`](https://github.com/websockets/ws) npm package (v8). It:\n\n- Binds a WebSocket server via Node's built-in `http.createServer`.\n- Accepts exactly one upgrade request that advertises the `tesseron-gateway` subprotocol; every other upgrade attempt is destroyed.\n- Tolerates every frame shape `ws` hands back - `string`, `Buffer`, `Buffer[]`, `ArrayBuffer` - and coerces to UTF-8 before parsing.\n- Writes its instance manifest on `listen()` and deletes it on `close()`.\n\nSee the [WebSocket binding spec](/protocol/transport-bindings/ws/) for the wire-level rules.\n\n### `UnixSocketServerTransport` (UDS binding)\n\nWraps Node's `net` module. It:\n\n- Creates a private (mode `0700`) directory under `os.tmpdir()` and binds a socket inside it (or uses the path you supplied).\n- `chmod 0600`s the socket file after bind, so the inode rejects connect attempts from other UIDs.\n- Accepts exactly one connection; rejects subsequent connect attempts.\n- Frames messages as NDJSON: `JSON.stringify(msg) + '\\n'` per outbound, `\\n`-split on inbound.\n- Writes its instance manifest on bind and deletes it (plus the temp dir) on `close()`.\n\nSee the [UDS binding spec](/protocol/transport-bindings/uds/) for the wire-level rules and the Windows limitation.\n\n## Running under Docker / systemd\n\nTwo things to get right:\n\n1. **Same HOME dir as the gateway.** The gateway reads `~/.tesseron/instances/`; your Node process has to write there. In containers, mount `~/.tesseron` into the container's `$HOME`.\n2. **Signal handling.** `process.on('SIGTERM', …)` to call `tesseron.disconnect()` before exit cleans up the manifest and gives the gateway a clean close (code 1001 on WS, normal `'close'` on UDS) so the agent doesn't see abrupt tool failures.\n\nClaim codes surface on stdout/stderr of your Node process, not the gateway's. Plan how you expose them to humans - a web UI endpoint, a file you rotate, whatever fits.\n\n## Capabilities\n\nServer handlers get the same `ActionContext` as browser handlers. Two differences to know:\n\n- `ctx.client.origin` - fabricated. Typically the string `\"node:<app.id>\"` or similar. Don't use it for auth.\n- `ctx.client.route` - always `undefined`. There's no \"current route\" on the server.\n\nEverything else - `progress`, `sample`, `elicit`, `log`, `signal` - behaves the same."},{"slug":"sdk/typescript/standard-schema","title":"Standard Schema (Zod, Valibot, …)","description":"Any Standard Schema v1 validator works. What that means, which libraries are supported, and how to handle JSON Schema export.","section":"sdk","related":["sdk/typescript/action-builder"],"bodyRaw":"\nTesseron's action builder accepts any validator that implements [Standard Schema v1](https://standardschema.dev). That's a small contract that most modern TypeScript validation libraries already expose:\n\n```ts\ninterface StandardSchemaV1<T> {\n readonly '~standard': {\n version: 1;\n vendor: string;\n validate(value: unknown): { value: T } | { issues: Issue[] } | Promise<…>;\n };\n}\n```\n\nBecause the contract is minimal, the SDK doesn't care which library you use. Pick whichever is already in your project - or whichever feels best for writing schemas for agents.\n\n## Supported libraries\n\nAll of these implement Standard Schema v1 and work with Tesseron:\n\n| Library | Notes |\n|---|---|\n| [Zod](https://zod.dev) | De-facto default. The smoothest DX, broadest ecosystem, native `toJSONSchema`. |\n| [Valibot](https://valibot.dev) | Tree-shakable, smaller bundle, functional style. |\n| [ArkType](https://arktype.io) | TypeScript-first; schemas read like runtime type expressions. |\n| [Effect Schema](https://effect.website) | Part of the Effect ecosystem; best if you already use Effect. |\n| [TypeBox](https://github.com/sinclairzx81/typebox) | JSON-Schema-first; schemas *are* JSON Schema. |\n\nIf your library isn't on the list, check its docs for \"Standard Schema\" - most have it or are adding it.\n\n## Input validation\n\nWhichever library you use, the behaviour is the same:\n\n```ts\n.input(validator)\n```\n\n- Before the handler runs, the SDK calls `validator['~standard'].validate(input)`.\n- On `{ issues }` → the invocation fails with `-32004 InputValidation`; `issues` ride in `error.data`.\n- On `{ value }` → the parsed `value` is passed to your handler, typed as `I`.\n\n## Output validation\n\nDefault (informational):\n\n```ts\n.output(validator)\n```\n\nThe SDK does not validate - it uses the schema for JSON Schema export and nothing else.\n\nStrict:\n\n```ts\n.output(validator).strictOutput()\n```\n\nThe SDK validates the handler's return value the same way it validates input. Failures raise `-32005 HandlerError`.\n\n## JSON Schema export\n\nThe wire protocol transports each action's input and output as JSON Schema (for the MCP tool descriptor). The agent reads it to know which fields exist, what types they expect, and how to format invocations. A typeless permissive schema means the LLM has to guess - including, sometimes, JSON-encoding numbers as strings.\n\nThere are three paths the SDK checks, in order:\n\n### 1. Auto-derive from the validator\n\nIf your schema's vendor exposes a JSON Schema converter on the schema object, the SDK calls it automatically. No extra work in your action code.\n\n| Validator | Auto-derive | How |\n|---|---|---|\n| **Zod 4+** | ✅ | calls `schema.toJSONSchema()` (instance method) |\n| **TypeBox** | ✅ | the schema object IS the JSON Schema; the SDK strips the Standard Schema metadata and passes it through |\n| **ArkType** | ✅ | calls `schema.toJsonSchema()` (instance method) |\n| Zod 3 | ❌ | no native exporter; pass JSON Schema explicitly (see below), or use `zod-to-json-schema` |\n| Valibot | ❌ | install `@valibot/to-json-schema` and pass the result explicitly |\n| Effect Schema | ❌ | call `JSONSchema.make(schema)` from `@effect/schema/JSONSchema` and pass the result explicitly |\n\nIf auto-derivation throws (e.g. the validator hits an unsupported feature), the SDK silently falls back - no exception escapes into your action wiring.\n\n### 2. Pass it manually\n\nAlways wins over auto-derivation. Pass the JSON Schema as the second argument:\n\n```ts\n.input(\n myValidator,\n {\n type: 'object',\n properties: { query: { type: 'string' }, limit: { type: 'integer', default: 10 } },\n required: ['query'],\n },\n)\n```\n\nFor Valibot, Effect Schema, or Zod 3, this is the path you'll typically take. Run your validator's converter once and pass the result.\n\n### 3. Fallback\n\nIf both paths above produce nothing, the SDK sends `{ type: 'object', additionalProperties: true }` - permissive, unhelpful to the agent, but the call still works. Avoid this where you can: agents on a permissive schema sometimes JSON-encode numbers as strings (because they have no type signal), and the call then fails Zod runtime validation.\n\n## Zod idioms that help the agent\n\n- Use `.describe()` on fields. The text shows up in the generated JSON Schema as `description`, which the agent reads when deciding what to pass.\n- Prefer `z.enum(['a', 'b'])` over `z.string()` when there's a finite set - gives the agent the choices up front.\n- Provide defaults for optional-looking fields: `z.number().int().default(10)`.\n- Avoid deeply nested structures. Flatten where possible.\n\n```ts\n.input(z.object({\n query: z.string().describe('Full-text search query; empty string matches all.'),\n limit: z.number().int().min(1).max(100).default(20).describe('Max results to return.'),\n sort: z.enum(['relevance', 'date', 'price']).default('relevance'),\n}))\n```\n\n## Mixing validators\n\nYou can use different validators across actions in the same app. Use Zod for one, Valibot for another - the SDK doesn't care. Consistency inside a project is mostly a tooling preference, not a correctness requirement.\n","bodyText":"Tesseron's action builder accepts any validator that implements [Standard Schema v1](https://standardschema.dev). That's a small contract that most modern TypeScript validation libraries already expose:\n\n```ts\ninterface StandardSchemaV1<T> {\n readonly '~standard': {\n version: 1;\n vendor: string;\n validate(value: unknown): { value: T } | { issues: Issue[] } | Promise<…>;\n };\n}\n```\n\nBecause the contract is minimal, the SDK doesn't care which library you use. Pick whichever is already in your project - or whichever feels best for writing schemas for agents.\n\n## Supported libraries\n\nAll of these implement Standard Schema v1 and work with Tesseron:\n\n| Library | Notes |\n|---|---|\n| [Zod](https://zod.dev) | De-facto default. The smoothest DX, broadest ecosystem, native `toJSONSchema`. |\n| [Valibot](https://valibot.dev) | Tree-shakable, smaller bundle, functional style. |\n| [ArkType](https://arktype.io) | TypeScript-first; schemas read like runtime type expressions. |\n| [Effect Schema](https://effect.website) | Part of the Effect ecosystem; best if you already use Effect. |\n| [TypeBox](https://github.com/sinclairzx81/typebox) | JSON-Schema-first; schemas *are* JSON Schema. |\n\nIf your library isn't on the list, check its docs for \"Standard Schema\" - most have it or are adding it.\n\n## Input validation\n\nWhichever library you use, the behaviour is the same:\n\n```ts\n.input(validator)\n```\n\n- Before the handler runs, the SDK calls `validator['~standard'].validate(input)`.\n- On `{ issues }` → the invocation fails with `-32004 InputValidation`; `issues` ride in `error.data`.\n- On `{ value }` → the parsed `value` is passed to your handler, typed as `I`.\n\n## Output validation\n\nDefault (informational):\n\n```ts\n.output(validator)\n```\n\nThe SDK does not validate - it uses the schema for JSON Schema export and nothing else.\n\nStrict:\n\n```ts\n.output(validator).strictOutput()\n```\n\nThe SDK validates the handler's return value the same way it validates input. Failures raise `-32005 HandlerError`.\n\n## JSON Schema export\n\nThe wire protocol transports each action's input and output as JSON Schema (for the MCP tool descriptor). The agent reads it to know which fields exist, what types they expect, and how to format invocations. A typeless permissive schema means the LLM has to guess - including, sometimes, JSON-encoding numbers as strings.\n\nThere are three paths the SDK checks, in order:\n\n### 1. Auto-derive from the validator\n\nIf your schema's vendor exposes a JSON Schema converter on the schema object, the SDK calls it automatically. No extra work in your action code.\n\n| Validator | Auto-derive | How |\n|---|---|---|\n| **Zod 4+** | ✅ | calls `schema.toJSONSchema()` (instance method) |\n| **TypeBox** | ✅ | the schema object IS the JSON Schema; the SDK strips the Standard Schema metadata and passes it through |\n| **ArkType** | ✅ | calls `schema.toJsonSchema()` (instance method) |\n| Zod 3 | ❌ | no native exporter; pass JSON Schema explicitly (see below), or use `zod-to-json-schema` |\n| Valibot | ❌ | install `@valibot/to-json-schema` and pass the result explicitly |\n| Effect Schema | ❌ | call `JSONSchema.make(schema)` from `@effect/schema/JSONSchema` and pass the result explicitly |\n\nIf auto-derivation throws (e.g. the validator hits an unsupported feature), the SDK silently falls back - no exception escapes into your action wiring.\n\n### 2. Pass it manually\n\nAlways wins over auto-derivation. Pass the JSON Schema as the second argument:\n\n```ts\n.input(\n myValidator,\n {\n type: 'object',\n properties: { query: { type: 'string' }, limit: { type: 'integer', default: 10 } },\n required: ['query'],\n },\n)\n```\n\nFor Valibot, Effect Schema, or Zod 3, this is the path you'll typically take. Run your validator's converter once and pass the result.\n\n### 3. Fallback\n\nIf both paths above produce nothing, the SDK sends `{ type: 'object', additionalProperties: true }` - permissive, unhelpful to the agent, but the call still works. Avoid this where you can: agents on a permissive schema sometimes JSON-encode numbers as strings (because they have no type signal), and the call then fails Zod runtime validation.\n\n## Zod idioms that help the agent\n\n- Use `.describe()` on fields. The text shows up in the generated JSON Schema as `description`, which the agent reads when deciding what to pass.\n- Prefer `z.enum(['a', 'b'])` over `z.string()` when there's a finite set - gives the agent the choices up front.\n- Provide defaults for optional-looking fields: `z.number().int().default(10)`.\n- Avoid deeply nested structures. Flatten where possible.\n\n```ts\n.input(z.object({\n query: z.string().describe('Full-text search query; empty string matches all.'),\n limit: z.number().int().min(1).max(100).default(20).describe('Max results to return.'),\n sort: z.enum(['relevance', 'date', 'price']).default('relevance'),\n}))\n```\n\n## Mixing validators\n\nYou can use different validators across actions in the same app. Use Zod for one, Valibot for another - the SDK doesn't care. Consistency inside a project is mostly a tooling preference, not a correctness requirement."},{"slug":"sdk/typescript/svelte","title":"@tesseron/svelte","description":"Svelte adapter. Lifecycle-scoped action and resource registration, reactive connection store.","section":"sdk","related":["sdk/typescript/web","sdk/typescript/vite","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/svelte` wraps `@tesseron/web` with Svelte lifecycle plumbing: actions and resources register on component mount, deregister on destroy; the connection status is a `Readable` store you subscribe to with `$connection` in templates.\n\nWorks with Svelte 4 and Svelte 5. Uses `onMount` / `onDestroy` / `writable` - no rune syntax, so the package ships as normal JS and doesn't need the Svelte compiler to build.\n\n## Install\n\n```bash\npnpm add @tesseron/svelte zod\npnpm add -D @tesseron/vite\n```\n\nThen register the [Vite plugin](/sdk/typescript/vite/) in your `vite.config.ts`.\n\n## API\n\nThree exports. The full `@tesseron/web` surface is re-exported too.\n\n```ts\nimport {\n tesseronAction,\n tesseronResource,\n tesseronConnection,\n} from '@tesseron/svelte';\n```\n\n### `tesseronAction(name, options)`\n\nRegisters an action for the lifetime of the component. Same shape as the builder API, passed as an object:\n\n```svelte\n<script lang=\"ts\">\n import { tesseronAction } from '@tesseron/svelte';\n import { z } from 'zod';\n\n let todos = $state<string[]>([]);\n\n tesseronAction('addTodo', {\n input: z.object({ text: z.string() }),\n handler: ({ text }) => {\n todos = [...todos, text];\n },\n });\n</script>\n```\n\nOptions: `description`, `input`, `inputJsonSchema`, `output`, `outputJsonSchema`, `annotations`, `timeoutMs`, `strictOutput`, `handler`. The handler closes over `$state` / `$derived` variables and reads the current value at invocation time - no `$bindable` required.\n\n### `tesseronResource(name, optionsOrReader)`\n\nRegisters a resource. Pass a reader function for the shorthand, or an options object if you also want `subscribe`, `description`, or an output schema:\n\n```svelte\n<script lang=\"ts\">\n import { tesseronResource } from '@tesseron/svelte';\n\n let todos = $state<Todo[]>([]);\n\n // Read-only\n tesseronResource('todoCount', () => todos.length);\n\n // Read + subscribe\n const subs = new Set<(n: number) => void>();\n $effect(() => { const n = todos.length; subs.forEach(fn => fn(n)); });\n\n tesseronResource('todoCount', {\n read: () => todos.length,\n subscribe: (emit) => { subs.add(emit); return () => subs.delete(emit); },\n });\n</script>\n```\n\n### `tesseronConnection(options?)`\n\nOpens the connection on mount and returns a `Readable<TesseronConnectionState>`:\n\n```svelte\n<script lang=\"ts\">\n import { tesseron, tesseronConnection } from '@tesseron/svelte';\n\n tesseron.app({ id: 'my_app', name: 'My App' });\n // ...tesseronAction / tesseronResource calls register before the connection...\n const connection = tesseronConnection();\n</script>\n\n{#if $connection.claimCode}\n <p>Claim code: <code>{$connection.claimCode}</code></p>\n{/if}\n```\n\n`$connection.claimCode` clears reactively when the agent claims the session — the store subscribes to `client.onWelcomeChange` and patches the state on `tesseron/claimed`, so a template that branches on `claimCode` hides automatically without any extra logic.\n\n`TesseronConnectionState`:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n resumeStatus?: 'none' | 'resumed' | 'failed';\n}\n```\n\nOptions:\n\n```ts\ninterface TesseronConnectionOptions {\n url?: string; // gateway URL; defaults to /@tesseron/ws\n enabled?: boolean; // false → skip connecting (e.g. behind an auth gate)\n resume?: boolean | string | ResumeStorage; // default true\n}\n```\n\n#### `resume` — survive page refresh / HMR\n\n`resume` defaults to `true` — the store persists `{ sessionId, resumeToken }` to `localStorage` under `'tesseron:resume'` and replays it on the next mount via `tesseron/resume`. Refresh inside the [host idle TTL window](/sdk/typescript/vite/#sessions-span-browser-refreshes) (default 4 hours) keeps the same Tesseron session paired with the agent — no claim code re-entry needed.\n\n| Form | Behaviour |\n|---|---|\n| `true` *(default)* | Persist in `localStorage` under `'tesseron:resume'`. |\n| `false` | No persistence. Every connect is a fresh hello. |\n| `string` | Persist in `localStorage` under that exact key. |\n| `ResumeStorage` | Custom `{ load, save, clear }` callbacks (sync or async). |\n\n`$connection.resumeStatus` (set when `status === 'open'`) reports `'resumed'` after a successful resume, `'failed'` after a rejected resume + fallback to fresh hello, or `'none'` otherwise. See [Session resume](/protocol/resume/) for the protocol-level semantics.\n\n## Why an adapter at all\n\n`@tesseron/web` by itself works fine in Svelte; you can call `tesseron.action(...)` and `tesseron.connect()` at module scope. The adapter is a convenience when you want:\n\n- **Lifecycle scoping** - actions registered in a `+page.svelte` get torn down when the user navigates away.\n- **Reactive connection status** - `$connection.status` in templates without manual store plumbing.\n- **Latest-value closures** - the handler always sees the current `$state` without re-registration.\n\nIf none of that matters, stick with `@tesseron/web`.\n","bodyText":"`@tesseron/svelte` wraps `@tesseron/web` with Svelte lifecycle plumbing: actions and resources register on component mount, deregister on destroy; the connection status is a `Readable` store you subscribe to with `$connection` in templates.\n\nWorks with Svelte 4 and Svelte 5. Uses `onMount` / `onDestroy` / `writable` - no rune syntax, so the package ships as normal JS and doesn't need the Svelte compiler to build.\n\n## Install\n\n```bash\npnpm add @tesseron/svelte zod\npnpm add -D @tesseron/vite\n```\n\nThen register the [Vite plugin](/sdk/typescript/vite/) in your `vite.config.ts`.\n\n## API\n\nThree exports. The full `@tesseron/web` surface is re-exported too.\n\n```ts\n\n tesseronAction,\n tesseronResource,\n tesseronConnection,\n} from '@tesseron/svelte';\n```\n\n### `tesseronAction(name, options)`\n\nRegisters an action for the lifetime of the component. Same shape as the builder API, passed as an object:\n\n```svelte\n<script lang=\"ts\">\n import { tesseronAction } from '@tesseron/svelte';\n import { z } from 'zod';\n\n let todos = $state<string[]>([]);\n\n tesseronAction('addTodo', {\n input: z.object({ text: z.string() }),\n handler: ({ text }) => {\n todos = [...todos, text];\n },\n });\n</script>\n```\n\nOptions: `description`, `input`, `inputJsonSchema`, `output`, `outputJsonSchema`, `annotations`, `timeoutMs`, `strictOutput`, `handler`. The handler closes over `$state` / `$derived` variables and reads the current value at invocation time - no `$bindable` required.\n\n### `tesseronResource(name, optionsOrReader)`\n\nRegisters a resource. Pass a reader function for the shorthand, or an options object if you also want `subscribe`, `description`, or an output schema:\n\n```svelte\n<script lang=\"ts\">\n import { tesseronResource } from '@tesseron/svelte';\n\n let todos = $state<Todo[]>([]);\n\n // Read-only\n tesseronResource('todoCount', () => todos.length);\n\n // Read + subscribe\n const subs = new Set<(n: number) => void>();\n $effect(() => { const n = todos.length; subs.forEach(fn => fn(n)); });\n\n tesseronResource('todoCount', {\n read: () => todos.length,\n subscribe: (emit) => { subs.add(emit); return () => subs.delete(emit); },\n });\n</script>\n```\n\n### `tesseronConnection(options?)`\n\nOpens the connection on mount and returns a `Readable<TesseronConnectionState>`:\n\n```svelte\n<script lang=\"ts\">\n import { tesseron, tesseronConnection } from '@tesseron/svelte';\n\n tesseron.app({ id: 'my_app', name: 'My App' });\n // ...tesseronAction / tesseronResource calls register before the connection...\n const connection = tesseronConnection();\n</script>\n\n{#if $connection.claimCode}\n <p>Claim code: <code>{$connection.claimCode}</code></p>\n{/if}\n```\n\n`$connection.claimCode` clears reactively when the agent claims the session — the store subscribes to `client.onWelcomeChange` and patches the state on `tesseron/claimed`, so a template that branches on `claimCode` hides automatically without any extra logic.\n\n`TesseronConnectionState`:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n resumeStatus?: 'none' | 'resumed' | 'failed';\n}\n```\n\nOptions:\n\n```ts\ninterface TesseronConnectionOptions {\n url?: string; // gateway URL; defaults to /@tesseron/ws\n enabled?: boolean; // false → skip connecting (e.g. behind an auth gate)\n resume?: boolean | string | ResumeStorage; // default true\n}\n```\n\n#### `resume` — survive page refresh / HMR\n\n`resume` defaults to `true` — the store persists `{ sessionId, resumeToken }` to `localStorage` under `'tesseron:resume'` and replays it on the next mount via `tesseron/resume`. Refresh inside the [host idle TTL window](/sdk/typescript/vite/#sessions-span-browser-refreshes) (default 4 hours) keeps the same Tesseron session paired with the agent — no claim code re-entry needed.\n\n| Form | Behaviour |\n|---|---|\n| `true` *(default)* | Persist in `localStorage` under `'tesseron:resume'`. |\n| `false` | No persistence. Every connect is a fresh hello. |\n| `string` | Persist in `localStorage` under that exact key. |\n| `ResumeStorage` | Custom `{ load, save, clear }` callbacks (sync or async). |\n\n`$connection.resumeStatus` (set when `status === 'open'`) reports `'resumed'` after a successful resume, `'failed'` after a rejected resume + fallback to fresh hello, or `'none'` otherwise. See [Session resume](/protocol/resume/) for the protocol-level semantics.\n\n## Why an adapter at all\n\n`@tesseron/web` by itself works fine in Svelte; you can call `tesseron.action(...)` and `tesseron.connect()` at module scope. The adapter is a convenience when you want:\n\n- **Lifecycle scoping** - actions registered in a `+page.svelte` get torn down when the user navigates away.\n- **Reactive connection status** - `$connection.status` in templates without manual store plumbing.\n- **Latest-value closures** - the handler always sees the current `$state` without re-registration.\n\nIf none of that matters, stick with `@tesseron/web`."},{"slug":"sdk/typescript/vite","title":"@tesseron/vite","description":"Vite plugin that exposes `/@tesseron/ws` on your dev server and bridges browser tabs to the Tesseron gateway.","section":"sdk","related":["sdk/typescript/web","protocol/transport","overview/architecture"],"bodyRaw":"\n`@tesseron/vite` is the bridge that lets `@tesseron/web` (and `@tesseron/react`, `@tesseron/svelte`, `@tesseron/vue`) connect without a separate port.\n\n## Why it exists\n\nBrowsers can't bind TCP ports. The gateway needs a WebSocket endpoint to dial. The Vite dev server is already listening on a port - the plugin piggybacks on it.\n\nWhen a browser tab opens your dev URL, it dials `/@tesseron/ws` on the same origin. The plugin:\n\n1. Accepts the browser connection (no subprotocol).\n2. Waits for the first JSON-RPC frame:\n - `tesseron/hello` → creates a new **Session**: mints `claimCode`/`sessionId`/`resumeToken`, writes `~/.tesseron/instances/<instanceId>.json` (a v2 manifest with `helloHandledByHost: true` + `hostMintedClaim`), synthesizes the welcome locally so the SDK sees the claim code instantly.\n - `tesseron/resume` → looks the sessionId up in the in-memory Session map; on a token match, re-attaches the new browser WS to the existing Session and synthesizes the resume response (rotated token, no claim code). On a miss, returns `ResumeFailed` so the SDK falls back to a fresh hello.\n3. Waits for the gateway to dial the per-tab URL with the `tesseron-gateway` + `tesseron-bind.<code>` subprotocols. On bind, replays the cached hello to the gateway and bridges frames in both directions, buffering browser → gateway traffic if the browser starts talking before the gateway dials in. Text frames stay text, binary frames stay binary — the bridge preserves the frame type so the browser SDK isn't fed binary blobs that it would silently drop.\n\n### Sessions span browser refreshes\n\nA **Session** is keyed by `sessionId`, not by browser WebSocket. The browser WS can detach (refresh, tab close, network blip) and reattach via `tesseron/resume` without disturbing the gateway-side bridge — the agent keeps the same `sessionId` and stays paired without the user retyping the claim code. The plugin keeps the Session in memory across the detach window; if no resume arrives within `sessionIdleTtlMs` (default 4 hours), the Session is destroyed and the gateway-side WS closes.\n\nOne tab → one Session → one manifest → one gateway connection. Multiple tabs coexist cleanly, each with its own Session.\n\n## Install\n\n```bash\npnpm add -D @tesseron/vite\n```\n\nPeer: `vite >= 4`. No runtime dependencies on your framework plugin.\n\n## Register\n\n```ts title=\"vite.config.ts\"\nimport { defineConfig } from 'vite';\nimport { tesseron } from '@tesseron/vite';\n\nexport default defineConfig({\n plugins: [\n // ...your framework plugin (vue(), svelte(), react(), etc.)\n tesseron(),\n ],\n});\n```\n\nWith your framework plugin:\n\n```ts title=\"vite.config.ts (Vue)\"\nimport { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport { tesseron } from '@tesseron/vite';\n\nexport default defineConfig({\n plugins: [vue(), tesseron({ appName: 'vue-todo' })],\n});\n```\n\n## Options\n\n```ts\ntesseron({\n appName: 'my-app', // Optional. Written into the instance manifest so the\n // gateway log names your app usefully. Defaults to the\n // Vite project directory name.\n sessionIdleTtlMs: 4 * 60 * 60 * 1000,\n // Optional. How long a Session is held in memory after\n // its browser WS detaches (refresh, tab close). A new\n // browser WS arriving within this window with a valid\n // `tesseron/resume` re-attaches to the same Session\n // and the gateway-side bridge sees no disconnect.\n // Default 4 h, matching @tesseron/mcp's resumeTtlMs.\n // Set 0 to tear down sessions immediately on browser\n // close (disables cross-refresh resume).\n});\n```\n\nThat's the whole API surface — ports, paths, and subprotocols are wire-level details.\n\n## How the browser reaches it\n\nThe client-side `@tesseron/web` defaults to `<location.origin>/@tesseron/ws`, so no URL config is needed in your app code:\n\n```ts\nimport { tesseron } from '@tesseron/web';\ntesseron.app({ id: 'shop', name: 'Shop' });\n// ...declare actions...\nawait tesseron.connect(); // dials ws://localhost:5173/@tesseron/ws\n```\n\nIf your Vite server runs on a non-default port (e.g. `5175`), `location.origin` already reflects that - the connection still lands on the plugin.\n\n## Multiple tabs\n\nEach browser tab gets its own `instanceId`, its own manifest, and its own gateway connection. Session claiming is per-tab - open three tabs of the same app and you get three claim codes, each independent.\n\n## Production builds\n\nThe plugin only runs under `vite dev`. Production builds (`vite build`) don't serve WebSocket endpoints, so a static `dist/` deployed to a CDN won't have `/@tesseron/ws` available.\n\nFor production Tesseron use with a browser SPA, you need a host process. Options:\n\n- **Electron / Tauri** - the native shell can run `@tesseron/server` in its main process and route `/@tesseron/ws` requests to it from the renderer.\n- **A custom reverse proxy in front of your SPA** that terminates `/@tesseron/ws` and bridges to a Node process running `@tesseron/server`.\n- **A separate Node service** that uses `@tesseron/server` if your prod topology already has one.\n\nThe Vite plugin is strictly for dev-time workflows.\n\n## What it doesn't do\n\n- **Not a framework adapter.** You still import from `@tesseron/web` / `@tesseron/react` / `@tesseron/svelte` / `@tesseron/vue` for the declarative API.\n- **Not a bundler plugin.** It only runs `configureServer`; no build-time transforms.\n- **Not a production tool.** See above.\n\n## Writing your own bridge\n\nIf you use a dev server other than Vite (webpack-dev-server, Rsbuild, Next.js dev, a custom Express-based HMR setup), the same pattern works:\n\n1. On WebSocket upgrade at `/@tesseron/ws` — accept the browser. Defer minting until you see the first JSON-RPC frame.\n2. On `tesseron/hello`, allocate a Session (mint `claimCode`, `sessionId`, `resumeToken`), write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, helloHandledByHost: true, hostMintedClaim: {...}, transport: { kind: 'ws', url } }` where `url` points at a tab-specific path like `/@tesseron/ws/<instanceId>`. Synthesize the welcome locally so the SDK sees the claim code immediately.\n3. On `tesseron/resume`, look up the sessionId in your in-memory Session map. On a token match, attach the new browser WS to the existing Session and synthesize the resume response (rotated token, no claim code); on a miss, return `ResumeFailed`.\n4. On WebSocket upgrade at the per-tab path with subprotocols `tesseron-gateway` + `tesseron-bind.<code>` — accept the gateway, validate the bind code in constant time, replay the cached hello, and relay frames between the two sockets. Preserve text/binary frame types.\n5. On browser-WS close, keep the Session alive for the idle TTL; on idle-TTL expiry or gateway-WS close, destroy the Session and delete the manifest.\n\n`@tesseron/vite`'s source is the reference; adapt it to whatever dev server you run.\n","bodyText":"`@tesseron/vite` is the bridge that lets `@tesseron/web` (and `@tesseron/react`, `@tesseron/svelte`, `@tesseron/vue`) connect without a separate port.\n\n## Why it exists\n\nBrowsers can't bind TCP ports. The gateway needs a WebSocket endpoint to dial. The Vite dev server is already listening on a port - the plugin piggybacks on it.\n\nWhen a browser tab opens your dev URL, it dials `/@tesseron/ws` on the same origin. The plugin:\n\n1. Accepts the browser connection (no subprotocol).\n2. Waits for the first JSON-RPC frame:\n - `tesseron/hello` → creates a new **Session**: mints `claimCode`/`sessionId`/`resumeToken`, writes `~/.tesseron/instances/<instanceId>.json` (a v2 manifest with `helloHandledByHost: true` + `hostMintedClaim`), synthesizes the welcome locally so the SDK sees the claim code instantly.\n - `tesseron/resume` → looks the sessionId up in the in-memory Session map; on a token match, re-attaches the new browser WS to the existing Session and synthesizes the resume response (rotated token, no claim code). On a miss, returns `ResumeFailed` so the SDK falls back to a fresh hello.\n3. Waits for the gateway to dial the per-tab URL with the `tesseron-gateway` + `tesseron-bind.<code>` subprotocols. On bind, replays the cached hello to the gateway and bridges frames in both directions, buffering browser → gateway traffic if the browser starts talking before the gateway dials in. Text frames stay text, binary frames stay binary — the bridge preserves the frame type so the browser SDK isn't fed binary blobs that it would silently drop.\n\n### Sessions span browser refreshes\n\nA **Session** is keyed by `sessionId`, not by browser WebSocket. The browser WS can detach (refresh, tab close, network blip) and reattach via `tesseron/resume` without disturbing the gateway-side bridge — the agent keeps the same `sessionId` and stays paired without the user retyping the claim code. The plugin keeps the Session in memory across the detach window; if no resume arrives within `sessionIdleTtlMs` (default 4 hours), the Session is destroyed and the gateway-side WS closes.\n\nOne tab → one Session → one manifest → one gateway connection. Multiple tabs coexist cleanly, each with its own Session.\n\n## Install\n\n```bash\npnpm add -D @tesseron/vite\n```\n\nPeer: `vite >= 4`. No runtime dependencies on your framework plugin.\n\n## Register\n\n```ts title=\"vite.config.ts\"\n\nexport default defineConfig({\n plugins: [\n // ...your framework plugin (vue(), svelte(), react(), etc.)\n tesseron(),\n ],\n});\n```\n\nWith your framework plugin:\n\n```ts title=\"vite.config.ts (Vue)\"\n\nexport default defineConfig({\n plugins: [vue(), tesseron({ appName: 'vue-todo' })],\n});\n```\n\n## Options\n\n```ts\ntesseron({\n appName: 'my-app', // Optional. Written into the instance manifest so the\n // gateway log names your app usefully. Defaults to the\n // Vite project directory name.\n sessionIdleTtlMs: 4 * 60 * 60 * 1000,\n // Optional. How long a Session is held in memory after\n // its browser WS detaches (refresh, tab close). A new\n // browser WS arriving within this window with a valid\n // `tesseron/resume` re-attaches to the same Session\n // and the gateway-side bridge sees no disconnect.\n // Default 4 h, matching @tesseron/mcp's resumeTtlMs.\n // Set 0 to tear down sessions immediately on browser\n // close (disables cross-refresh resume).\n});\n```\n\nThat's the whole API surface — ports, paths, and subprotocols are wire-level details.\n\n## How the browser reaches it\n\nThe client-side `@tesseron/web` defaults to `<location.origin>/@tesseron/ws`, so no URL config is needed in your app code:\n\n```ts\n\ntesseron.app({ id: 'shop', name: 'Shop' });\n// ...declare actions...\nawait tesseron.connect(); // dials ws://localhost:5173/@tesseron/ws\n```\n\nIf your Vite server runs on a non-default port (e.g. `5175`), `location.origin` already reflects that - the connection still lands on the plugin.\n\n## Multiple tabs\n\nEach browser tab gets its own `instanceId`, its own manifest, and its own gateway connection. Session claiming is per-tab - open three tabs of the same app and you get three claim codes, each independent.\n\n## Production builds\n\nThe plugin only runs under `vite dev`. Production builds (`vite build`) don't serve WebSocket endpoints, so a static `dist/` deployed to a CDN won't have `/@tesseron/ws` available.\n\nFor production Tesseron use with a browser SPA, you need a host process. Options:\n\n- **Electron / Tauri** - the native shell can run `@tesseron/server` in its main process and route `/@tesseron/ws` requests to it from the renderer.\n- **A custom reverse proxy in front of your SPA** that terminates `/@tesseron/ws` and bridges to a Node process running `@tesseron/server`.\n- **A separate Node service** that uses `@tesseron/server` if your prod topology already has one.\n\nThe Vite plugin is strictly for dev-time workflows.\n\n## What it doesn't do\n\n- **Not a framework adapter.** You still import from `@tesseron/web` / `@tesseron/react` / `@tesseron/svelte` / `@tesseron/vue` for the declarative API.\n- **Not a bundler plugin.** It only runs `configureServer`; no build-time transforms.\n- **Not a production tool.** See above.\n\n## Writing your own bridge\n\nIf you use a dev server other than Vite (webpack-dev-server, Rsbuild, Next.js dev, a custom Express-based HMR setup), the same pattern works:\n\n1. On WebSocket upgrade at `/@tesseron/ws` — accept the browser. Defer minting until you see the first JSON-RPC frame.\n2. On `tesseron/hello`, allocate a Session (mint `claimCode`, `sessionId`, `resumeToken`), write `~/.tesseron/instances/<instanceId>.json` with `{ version: 2, instanceId, appName, addedAt, helloHandledByHost: true, hostMintedClaim: {...}, transport: { kind: 'ws', url } }` where `url` points at a tab-specific path like `/@tesseron/ws/<instanceId>`. Synthesize the welcome locally so the SDK sees the claim code immediately.\n3. On `tesseron/resume`, look up the sessionId in your in-memory Session map. On a token match, attach the new browser WS to the existing Session and synthesize the resume response (rotated token, no claim code); on a miss, return `ResumeFailed`.\n4. On WebSocket upgrade at the per-tab path with subprotocols `tesseron-gateway` + `tesseron-bind.<code>` — accept the gateway, validate the bind code in constant time, replay the cached hello, and relay frames between the two sockets. Preserve text/binary frame types.\n5. On browser-WS close, keep the Session alive for the idle TTL; on idle-TTL expiry or gateway-WS close, destroy the Session and delete the manifest.\n\n`@tesseron/vite`'s source is the reference; adapt it to whatever dev server you run."},{"slug":"sdk/typescript/vue","title":"@tesseron/vue","description":"Vue 3 adapter. Composition-API bindings for actions, resources, and the connection state ref.","section":"sdk","related":["sdk/typescript/web","sdk/typescript/vite","sdk/typescript/action-builder"],"bodyRaw":"\n`@tesseron/vue` wraps `@tesseron/web` with Vue 3 Composition API lifecycle plumbing: actions and resources register on `onMounted`, deregister on `onUnmounted`; the connection status is a `Ref` that auto-unwraps in templates.\n\nVue 3.0+, Composition API. Script-setup or `setup()` return - either works.\n\n## Install\n\n```bash\npnpm add @tesseron/vue zod\npnpm add -D @tesseron/vite\n```\n\nThen register the [Vite plugin](/sdk/typescript/vite/) in your `vite.config.ts`.\n\n## API\n\nThree exports. The full `@tesseron/web` surface is re-exported too.\n\n```ts\nimport {\n tesseronAction,\n tesseronResource,\n tesseronConnection,\n} from '@tesseron/vue';\n```\n\n### `tesseronAction(name, options)`\n\nRegisters an action for the lifetime of the component. Same shape as the builder API, passed as an object:\n\n```vue\n<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { tesseronAction } from '@tesseron/vue';\nimport { z } from 'zod';\n\nconst todos = ref<string[]>([]);\n\ntesseronAction('addTodo', {\n input: z.object({ text: z.string() }),\n handler: ({ text }) => {\n todos.value = [...todos.value, text];\n },\n});\n</script>\n```\n\nOptions: `description`, `input`, `inputJsonSchema`, `output`, `outputJsonSchema`, `annotations`, `timeoutMs`, `strictOutput`, `handler`. The handler closes over `ref` / `computed` values and reads the current value at invocation time.\n\n### `tesseronResource(name, optionsOrReader)`\n\nRegisters a resource. Pass a reader function for the shorthand, or an options object if you also want `subscribe`, `description`, or an output schema:\n\n```vue\n<script setup lang=\"ts\">\nimport { ref, watch } from 'vue';\nimport { tesseronResource } from '@tesseron/vue';\n\nconst todos = ref<Todo[]>([]);\n\n// Read-only\ntesseronResource('todoCount', () => todos.value.length);\n\n// Read + subscribe\nconst subs = new Set<(n: number) => void>();\nwatch(() => todos.value.length, (n) => subs.forEach(fn => fn(n)));\n\ntesseronResource('todoCount', {\n read: () => todos.value.length,\n subscribe: (emit) => { subs.add(emit); return () => subs.delete(emit); },\n});\n</script>\n```\n\n### `tesseronConnection(options?)`\n\nOpens the connection on mount and returns a `Ref<TesseronConnectionState>`:\n\n```vue\n<script setup lang=\"ts\">\nimport { tesseron, tesseronConnection } from '@tesseron/vue';\n\ntesseron.app({ id: 'my_app', name: 'My App' });\n// ...tesseronAction / tesseronResource calls register before the connection...\nconst connection = tesseronConnection();\n</script>\n\n<template>\n <p v-if=\"connection.claimCode\">\n Claim code: <code>{{ connection.claimCode }}</code>\n </p>\n</template>\n```\n\nTemplates auto-unwrap refs, so `connection.status` works directly. Outside templates use `connection.value.status`.\n\n`connection.claimCode` clears reactively when the agent claims the session — the composable subscribes to `client.onWelcomeChange` and patches the ref on `tesseron/claimed`, so a `v-if` on `claimCode` hides automatically.\n\n`TesseronConnectionState`:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n resumeStatus?: 'none' | 'resumed' | 'failed';\n}\n```\n\nOptions:\n\n```ts\ninterface TesseronConnectionOptions {\n url?: string; // gateway URL; defaults to /@tesseron/ws\n enabled?: boolean; // false → skip connecting (e.g. behind an auth gate)\n resume?: boolean | string | ResumeStorage; // default true\n}\n```\n\n#### `resume` — survive page refresh / HMR\n\n`resume` defaults to `true` — the composable persists `{ sessionId, resumeToken }` to `localStorage` under `'tesseron:resume'` and replays it on the next mount via `tesseron/resume`. Refresh inside the [host idle TTL window](/sdk/typescript/vite/#sessions-span-browser-refreshes) (default 4 hours) keeps the same Tesseron session paired with the agent — no claim code re-entry needed.\n\n| Form | Behaviour |\n|---|---|\n| `true` *(default)* | Persist in `localStorage` under `'tesseron:resume'`. |\n| `false` | No persistence. Every connect is a fresh hello. |\n| `string` | Persist in `localStorage` under that exact key. |\n| `ResumeStorage` | Custom `{ load, save, clear }` callbacks (sync or async). |\n\n`connection.resumeStatus` (set when `status === 'open'`) reports `'resumed'` after a successful resume, `'failed'` after a rejected resume + fallback to fresh hello, or `'none'` otherwise. See [Session resume](/protocol/resume/) for the protocol-level semantics.\n\n## Why an adapter at all\n\n`@tesseron/web` by itself works fine in Vue; you can call `tesseron.action(...)` and `tesseron.connect()` at module scope. The adapter is a convenience when you want:\n\n- **Lifecycle scoping** - actions registered in a `<script setup>` get torn down when the component unmounts.\n- **Reactive connection status** - `connection.status` in templates without manual `ref` plumbing.\n- **Latest-value closures** - the handler always sees the current `ref.value` without re-registration.\n\nIf none of that matters, stick with `@tesseron/web`.\n","bodyText":"`@tesseron/vue` wraps `@tesseron/web` with Vue 3 Composition API lifecycle plumbing: actions and resources register on `onMounted`, deregister on `onUnmounted`; the connection status is a `Ref` that auto-unwraps in templates.\n\nVue 3.0+, Composition API. Script-setup or `setup()` return - either works.\n\n## Install\n\n```bash\npnpm add @tesseron/vue zod\npnpm add -D @tesseron/vite\n```\n\nThen register the [Vite plugin](/sdk/typescript/vite/) in your `vite.config.ts`.\n\n## API\n\nThree exports. The full `@tesseron/web` surface is re-exported too.\n\n```ts\n\n tesseronAction,\n tesseronResource,\n tesseronConnection,\n} from '@tesseron/vue';\n```\n\n### `tesseronAction(name, options)`\n\nRegisters an action for the lifetime of the component. Same shape as the builder API, passed as an object:\n\n```vue\n<script setup lang=\"ts\">\n\nconst todos = ref<string[]>([]);\n\ntesseronAction('addTodo', {\n input: z.object({ text: z.string() }),\n handler: ({ text }) => {\n todos.value = [...todos.value, text];\n },\n});\n</script>\n```\n\nOptions: `description`, `input`, `inputJsonSchema`, `output`, `outputJsonSchema`, `annotations`, `timeoutMs`, `strictOutput`, `handler`. The handler closes over `ref` / `computed` values and reads the current value at invocation time.\n\n### `tesseronResource(name, optionsOrReader)`\n\nRegisters a resource. Pass a reader function for the shorthand, or an options object if you also want `subscribe`, `description`, or an output schema:\n\n```vue\n<script setup lang=\"ts\">\n\nconst todos = ref<Todo[]>([]);\n\n// Read-only\ntesseronResource('todoCount', () => todos.value.length);\n\n// Read + subscribe\nconst subs = new Set<(n: number) => void>();\nwatch(() => todos.value.length, (n) => subs.forEach(fn => fn(n)));\n\ntesseronResource('todoCount', {\n read: () => todos.value.length,\n subscribe: (emit) => { subs.add(emit); return () => subs.delete(emit); },\n});\n</script>\n```\n\n### `tesseronConnection(options?)`\n\nOpens the connection on mount and returns a `Ref<TesseronConnectionState>`:\n\n```vue\n<script setup lang=\"ts\">\n\ntesseron.app({ id: 'my_app', name: 'My App' });\n// ...tesseronAction / tesseronResource calls register before the connection...\nconst connection = tesseronConnection();\n</script>\n\n<template>\n <p v-if=\"connection.claimCode\">\n Claim code: <code>{{ connection.claimCode }}</code>\n </p>\n</template>\n```\n\nTemplates auto-unwrap refs, so `connection.status` works directly. Outside templates use `connection.value.status`.\n\n`connection.claimCode` clears reactively when the agent claims the session — the composable subscribes to `client.onWelcomeChange` and patches the ref on `tesseron/claimed`, so a `v-if` on `claimCode` hides automatically.\n\n`TesseronConnectionState`:\n\n```ts\ninterface TesseronConnectionState {\n status: 'idle' | 'connecting' | 'open' | 'error' | 'closed';\n welcome?: WelcomeResult;\n claimCode?: string;\n error?: Error;\n resumeStatus?: 'none' | 'resumed' | 'failed';\n}\n```\n\nOptions:\n\n```ts\ninterface TesseronConnectionOptions {\n url?: string; // gateway URL; defaults to /@tesseron/ws\n enabled?: boolean; // false → skip connecting (e.g. behind an auth gate)\n resume?: boolean | string | ResumeStorage; // default true\n}\n```\n\n#### `resume` — survive page refresh / HMR\n\n`resume` defaults to `true` — the composable persists `{ sessionId, resumeToken }` to `localStorage` under `'tesseron:resume'` and replays it on the next mount via `tesseron/resume`. Refresh inside the [host idle TTL window](/sdk/typescript/vite/#sessions-span-browser-refreshes) (default 4 hours) keeps the same Tesseron session paired with the agent — no claim code re-entry needed.\n\n| Form | Behaviour |\n|---|---|\n| `true` *(default)* | Persist in `localStorage` under `'tesseron:resume'`. |\n| `false` | No persistence. Every connect is a fresh hello. |\n| `string` | Persist in `localStorage` under that exact key. |\n| `ResumeStorage` | Custom `{ load, save, clear }` callbacks (sync or async). |\n\n`connection.resumeStatus` (set when `status === 'open'`) reports `'resumed'` after a successful resume, `'failed'` after a rejected resume + fallback to fresh hello, or `'none'` otherwise. See [Session resume](/protocol/resume/) for the protocol-level semantics.\n\n## Why an adapter at all\n\n`@tesseron/web` by itself works fine in Vue; you can call `tesseron.action(...)` and `tesseron.connect()` at module scope. The adapter is a convenience when you want:\n\n- **Lifecycle scoping** - actions registered in a `<script setup>` get torn down when the component unmounts.\n- **Reactive connection status** - `connection.status` in templates without manual `ref` plumbing.\n- **Latest-value closures** - the handler always sees the current `ref.value` without re-registration.\n\nIf none of that matters, stick with `@tesseron/web`."},{"slug":"sdk/typescript/web","title":"@tesseron/web","description":"The browser SDK. Singleton client, WebSocket transport, framework-agnostic.","section":"sdk","related":["sdk/typescript/core","protocol/transport","sdk/typescript/action-builder"],"bodyRaw":"\nThe package for anything running in a browser tab - vanilla TS, Vite, Next, Svelte, Vue. If you use React, the [@tesseron/react](/sdk/typescript/react/) adapter is the ergonomic wrapper on top of this.\n\n## Exports\n\n```ts\nimport {\n // Singleton client - pre-constructed, use directly.\n tesseron,\n // Class (if you need multiple clients, e.g. for multiple apps in one tab).\n WebTesseronClient,\n // WebSocket transport (WS client; dials the Vite plugin's bridge endpoint).\n BrowserWebSocketTransport,\n // Default endpoint — same-origin `/@tesseron/ws`, derived from `location.origin`.\n // Served by the `@tesseron/vite` plugin. In a dev browser this resolves to e.g.\n // `ws://localhost:5173/@tesseron/ws` when the page is served from Vite on :5173.\n DEFAULT_GATEWAY_URL,\n // Default localStorage key used for auto-persist resume credentials.\n DEFAULT_RESUME_STORAGE_KEY,\n // Persistence backend interface for custom resume storage.\n type ResumeStorage,\n // Extended ConnectOptions accepted by WebTesseronClient.connect.\n type WebConnectOptions,\n} from '@tesseron/web';\n\n// The full `@tesseron/core` surface is also re-exported.\n```\n\nBrowsers can't bind ports, so `@tesseron/web` is a WebSocket **client**. It dials the [`@tesseron/vite`](/sdk/typescript/vite/) plugin at the same origin; the plugin bridges the connection to the gateway that dialed in with the `tesseron-gateway` subprotocol.\n\n## Singleton usage\n\n```ts\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'shop', name: 'Shop' });\n\ntesseron.action('search')\n .input(z.object({ query: z.string() }))\n .handler(({ query }) => store.search(query));\n\nconst welcome = await tesseron.connect();\nconsole.log('claim code:', welcome.claimCode);\n```\n\n`tesseron.connect()` accepts:\n\n| Argument | Behaviour |\n|---|---|\n| `undefined` | Dials `<location.origin>/@tesseron/ws` - the endpoint exposed by the `@tesseron/vite` plugin. |\n| `string` (URL) | Dials that URL. |\n| `Transport` | Uses the supplied transport - mostly for tests. |\n\nBrowser apps need the [`@tesseron/vite`](/sdk/typescript/vite/) plugin in their `vite.config.ts` to serve `/@tesseron/ws`. Without it, `tesseron.connect()` will fail with a WebSocket error. If you use another dev server, pass a URL explicitly or build your own transport.\n\n### Auto-persist resume\n\nThe optional second argument is `WebConnectOptions`. Its `resume` field controls whether the SDK persists the session credentials across reloads:\n\n| `resume` value | Behaviour |\n|---|---|\n| omitted or `true` (default) | Persist `{ sessionId, resumeToken }` in `localStorage` under `tesseron:resume`. On the next `connect()` the SDK reads them, sends `tesseron/resume`, saves the rotated token. On `ResumeFailed` it clears storage and falls back to a fresh `tesseron/hello`. |\n| `false` | No persistence. Every connect is a fresh hello with a new claim code. |\n| `string` | Same as `true` but with this `localStorage` key. Useful when you run multiple Tesseron clients on one page. |\n| [`ResumeStorage`](#custom-resume-backend) | Custom backend - OS keychain, Electron store, IPC bridge, anything implementing the interface. |\n| [`ResumeCredentials`](/protocol/resume/) literal | Caller-managed creds. SDK uses them as-is and does **not** auto-persist. |\n\nThe default keeps casual refreshes from costing the user a fresh claim code — the most common reason resume was hand-wired in apps before. See [protocol/resume](/protocol/resume/) for the gateway-side TTL semantics (default 4 hours, configurable via `TESSERON_RESUME_TTL_MS`).\n\nTransport-form `tesseron.connect(customTransport, ...)` only accepts `ResumeCredentials` or `false` for `resume`; the storage-aware shapes require the URL form (the SDK constructs and owns the transport so it can retry the handshake on `ResumeFailed`).\n\n#### Custom resume backend\n\n```ts\nimport { tesseron, type ResumeStorage } from '@tesseron/web';\n\nconst keychain: ResumeStorage = {\n load: () => electronAPI.invoke('tesseron:load'),\n save: (creds) => electronAPI.invoke('tesseron:save', creds),\n clear: () => electronAPI.invoke('tesseron:clear'),\n};\n\nawait tesseron.connect(undefined, { resume: keychain });\n```\n\nThrows inside `load`/`save`/`clear` are non-fatal: the SDK treats a thrown `load()` as no saved creds, and thrown `save()` / `clear()` as silent best-effort. Storage misbehaviour can't fail-close the connection.\n\n### Re-entry safety\n\n`tesseron.connect()` is idempotent against re-entry. Two concurrent calls to the URL form with the same URL and the same `resume` credentials share a single in-flight promise (and a single WebSocket); the second caller does not open a parallel socket. This matters under React 18 StrictMode (mount → cleanup → remount), Vite HMR re-running module-scope `connect()`, and any flow that flips a connection-gating boolean rapidly. Without de-dup, the gateway would receive two `tesseron/resume` requests carrying the same single-shot token; the first would consume the zombie session and rotate, and the second would invariably fail with `ResumeFailed`. Connect-after-connect (a fresh call against an already-open transport) eagerly closes the prior socket, waits for its close handler to drain, and only then starts the new handshake — so dispatcher state never overlaps between the dying and the new transport.\n\nReturns `WelcomeResult`:\n\n```ts\ninterface WelcomeResult {\n sessionId: string;\n protocolVersion: string;\n capabilities: TesseronCapabilities; // { streaming, subscriptions, sampling, elicitation }\n agent: { id: string; name: string };\n claimCode?: string;\n}\n```\n\nThe SDK's own agreed-side capabilities (advertised in `tesseron/hello`) live in `SDK_CAPABILITIES`. The `welcome.capabilities` above describe what the *agent side* supports. Inside a handler the narrower `ctx.agentCapabilities` surface (`{ sampling, elicitation, subscriptions }`) is the one to branch on.\n\n## Multiple clients in one page\n\nThe singleton is convenient, but if you need two apps in one tab:\n\n```ts\nimport { WebTesseronClient } from '@tesseron/web';\n\nconst shop = new WebTesseronClient();\nshop.app({ id: 'shop', name: 'Shop' });\nshop.action('search').input(...).handler(...);\nawait shop.connect();\n\nconst admin = new WebTesseronClient();\nadmin.app({ id: 'admin', name: 'Admin' });\nadmin.action('ban').input(...).handler(...);\nawait admin.connect();\n```\n\nEach `WebTesseronClient` holds its own WebSocket to the MCP gateway. Two sessions, two claim codes. Tools don't collide because they're namespaced by `app.id`.\n\n## Custom transport\n\nThe built-in transport uses the browser's `WebSocket`. If you need something else (a service worker relaying to an extension, a shared worker, a BroadcastChannel for tests), pass a `Transport` directly:\n\n```ts\nconst custom: Transport = {\n send: (msg) => postMessage(msg),\n onMessage: (h) => addEventListener('message', (e) => h(e.data)),\n onClose: (h) => { /* ... */ },\n close: () => { /* ... */ },\n};\nawait tesseron.connect(custom);\n```\n\n## Frame handling quirks\n\n- The transport only handles string frames (`typeof ev.data === 'string'`). Non-string frames from the gateway are dropped - in practice the gateway always sends text, so this never fires.\n- Messages that fail `JSON.parse` are dropped silently.\n- The `open` event resolves `connect()`. If the WebSocket's `error` fires before `open`, `connect()` rejects with `WebSocket connection failed: <url>`.\n\n## Disconnect\n\n```ts\nawait tesseron.disconnect();\n```\n\nSends WebSocket close frame, rejects pending requests with `TransportClosedError`, aborts in-flight invocations. Safe to call multiple times.\n\n## Reconnect pattern\n\nThere is no built-in reconnect. Pattern:\n\n```ts\nasync function connectWithRetry(attempt = 0) {\n try {\n const welcome = await tesseron.connect();\n surfaceClaimCode(welcome.claimCode);\n } catch (err) {\n const delay = Math.min(30_000, 500 * 2 ** attempt);\n setTimeout(() => connectWithRetry(attempt + 1), delay);\n }\n}\nconnectWithRetry();\n```\n\nDon't reconnect automatically in a hot loop - if the gateway is down (plugin disabled), hammering the port wastes CPU. Back off, cap at ~30 s, surface the state to the user.\n","bodyText":"The package for anything running in a browser tab - vanilla TS, Vite, Next, Svelte, Vue. If you use React, the [@tesseron/react](/sdk/typescript/react/) adapter is the ergonomic wrapper on top of this.\n\n## Exports\n\n```ts\n\n // Singleton client - pre-constructed, use directly.\n tesseron,\n // Class (if you need multiple clients, e.g. for multiple apps in one tab).\n WebTesseronClient,\n // WebSocket transport (WS client; dials the Vite plugin's bridge endpoint).\n BrowserWebSocketTransport,\n // Default endpoint — same-origin `/@tesseron/ws`, derived from `location.origin`.\n // Served by the `@tesseron/vite` plugin. In a dev browser this resolves to e.g.\n // `ws://localhost:5173/@tesseron/ws` when the page is served from Vite on :5173.\n DEFAULT_GATEWAY_URL,\n // Default localStorage key used for auto-persist resume credentials.\n DEFAULT_RESUME_STORAGE_KEY,\n // Persistence backend interface for custom resume storage.\n type ResumeStorage,\n // Extended ConnectOptions accepted by WebTesseronClient.connect.\n type WebConnectOptions,\n} from '@tesseron/web';\n\n// The full `@tesseron/core` surface is also re-exported.\n```\n\nBrowsers can't bind ports, so `@tesseron/web` is a WebSocket **client**. It dials the [`@tesseron/vite`](/sdk/typescript/vite/) plugin at the same origin; the plugin bridges the connection to the gateway that dialed in with the `tesseron-gateway` subprotocol.\n\n## Singleton usage\n\n```ts\n\ntesseron.app({ id: 'shop', name: 'Shop' });\n\ntesseron.action('search')\n .input(z.object({ query: z.string() }))\n .handler(({ query }) => store.search(query));\n\nconst welcome = await tesseron.connect();\nconsole.log('claim code:', welcome.claimCode);\n```\n\n`tesseron.connect()` accepts:\n\n| Argument | Behaviour |\n|---|---|\n| `undefined` | Dials `<location.origin>/@tesseron/ws` - the endpoint exposed by the `@tesseron/vite` plugin. |\n| `string` (URL) | Dials that URL. |\n| `Transport` | Uses the supplied transport - mostly for tests. |\n\nBrowser apps need the [`@tesseron/vite`](/sdk/typescript/vite/) plugin in their `vite.config.ts` to serve `/@tesseron/ws`. Without it, `tesseron.connect()` will fail with a WebSocket error. If you use another dev server, pass a URL explicitly or build your own transport.\n\n### Auto-persist resume\n\nThe optional second argument is `WebConnectOptions`. Its `resume` field controls whether the SDK persists the session credentials across reloads:\n\n| `resume` value | Behaviour |\n|---|---|\n| omitted or `true` (default) | Persist `{ sessionId, resumeToken }` in `localStorage` under `tesseron:resume`. On the next `connect()` the SDK reads them, sends `tesseron/resume`, saves the rotated token. On `ResumeFailed` it clears storage and falls back to a fresh `tesseron/hello`. |\n| `false` | No persistence. Every connect is a fresh hello with a new claim code. |\n| `string` | Same as `true` but with this `localStorage` key. Useful when you run multiple Tesseron clients on one page. |\n| [`ResumeStorage`](#custom-resume-backend) | Custom backend - OS keychain, Electron store, IPC bridge, anything implementing the interface. |\n| [`ResumeCredentials`](/protocol/resume/) literal | Caller-managed creds. SDK uses them as-is and does **not** auto-persist. |\n\nThe default keeps casual refreshes from costing the user a fresh claim code — the most common reason resume was hand-wired in apps before. See [protocol/resume](/protocol/resume/) for the gateway-side TTL semantics (default 4 hours, configurable via `TESSERON_RESUME_TTL_MS`).\n\nTransport-form `tesseron.connect(customTransport, ...)` only accepts `ResumeCredentials` or `false` for `resume`; the storage-aware shapes require the URL form (the SDK constructs and owns the transport so it can retry the handshake on `ResumeFailed`).\n\n#### Custom resume backend\n\n```ts\n\nconst keychain: ResumeStorage = {\n load: () => electronAPI.invoke('tesseron:load'),\n save: (creds) => electronAPI.invoke('tesseron:save', creds),\n clear: () => electronAPI.invoke('tesseron:clear'),\n};\n\nawait tesseron.connect(undefined, { resume: keychain });\n```\n\nThrows inside `load`/`save`/`clear` are non-fatal: the SDK treats a thrown `load()` as no saved creds, and thrown `save()` / `clear()` as silent best-effort. Storage misbehaviour can't fail-close the connection.\n\n### Re-entry safety\n\n`tesseron.connect()` is idempotent against re-entry. Two concurrent calls to the URL form with the same URL and the same `resume` credentials share a single in-flight promise (and a single WebSocket); the second caller does not open a parallel socket. This matters under React 18 StrictMode (mount → cleanup → remount), Vite HMR re-running module-scope `connect()`, and any flow that flips a connection-gating boolean rapidly. Without de-dup, the gateway would receive two `tesseron/resume` requests carrying the same single-shot token; the first would consume the zombie session and rotate, and the second would invariably fail with `ResumeFailed`. Connect-after-connect (a fresh call against an already-open transport) eagerly closes the prior socket, waits for its close handler to drain, and only then starts the new handshake — so dispatcher state never overlaps between the dying and the new transport.\n\nReturns `WelcomeResult`:\n\n```ts\ninterface WelcomeResult {\n sessionId: string;\n protocolVersion: string;\n capabilities: TesseronCapabilities; // { streaming, subscriptions, sampling, elicitation }\n agent: { id: string; name: string };\n claimCode?: string;\n}\n```\n\nThe SDK's own agreed-side capabilities (advertised in `tesseron/hello`) live in `SDK_CAPABILITIES`. The `welcome.capabilities` above describe what the *agent side* supports. Inside a handler the narrower `ctx.agentCapabilities` surface (`{ sampling, elicitation, subscriptions }`) is the one to branch on.\n\n## Multiple clients in one page\n\nThe singleton is convenient, but if you need two apps in one tab:\n\n```ts\n\nconst shop = new WebTesseronClient();\nshop.app({ id: 'shop', name: 'Shop' });\nshop.action('search').input(...).handler(...);\nawait shop.connect();\n\nconst admin = new WebTesseronClient();\nadmin.app({ id: 'admin', name: 'Admin' });\nadmin.action('ban').input(...).handler(...);\nawait admin.connect();\n```\n\nEach `WebTesseronClient` holds its own WebSocket to the MCP gateway. Two sessions, two claim codes. Tools don't collide because they're namespaced by `app.id`.\n\n## Custom transport\n\nThe built-in transport uses the browser's `WebSocket`. If you need something else (a service worker relaying to an extension, a shared worker, a BroadcastChannel for tests), pass a `Transport` directly:\n\n```ts\nconst custom: Transport = {\n send: (msg) => postMessage(msg),\n onMessage: (h) => addEventListener('message', (e) => h(e.data)),\n onClose: (h) => { /* ... */ },\n close: () => { /* ... */ },\n};\nawait tesseron.connect(custom);\n```\n\n## Frame handling quirks\n\n- The transport only handles string frames (`typeof ev.data === 'string'`). Non-string frames from the gateway are dropped - in practice the gateway always sends text, so this never fires.\n- Messages that fail `JSON.parse` are dropped silently.\n- The `open` event resolves `connect()`. If the WebSocket's `error` fires before `open`, `connect()` rejects with `WebSocket connection failed: <url>`.\n\n## Disconnect\n\n```ts\nawait tesseron.disconnect();\n```\n\nSends WebSocket close frame, rejects pending requests with `TransportClosedError`, aborts in-flight invocations. Safe to call multiple times.\n\n## Reconnect pattern\n\nThere is no built-in reconnect. Pattern:\n\n```ts\nasync function connectWithRetry(attempt = 0) {\n try {\n const welcome = await tesseron.connect();\n surfaceClaimCode(welcome.claimCode);\n } catch (err) {\n const delay = Math.min(30_000, 500 * 2 ** attempt);\n setTimeout(() => connectWithRetry(attempt + 1), delay);\n }\n}\nconnectWithRetry();\n```\n\nDon't reconnect automatically in a hot loop - if the gateway is down (plugin disabled), hammering the port wastes CPU. Back off, cap at ~30 s, surface the state to the user."}]}