@tesseron/docs-mcp 2.10.3 → 2.10.5

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":"5f9a124","generatedAt":"2026-09-08T10:45:51.188Z","count":64,"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/Eigenwise/tesseron-typescript/tree/main/examples/express-prompts)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/express-prompts)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/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/Eigenwise/tesseron-typescript\ncd tesseron-typescript\npnpm install --frozen-lockfile\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/Eigenwise/tesseron-typescript/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/Eigenwise/tesseron-typescript\ncd tesseron-typescript\npnpm install --frozen-lockfile\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/Eigenwise/tesseron-typescript/tree/main/examples/node-prompts)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/node-prompts)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/react-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/react-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/svelte-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/svelte-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/vanilla-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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 three subscribable resources (`currentFilter`, `todoStats`, and `todos://all`, the full list) - 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/Eigenwise/tesseron-typescript/tree/main/examples/vanilla-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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 three subscribable resources (`currentFilter`, `todoStats`, and `todos://all`, the full list) - 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/Eigenwise/tesseron-typescript/tree/main/examples/vue-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/vue-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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":"An accessibility layer for AI agents. Expose typed app actions to MCP-compatible 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 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**The agent doesn't need to click a button - it needs to do the thing the button does.** You declare the typed actions your app already performs; Tesseron exposes them to any MCP agent as tools, and your real handler runs in your real process against your real state. It's a protocol, not just a TypeScript library, and not just for the web - the SDKs cover browser, Node, and desktop today, and anything that speaks WebSocket + JSON-RPC 2.0 can host actions in any language ([Python and Rust are on the roadmap](/sdk/porting/)).\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 use the Rust and Python SDKs. Port Tesseron to another language when you need one.\"\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":"**The agent doesn't need to click a button - it needs to do the thing the button does.** You declare the typed actions your app already performs; Tesseron exposes them to any MCP agent as tools, and your real handler runs in your real process against your real state. It's a protocol, not just a TypeScript library, and not just for the web - the SDKs cover browser, Node, and desktop today, and anything that speaks WebSocket + JSON-RPC 2.0 can host actions in any language ([Python and Rust are on the roadmap](/sdk/porting/)).\n\n## 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 eigenwise/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/what-you-can-build","title":"What you can build","description":"Worked scenarios for Tesseron - a live copilot, bulk operations, a desktop app, a headless service - written agnostic to your stack, with the bindings and languages covered at the end.","section":"overview","related":["overview/why","overview/quickstart","sdk/index"],"bodyRaw":"\nTesseron is an accessibility layer for AI agents: you declare the typed actions your app already performs, and any MCP-compatible agent can call them against your real, running state. This page is about what that lets you build.\n\nNone of these scenarios care what your app is built with. You declare an app, some actions, and a resource or two with one builder, then connect - the same three steps whether your app is a browser tab, a desktop app, or a background service with no UI at all. The examples show the `tesseron` builder directly; where you import it from - and the framework adapters and other-language bindings - is covered at the end, under [Bindings and languages](#bindings-and-languages). Every code block uses the real API.\n\n## A live copilot inside a complex editor\n\nYou are building an editor - say a video editor - and you want an agent that co-edits alongside the human, drafting a rough cut the human then fine-tunes by hand. You declare the editor's real operations once, and the agent calls them straight against your live app.\n\n**The problem.** Dragging clips onto a timeline, snapping them in order, nudging trim handles - that is exactly the fiddly, pixel-precise UI manipulation that browser automation gets wrong, slowly, one brittle round-trip at a time. But \"drop these 6 clips in this order, then a title card before clip 3\" is one structured intent. The agent does not need to drive your timeline widget, it needs to do the thing the widget does.\n\n**What you declare.** A bulk timeline action, a title-card insert, a long-running preview render, an irreversible export, and a subscribable resource for the current timeline so the agent reads structured state instead of scraping the UI.\n\n```ts\nimport { z } from \"zod\";\n\ntesseron.app({ id: \"video_editor\", name: \"Video Editor\" });\n\ntesseron.action(\"add_clips_to_timeline\")\n .describe(\"Insert multiple clips onto the timeline in one ordered batch\")\n .input(z.object({\n trackId: z.string(),\n clips: z.array(z.object({ assetId: z.string(), startMs: z.number() })),\n }))\n .handler((input) => {\n // one-shot bulk insert against your real editor store\n return editorStore.insertClips(input.trackId, input.clips);\n });\n\ntesseron.action(\"insert_title_card\")\n .describe(\"Insert a title card at a given position\")\n .input(z.object({ trackId: z.string(), atMs: z.number(), text: z.string() }))\n .handler((input) => editorStore.insertTitleCard(input));\n\ntesseron.action(\"render_preview\")\n .describe(\"Render a preview of the current timeline\")\n .input(z.object({ fromMs: z.number(), toMs: z.number() }))\n .annotate({ readOnly: true })\n .handler(async (input, ctx) => {\n // long-running: stream progress and forward cancellation\n return renderer.preview(input, {\n signal: ctx.signal,\n onProgress: (percent) =>\n ctx.progress({ message: \"rendering preview\", percent }),\n });\n });\n\ntesseron.action(\"export_video\")\n .describe(\"Export the final video to a file (expensive, irreversible)\")\n .input(z.object({ format: z.enum([\"mp4\", \"webm\"]), quality: z.enum([\"1080p\", \"4k\"]) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .timeout({ ms: 600000 })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({\n question: `Export the full timeline as ${input.quality} ${input.format}? This burns render minutes.`,\n });\n if (!ok) return { exported: false };\n return renderer.export(input, {\n signal: ctx.signal,\n onProgress: (percent) => ctx.progress({ message: \"exporting\", percent }),\n });\n });\n\ntesseron.resource(\"timeline\")\n .describe(\"The current timeline: tracks, clips, and ordering\")\n .read(() => editorStore.getTimeline())\n .subscribe((emit) => {\n emit(editorStore.getTimeline()); // initial value\n const off = editorStore.on(\"change\", () => emit(editorStore.getTimeline()));\n return () => off(); // cleanup the listener\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(\"Pair the agent with this code:\", welcome.claimCode);\n```\n\n**What the agent does.** When the human says \"build me a 30 second rough cut from the beach footage, clips in chronological order,\" the agent reads the `timeline` resource for available assets, then makes a single `video_editor__add_clips_to_timeline` call with all six clips ordered - no dragging, no per-clip round-trips. \"Put a title card that says Day One before the third clip\" is one `insert_title_card` call. When the human says \"let me see it,\" the agent calls `render_preview`, which streams `ctx.progress` so the human watches the percent climb and can cancel mid-render through `ctx.signal`. Then the human takes over - nudging a trim handle, sliding clip four left by 200ms - and the agent sees the edit because the `timeline` resource re-emits. Finally \"ship the 4K mp4\" triggers `export_video`, which gates on `ctx.confirm`; if the agent's host cannot prompt, `ctx.confirm` returns false and nothing irreversible runs.\n\n*Dragging clips is the UI manipulation automation fails at; \"insert these 6 clips in this order\" is one typed action, and your real handler runs against your real editor state.*\n\n## One-shot bulk operations across a data-heavy app\n\nAn internal admin panel or data dashboard already has buttons for refunds, tagging, and bans. You expose those same handlers to the agent so it can run them in bulk, across exactly what the operator is looking at.\n\n**The problem.** Every bulk job is N brittle UI round-trips: select a row, click refund, confirm the modal, wait, repeat for the next forty orders. The agent doesn't need to click those buttons - it needs to do the thing the buttons do, once, across the whole selection.\n\n**What you declare.** Register the same handlers your buttons already call, plus a resource that exposes the live table filter and row selection so the agent can act on the current view.\n\n```ts\nimport { z } from \"zod\";\n\ntesseron.app({ id: \"dashboard\", name: \"Admin Dashboard\" });\n\n// expose the live table filter + selection\ntesseron.resource(\"current_selection\")\n .describe(\"The operator's current table filter and selected row ids\")\n .read(() => store.getSelection()) // { filter, orderIds, customerIds, userIds }\n .subscribe((emit) => {\n const onChange = () => emit(store.getSelection());\n store.on(\"change\", onChange);\n return () => store.off(\"change\", onChange); // cleanup the listener\n });\n\ntesseron.action(\"refund_orders\")\n .describe(\"Refund every order in the given list\")\n .input(z.object({ orderIds: z.array(z.string()).min(1) }))\n .handler(async ({ orderIds }, ctx) => {\n let done = 0;\n for (const id of orderIds) {\n await refundOrder(id); // same code path the refund button calls\n ctx.progress({ message: `refunded ${id}`, percent: ++done / orderIds.length });\n }\n return { refunded: done };\n });\n\ntesseron.action(\"tag_customers\")\n .describe(\"Apply a tag to a list of customers\")\n .input(z.object({ customerIds: z.array(z.string()).min(1), tag: z.string() }))\n .handler(async ({ customerIds, tag }) => {\n await tagCustomers(customerIds, tag); // your real customer store\n return { tagged: customerIds.length };\n });\n\ntesseron.action(\"ban_users\")\n .describe(\"Permanently ban a list of users\")\n .input(z.object({ userIds: z.array(z.string()).min(1), reason: z.string() }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async ({ userIds, reason }, ctx) => {\n const ok = await ctx.confirm({\n question: `Ban ${userIds.length} users for \"${reason}\"? This is irreversible.`,\n });\n if (!ok) return { banned: 0 };\n await banUsers(userIds, reason); // same code path the ban button calls\n return { banned: userIds.length };\n });\n\nawait tesseron.connect();\n```\n\n`ban_users` is annotated destructive, and the `ctx.confirm` gate returns `false` on decline and on agents without elicitation - so the safe path needs no capability guard. The `current_selection` resource is subscribable, so the agent always acts on the operator's current view rather than a stale snapshot.\n\n**What the agent does.** An operator filters the table to last week's failed charges, selects the rows, and types \"refund the orders I currently have selected.\" The agent reads the `current_selection` resource, pulls the `orderIds`, and calls `dashboard__refund_orders` with the whole array - five clicks become one call, and `ctx.progress` streams each refund back into the chat. \"Tag everyone in this view as priority_support\" reads the same resource and fires `dashboard__tag_customers` once. \"Ban these three accounts for fraud\" triggers `dashboard__ban_users`, which calls `ctx.confirm` - the operator approves in the agent, the ban runs, and a decline returns `{ banned: 0 }`.\n\n*Your real handler runs against your real state - no separate MCP server, no backend duplication, just one-shot bulk where the UI made you click N times.*\n\n## A desktop or local-first app\n\nNot every app is a web page. A desktop tool - a markdown notes vault, a local-database GUI, an offline knowledge base - exposes actions exactly the same way. Tesseron runs inside your app's own process (for Electron or Tauri, the main process), mutates real state on disk, and pushes the result to your UI; no browser tab is involved anywhere.\n\n**The problem.** A desktop app has no public API and no URL an agent can hit - the only way in is the UI, so an agent would have to drive menus, dialogs, and a tree view it cannot see. \"Reorganize my 400 notes into folders by topic\" is hundreds of brittle clicks. The agent doesn't need to click the New Note button, it needs to do the thing that button does, against your real vault.\n\n**What you declare.** The same builder, running in the process that owns the files.\n\n```ts\nimport { z } from \"zod\";\n\ntesseron.app({\n id: \"notes_vault\",\n name: \"Notes Vault\",\n description: \"A local markdown notes vault.\",\n});\n\nconst folderSchema = z.object({ folder: z.string() });\n\ntesseron.action(\"create_note\")\n .describe(\"Create a markdown note in a folder.\")\n .input(z.object({ title: z.string(), body: z.string(), folder: z.string().optional() }))\n .handler(async (input, ctx) => {\n let folder = input.folder;\n if (!folder) {\n // no destination given - ask the human which folder\n const picked = await ctx.elicit({\n question: \"Which folder should this note go in?\",\n schema: folderSchema,\n jsonSchema: z.toJSONSchema(folderSchema),\n });\n if (picked === null) return { created: false };\n folder = picked.folder;\n }\n // writes a real .md file and notifies the UI\n const path = await vault.writeNote(folder, input.title, input.body);\n return { created: true, path };\n });\n\ntesseron.action(\"search_notes\")\n .describe(\"Full-text search across the vault.\")\n .input(z.object({ query: z.string() }))\n .annotate({ readOnly: true })\n .handler(async (input) => {\n // runs against your real local index\n return { hits: await vault.search(input.query) };\n });\n\ntesseron.action(\"organize_vault\")\n .describe(\"Move notes into topic folders in bulk.\")\n .input(z.object({ moves: z.array(z.object({ path: z.string(), folder: z.string() })) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({ question: `Move ${input.moves.length} notes?` });\n if (!ok) return { moved: 0 };\n for (let i = 0; i < input.moves.length; i++) {\n ctx.progress({ message: `moving ${input.moves[i].path}`, percent: (i / input.moves.length) * 100 });\n await vault.move(input.moves[i].path, input.moves[i].folder); // real fs move\n }\n return { moved: input.moves.length };\n });\n\ntesseron.resource(\"vault_tree\")\n .describe(\"The current folder and note tree.\")\n .read(() => vault.tree())\n .subscribe((emit) => {\n const onChange = () => emit(vault.tree());\n vault.on(\"change\", onChange);\n return () => vault.off(\"change\", onChange); // tear down the watcher\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(`Pair the agent with claim code: ${welcome.claimCode}`);\n```\n\n**What the agent does.** \"Jot down a note about today's standup\" calls `notes_vault__create_note` with no folder, so the handler runs `ctx.elicit` and a native dialog asks which folder - you pick \"Work\", the note is written, and your UI refreshes. \"Reorganize my vault by topic\" reads the `vault_tree` resource, then calls `notes_vault__organize_vault`; because it is annotated destructive, `ctx.confirm` gates the move (\"Move 412 notes?\") and `ctx.progress` streams each file as it lands. \"Find everything I wrote about Postgres\" hits the read-only `notes_vault__search_notes` and returns hits straight from your local index. One bulk call replaces hundreds of drag-and-drop round-trips.\n\n*The agent does the thing your buttons do - your real handler runs against your real vault, no browser in sight.*\n\n## A service or daemon with no UI\n\nSome apps have no UI at all - a deploy runner, a data-pipeline supervisor, an internal CLI. There is nothing to render; there are just typed handlers in a long-running process. Expose them and an agent can operate the service directly.\n\n**The problem.** To let an agent drive a service like this, you would normally stand up a parallel REST API just to feed an LLM - new routes, new auth, new serialization - and then watch it drift from the real internal functions it wraps. Tesseron skips that layer: you expose your existing typed handlers directly, and your real handler runs against your real state.\n\n**What you declare.**\n\n```ts\nimport { z } from \"zod\";\n\ntesseron.app({\n id: \"deploy_ops\",\n name: \"Deploy Ops Daemon\",\n description: \"Headless control plane for deploys and rollbacks\",\n});\n\ntesseron.action(\"trigger_deploy\")\n .describe(\"Build and roll out a service to an environment. Returns the result.\")\n .input(z.object({ service: z.string(), env: z.enum([\"staging\", \"prod\"]), ref: z.string() }))\n .timeout({ ms: 600_000 })\n .handler(async (input, ctx) => {\n ctx.progress({ message: `building ${input.service}@${input.ref}`, percent: 10 });\n // runs against your real deploy pipeline; signal cancels the in-flight rollout\n const res = await fetch(`http://internal/deploy/${input.service}`, {\n method: \"POST\",\n body: JSON.stringify(input),\n signal: ctx.signal,\n });\n ctx.progress({ message: \"rollout complete\", percent: 100 });\n\n // optionally have the agent summarize the tail of the deploy log\n if (ctx.agentCapabilities.sampling) {\n const tail = await getLogTail(input.service); // your real log store\n const summary = await ctx.sample({\n prompt: `Summarize this deploy log tail in two lines:\\n${tail}`,\n maxTokens: 200,\n });\n return { ok: res.ok, summary };\n }\n return { ok: res.ok };\n });\n\ntesseron.action(\"rollback\")\n .describe(\"Roll a service back to its previous release.\")\n .input(z.object({ service: z.string(), env: z.enum([\"staging\", \"prod\"]) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({\n question: `Roll back ${input.service} in ${input.env} to the previous release?`,\n });\n if (!ok) return { rolledBack: false };\n await rollbackService(input.service, input.env); // your real state\n return { rolledBack: true };\n });\n\ntesseron.resource(\"deploy_status\")\n .describe(\"Live status of in-flight and recent deploys.\")\n .read(() => readDeployStatus()) // your real status store\n .subscribe((emit) => {\n emit(readDeployStatus()); // initial value so the first read resolves\n const off = onDeployChange((status) => emit(status)); // your event source\n return () => off(); // cleanup the listener\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(`Pair the agent with claim code: ${welcome.claimCode}`);\n```\n\n**What the agent does.** When you say \"ship payments at ref a1b2c3 to staging\", the agent calls `deploy_ops__trigger_deploy`; your handler streams `ctx.progress` updates as the build and rollout advance, forwards `ctx.signal` so a cancel actually aborts the fetch, and - if the connected agent supports sampling - uses `ctx.sample` to fold a log tail into a two-line summary. \"What is deploying right now?\" reads the `deploy_status` resource and, because it is subscribable, the agent watches it change live instead of polling. \"Roll back prod payments\" hits the rollback action, where `ctx.confirm` gates the destructive step - decline, or connect an agent without elicitation, and it returns false, so nothing happens.\n\n*Expose your typed actions instead of standing up a parallel REST API just to feed an LLM - the agent does not need a button to click, it needs the thing the button does.*\n\n## Bindings and languages\n\nNothing above was specific to a framework. The `tesseron` builder is the same whichever package you import it from - pick the one that matches your runtime:\n\n- **`@tesseron/web`** - any browser app, vanilla or alongside any framework.\n- **`@tesseron/react`**, **`@tesseron/svelte`**, **`@tesseron/vue`** - ergonomic adapters that register actions and resources from inside your components and tear them down on unmount. The admin and editor examples above could use these instead of the bare builder; the declared actions are identical.\n- **`@tesseron/server`** - Node: backend services, CLIs, daemons, and the main process of an Electron or Tauri desktop app. The desktop and daemon examples above run on this.\n- **`@tesseron/core`** - the builder and protocol types with no transport, for wiring your own.\n\nAnd it is not limited to TypeScript. Tesseron is a protocol - the spec is published under CC BY 4.0 - and the JS/TS packages are the reference implementation, not the only possible one. Anything that speaks JSON-RPC 2.0 over a duplex channel can host actions: a Python data daemon, a Rust Tauri app, a .NET line-of-business tool. Those SDKs are not written yet - today TypeScript ships, and a Python SDK and Rust bindings for Tauri are on the roadmap. To expose actions from another language right now, [port the protocol](/sdk/porting/) - it is a small wire contract; for the planned Python SDK and its status, see [its page](/sdk/python/).\n\n## Where to start\n\nThe [5-minute quickstart](/overview/quickstart/) takes any runtime from zero to a claimed session. Then browse the [SDK overview](/sdk/) for the package that fits your stack.\n","bodyText":"Tesseron is an accessibility layer for AI agents: you declare the typed actions your app already performs, and any MCP-compatible agent can call them against your real, running state. This page is about what that lets you build.\n\nNone of these scenarios care what your app is built with. You declare an app, some actions, and a resource or two with one builder, then connect - the same three steps whether your app is a browser tab, a desktop app, or a background service with no UI at all. The examples show the `tesseron` builder directly; where you import it from - and the framework adapters and other-language bindings - is covered at the end, under [Bindings and languages](#bindings-and-languages). Every code block uses the real API.\n\n## A live copilot inside a complex editor\n\nYou are building an editor - say a video editor - and you want an agent that co-edits alongside the human, drafting a rough cut the human then fine-tunes by hand. You declare the editor's real operations once, and the agent calls them straight against your live app.\n\n**The problem.** Dragging clips onto a timeline, snapping them in order, nudging trim handles - that is exactly the fiddly, pixel-precise UI manipulation that browser automation gets wrong, slowly, one brittle round-trip at a time. But \"drop these 6 clips in this order, then a title card before clip 3\" is one structured intent. The agent does not need to drive your timeline widget, it needs to do the thing the widget does.\n\n**What you declare.** A bulk timeline action, a title-card insert, a long-running preview render, an irreversible export, and a subscribable resource for the current timeline so the agent reads structured state instead of scraping the UI.\n\n```ts\n\ntesseron.app({ id: \"video_editor\", name: \"Video Editor\" });\n\ntesseron.action(\"add_clips_to_timeline\")\n .describe(\"Insert multiple clips onto the timeline in one ordered batch\")\n .input(z.object({\n trackId: z.string(),\n clips: z.array(z.object({ assetId: z.string(), startMs: z.number() })),\n }))\n .handler((input) => {\n // one-shot bulk insert against your real editor store\n return editorStore.insertClips(input.trackId, input.clips);\n });\n\ntesseron.action(\"insert_title_card\")\n .describe(\"Insert a title card at a given position\")\n .input(z.object({ trackId: z.string(), atMs: z.number(), text: z.string() }))\n .handler((input) => editorStore.insertTitleCard(input));\n\ntesseron.action(\"render_preview\")\n .describe(\"Render a preview of the current timeline\")\n .input(z.object({ fromMs: z.number(), toMs: z.number() }))\n .annotate({ readOnly: true })\n .handler(async (input, ctx) => {\n // long-running: stream progress and forward cancellation\n return renderer.preview(input, {\n signal: ctx.signal,\n onProgress: (percent) =>\n ctx.progress({ message: \"rendering preview\", percent }),\n });\n });\n\ntesseron.action(\"export_video\")\n .describe(\"Export the final video to a file (expensive, irreversible)\")\n .input(z.object({ format: z.enum([\"mp4\", \"webm\"]), quality: z.enum([\"1080p\", \"4k\"]) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .timeout({ ms: 600000 })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({\n question: `Export the full timeline as ${input.quality} ${input.format}? This burns render minutes.`,\n });\n if (!ok) return { exported: false };\n return renderer.export(input, {\n signal: ctx.signal,\n onProgress: (percent) => ctx.progress({ message: \"exporting\", percent }),\n });\n });\n\ntesseron.resource(\"timeline\")\n .describe(\"The current timeline: tracks, clips, and ordering\")\n .read(() => editorStore.getTimeline())\n .subscribe((emit) => {\n emit(editorStore.getTimeline()); // initial value\n const off = editorStore.on(\"change\", () => emit(editorStore.getTimeline()));\n return () => off(); // cleanup the listener\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(\"Pair the agent with this code:\", welcome.claimCode);\n```\n\n**What the agent does.** When the human says \"build me a 30 second rough cut from the beach footage, clips in chronological order,\" the agent reads the `timeline` resource for available assets, then makes a single `video_editor__add_clips_to_timeline` call with all six clips ordered - no dragging, no per-clip round-trips. \"Put a title card that says Day One before the third clip\" is one `insert_title_card` call. When the human says \"let me see it,\" the agent calls `render_preview`, which streams `ctx.progress` so the human watches the percent climb and can cancel mid-render through `ctx.signal`. Then the human takes over - nudging a trim handle, sliding clip four left by 200ms - and the agent sees the edit because the `timeline` resource re-emits. Finally \"ship the 4K mp4\" triggers `export_video`, which gates on `ctx.confirm`; if the agent's host cannot prompt, `ctx.confirm` returns false and nothing irreversible runs.\n\n*Dragging clips is the UI manipulation automation fails at; \"insert these 6 clips in this order\" is one typed action, and your real handler runs against your real editor state.*\n\n## One-shot bulk operations across a data-heavy app\n\nAn internal admin panel or data dashboard already has buttons for refunds, tagging, and bans. You expose those same handlers to the agent so it can run them in bulk, across exactly what the operator is looking at.\n\n**The problem.** Every bulk job is N brittle UI round-trips: select a row, click refund, confirm the modal, wait, repeat for the next forty orders. The agent doesn't need to click those buttons - it needs to do the thing the buttons do, once, across the whole selection.\n\n**What you declare.** Register the same handlers your buttons already call, plus a resource that exposes the live table filter and row selection so the agent can act on the current view.\n\n```ts\n\ntesseron.app({ id: \"dashboard\", name: \"Admin Dashboard\" });\n\n// expose the live table filter + selection\ntesseron.resource(\"current_selection\")\n .describe(\"The operator's current table filter and selected row ids\")\n .read(() => store.getSelection()) // { filter, orderIds, customerIds, userIds }\n .subscribe((emit) => {\n const onChange = () => emit(store.getSelection());\n store.on(\"change\", onChange);\n return () => store.off(\"change\", onChange); // cleanup the listener\n });\n\ntesseron.action(\"refund_orders\")\n .describe(\"Refund every order in the given list\")\n .input(z.object({ orderIds: z.array(z.string()).min(1) }))\n .handler(async ({ orderIds }, ctx) => {\n let done = 0;\n for (const id of orderIds) {\n await refundOrder(id); // same code path the refund button calls\n ctx.progress({ message: `refunded ${id}`, percent: ++done / orderIds.length });\n }\n return { refunded: done };\n });\n\ntesseron.action(\"tag_customers\")\n .describe(\"Apply a tag to a list of customers\")\n .input(z.object({ customerIds: z.array(z.string()).min(1), tag: z.string() }))\n .handler(async ({ customerIds, tag }) => {\n await tagCustomers(customerIds, tag); // your real customer store\n return { tagged: customerIds.length };\n });\n\ntesseron.action(\"ban_users\")\n .describe(\"Permanently ban a list of users\")\n .input(z.object({ userIds: z.array(z.string()).min(1), reason: z.string() }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async ({ userIds, reason }, ctx) => {\n const ok = await ctx.confirm({\n question: `Ban ${userIds.length} users for \"${reason}\"? This is irreversible.`,\n });\n if (!ok) return { banned: 0 };\n await banUsers(userIds, reason); // same code path the ban button calls\n return { banned: userIds.length };\n });\n\nawait tesseron.connect();\n```\n\n`ban_users` is annotated destructive, and the `ctx.confirm` gate returns `false` on decline and on agents without elicitation - so the safe path needs no capability guard. The `current_selection` resource is subscribable, so the agent always acts on the operator's current view rather than a stale snapshot.\n\n**What the agent does.** An operator filters the table to last week's failed charges, selects the rows, and types \"refund the orders I currently have selected.\" The agent reads the `current_selection` resource, pulls the `orderIds`, and calls `dashboard__refund_orders` with the whole array - five clicks become one call, and `ctx.progress` streams each refund back into the chat. \"Tag everyone in this view as priority_support\" reads the same resource and fires `dashboard__tag_customers` once. \"Ban these three accounts for fraud\" triggers `dashboard__ban_users`, which calls `ctx.confirm` - the operator approves in the agent, the ban runs, and a decline returns `{ banned: 0 }`.\n\n*Your real handler runs against your real state - no separate MCP server, no backend duplication, just one-shot bulk where the UI made you click N times.*\n\n## A desktop or local-first app\n\nNot every app is a web page. A desktop tool - a markdown notes vault, a local-database GUI, an offline knowledge base - exposes actions exactly the same way. Tesseron runs inside your app's own process (for Electron or Tauri, the main process), mutates real state on disk, and pushes the result to your UI; no browser tab is involved anywhere.\n\n**The problem.** A desktop app has no public API and no URL an agent can hit - the only way in is the UI, so an agent would have to drive menus, dialogs, and a tree view it cannot see. \"Reorganize my 400 notes into folders by topic\" is hundreds of brittle clicks. The agent doesn't need to click the New Note button, it needs to do the thing that button does, against your real vault.\n\n**What you declare.** The same builder, running in the process that owns the files.\n\n```ts\n\ntesseron.app({\n id: \"notes_vault\",\n name: \"Notes Vault\",\n description: \"A local markdown notes vault.\",\n});\n\nconst folderSchema = z.object({ folder: z.string() });\n\ntesseron.action(\"create_note\")\n .describe(\"Create a markdown note in a folder.\")\n .input(z.object({ title: z.string(), body: z.string(), folder: z.string().optional() }))\n .handler(async (input, ctx) => {\n let folder = input.folder;\n if (!folder) {\n // no destination given - ask the human which folder\n const picked = await ctx.elicit({\n question: \"Which folder should this note go in?\",\n schema: folderSchema,\n jsonSchema: z.toJSONSchema(folderSchema),\n });\n if (picked === null) return { created: false };\n folder = picked.folder;\n }\n // writes a real .md file and notifies the UI\n const path = await vault.writeNote(folder, input.title, input.body);\n return { created: true, path };\n });\n\ntesseron.action(\"search_notes\")\n .describe(\"Full-text search across the vault.\")\n .input(z.object({ query: z.string() }))\n .annotate({ readOnly: true })\n .handler(async (input) => {\n // runs against your real local index\n return { hits: await vault.search(input.query) };\n });\n\ntesseron.action(\"organize_vault\")\n .describe(\"Move notes into topic folders in bulk.\")\n .input(z.object({ moves: z.array(z.object({ path: z.string(), folder: z.string() })) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({ question: `Move ${input.moves.length} notes?` });\n if (!ok) return { moved: 0 };\n for (let i = 0; i < input.moves.length; i++) {\n ctx.progress({ message: `moving ${input.moves[i].path}`, percent: (i / input.moves.length) * 100 });\n await vault.move(input.moves[i].path, input.moves[i].folder); // real fs move\n }\n return { moved: input.moves.length };\n });\n\ntesseron.resource(\"vault_tree\")\n .describe(\"The current folder and note tree.\")\n .read(() => vault.tree())\n .subscribe((emit) => {\n const onChange = () => emit(vault.tree());\n vault.on(\"change\", onChange);\n return () => vault.off(\"change\", onChange); // tear down the watcher\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(`Pair the agent with claim code: ${welcome.claimCode}`);\n```\n\n**What the agent does.** \"Jot down a note about today's standup\" calls `notes_vault__create_note` with no folder, so the handler runs `ctx.elicit` and a native dialog asks which folder - you pick \"Work\", the note is written, and your UI refreshes. \"Reorganize my vault by topic\" reads the `vault_tree` resource, then calls `notes_vault__organize_vault`; because it is annotated destructive, `ctx.confirm` gates the move (\"Move 412 notes?\") and `ctx.progress` streams each file as it lands. \"Find everything I wrote about Postgres\" hits the read-only `notes_vault__search_notes` and returns hits straight from your local index. One bulk call replaces hundreds of drag-and-drop round-trips.\n\n*The agent does the thing your buttons do - your real handler runs against your real vault, no browser in sight.*\n\n## A service or daemon with no UI\n\nSome apps have no UI at all - a deploy runner, a data-pipeline supervisor, an internal CLI. There is nothing to render; there are just typed handlers in a long-running process. Expose them and an agent can operate the service directly.\n\n**The problem.** To let an agent drive a service like this, you would normally stand up a parallel REST API just to feed an LLM - new routes, new auth, new serialization - and then watch it drift from the real internal functions it wraps. Tesseron skips that layer: you expose your existing typed handlers directly, and your real handler runs against your real state.\n\n**What you declare.**\n\n```ts\n\ntesseron.app({\n id: \"deploy_ops\",\n name: \"Deploy Ops Daemon\",\n description: \"Headless control plane for deploys and rollbacks\",\n});\n\ntesseron.action(\"trigger_deploy\")\n .describe(\"Build and roll out a service to an environment. Returns the result.\")\n .input(z.object({ service: z.string(), env: z.enum([\"staging\", \"prod\"]), ref: z.string() }))\n .timeout({ ms: 600_000 })\n .handler(async (input, ctx) => {\n ctx.progress({ message: `building ${input.service}@${input.ref}`, percent: 10 });\n // runs against your real deploy pipeline; signal cancels the in-flight rollout\n const res = await fetch(`http://internal/deploy/${input.service}`, {\n method: \"POST\",\n body: JSON.stringify(input),\n signal: ctx.signal,\n });\n ctx.progress({ message: \"rollout complete\", percent: 100 });\n\n // optionally have the agent summarize the tail of the deploy log\n if (ctx.agentCapabilities.sampling) {\n const tail = await getLogTail(input.service); // your real log store\n const summary = await ctx.sample({\n prompt: `Summarize this deploy log tail in two lines:\\n${tail}`,\n maxTokens: 200,\n });\n return { ok: res.ok, summary };\n }\n return { ok: res.ok };\n });\n\ntesseron.action(\"rollback\")\n .describe(\"Roll a service back to its previous release.\")\n .input(z.object({ service: z.string(), env: z.enum([\"staging\", \"prod\"]) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({\n question: `Roll back ${input.service} in ${input.env} to the previous release?`,\n });\n if (!ok) return { rolledBack: false };\n await rollbackService(input.service, input.env); // your real state\n return { rolledBack: true };\n });\n\ntesseron.resource(\"deploy_status\")\n .describe(\"Live status of in-flight and recent deploys.\")\n .read(() => readDeployStatus()) // your real status store\n .subscribe((emit) => {\n emit(readDeployStatus()); // initial value so the first read resolves\n const off = onDeployChange((status) => emit(status)); // your event source\n return () => off(); // cleanup the listener\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(`Pair the agent with claim code: ${welcome.claimCode}`);\n```\n\n**What the agent does.** When you say \"ship payments at ref a1b2c3 to staging\", the agent calls `deploy_ops__trigger_deploy`; your handler streams `ctx.progress` updates as the build and rollout advance, forwards `ctx.signal` so a cancel actually aborts the fetch, and - if the connected agent supports sampling - uses `ctx.sample` to fold a log tail into a two-line summary. \"What is deploying right now?\" reads the `deploy_status` resource and, because it is subscribable, the agent watches it change live instead of polling. \"Roll back prod payments\" hits the rollback action, where `ctx.confirm` gates the destructive step - decline, or connect an agent without elicitation, and it returns false, so nothing happens.\n\n*Expose your typed actions instead of standing up a parallel REST API just to feed an LLM - the agent does not need a button to click, it needs the thing the button does.*\n\n## Bindings and languages\n\nNothing above was specific to a framework. The `tesseron` builder is the same whichever package you import it from - pick the one that matches your runtime:\n\n- **`@tesseron/web`** - any browser app, vanilla or alongside any framework.\n- **`@tesseron/react`**, **`@tesseron/svelte`**, **`@tesseron/vue`** - ergonomic adapters that register actions and resources from inside your components and tear them down on unmount. The admin and editor examples above could use these instead of the bare builder; the declared actions are identical.\n- **`@tesseron/server`** - Node: backend services, CLIs, daemons, and the main process of an Electron or Tauri desktop app. The desktop and daemon examples above run on this.\n- **`@tesseron/core`** - the builder and protocol types with no transport, for wiring your own.\n\nAnd it is not limited to TypeScript. Tesseron is a protocol - the spec is published under CC BY 4.0 - and the JS/TS packages are the reference implementation, not the only possible one. Anything that speaks JSON-RPC 2.0 over a duplex channel can host actions: a Python data daemon, a Rust Tauri app, a .NET line-of-business tool. Those SDKs are not written yet - today TypeScript ships, and a Python SDK and Rust bindings for Tauri are on the roadmap. To expose actions from another language right now, [port the protocol](/sdk/porting/) - it is a small wire contract; for the planned Python SDK and its status, see [its page](/sdk/python/).\n\n## Where to start\n\nThe [5-minute quickstart](/overview/quickstart/) takes any runtime from zero to a claimed session. Then browse the [SDK overview](/sdk/) for the package that fits your stack."},{"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\nThe agent doesn't need to click a button - it needs to *do the thing the button does*. Tesseron is the layer that lets it: you instrument your app once, the way you'd add ARIA to a web page, and any MCP-compatible agent can call the typed actions you expose. An accessibility layer for AI agents, in other words - or an API for agents, written by the people who built the app.\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 app, with their real state, their real auth.\n\n### Not just for the web\n\nTesseron is a protocol, not a web framework. The shipped SDKs cover TypeScript, Python, Rust, and C++. The SDKs live in [separate language repositories](/sdk/). TypeScript, Rust, and Python are published on npm, crates.io, and PyPI; C++ is consumed through CMake FetchContent. Any process that can open a WebSocket and speak JSON-RPC 2.0 can host actions, including a Python daemon, a Rust desktop app, a C++ service, or a .NET line-of-business tool. See [Porting Tesseron](/sdk/porting/), the [Python SDK](/sdk/python/), the [Rust SDK](/sdk/rust/), and the [C++ SDK](/sdk/cpp/).\n\n## 5. Tesseron and WebMCP\n\nThe [W3C WebMCP draft](https://webmachinelearning.github.io/webmcp/) is a W3C Community Group draft co-authored by Google and Microsoft. It lets a website expose tools to the browser's own agent. Chrome put it behind a flag in Chrome 146 in February 2026, and the [Chrome origin trial](https://developer.chrome.com/blog/ai-webmcp-origin-trial) is ongoing. The July 2026 draft moved the API from `navigator.modelContext` to `document.modelContext`. Chrome 150 deprecated the old name. `provideContext()` was removed in March 2026. Only Chromium implements WebMCP today. It exposes tools through `registerTool()` with a JSON schema or through annotated forms. Those tools run in the page as the logged-in user. The agent has to live in the browser, either built in or installed as an extension. A public website that wants the browser's own assistant to fill forms is the right fit.\n\n- **Any process, not a page.** Tesseron can expose actions from a Tauri app, Python daemon, CLI, or game, and a Python daemon can expose `importTodos` while a Tauri app uses system webviews that cannot reach WebMCP through its own UI.\n- **The agent is outside, and it is the one you already use.** Claude Code, Cursor, or Claude Desktop can build, run, and drive the app through Tesseron, while a coding agent cannot reach `document.modelContext`, so Claude Code can call `addTodo` after editing its handler.\n- **One gateway sees every running app.** Tesseron can expose a browser tab and a desktop app together, so a cross-app flow is built in; for example, it can read an invoice from a web app and post it into a local accounting app.\n- **The app can talk back.** Tesseron supports sampling, elicitation, `confirm`, resources with subscriptions, progress, cancellation, and resume; for example, `importTodos` can report each added item while a subscribed resource updates.\n- **Loopback plus an explicit claim.** Tesseron stays on loopback and requires the user's claim code, so a third-party script on the page cannot register a tool; for example, an unrelated analytics script cannot expose `deleteAccount` through the gateway.\n- **One CC BY spec, several languages, conformance-tested.** The protocol is CC BY 4.0, SDKs can be written in several languages, and the conformance suite checks them; for example, the same `addTodo` action can run in TypeScript, Python, Rust, or C++.\n\nTesseron does not build on WebMCP and does not publish into it. Use WebMCP for browser-native agents and Tesseron for the wider set of processes and agents.\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- **Bound to a running app.** The agent can only act while your app is running. A refresh or reload keeps the same session (resume is on by default); fully closing the app ends it. This is a feature - it keeps the agent bound to what the user can actually 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- Desktop and back-end apps too - an Electron editor, a Node daemon, a CLI - that want an agent-callable surface without standing up a separate MCP server.\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\nThe agent doesn't need to click a button - it needs to *do the thing the button does*. Tesseron is the layer that lets it: you instrument your app once, the way you'd add ARIA to a web page, and any MCP-compatible agent can call the typed actions you expose. An accessibility layer for AI agents, in other words - or an API for agents, written by the people who built the app.\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 app, with their real state, their real auth.\n\n### Not just for the web\n\nTesseron is a protocol, not a web framework. The shipped SDKs cover TypeScript, Python, Rust, and C++. The SDKs live in [separate language repositories](/sdk/). TypeScript, Rust, and Python are published on npm, crates.io, and PyPI; C++ is consumed through CMake FetchContent. Any process that can open a WebSocket and speak JSON-RPC 2.0 can host actions, including a Python daemon, a Rust desktop app, a C++ service, or a .NET line-of-business tool. See [Porting Tesseron](/sdk/porting/), the [Python SDK](/sdk/python/), the [Rust SDK](/sdk/rust/), and the [C++ SDK](/sdk/cpp/).\n\n## 5. Tesseron and WebMCP\n\nThe [W3C WebMCP draft](https://webmachinelearning.github.io/webmcp/) is a W3C Community Group draft co-authored by Google and Microsoft. It lets a website expose tools to the browser's own agent. Chrome put it behind a flag in Chrome 146 in February 2026, and the [Chrome origin trial](https://developer.chrome.com/blog/ai-webmcp-origin-trial) is ongoing. The July 2026 draft moved the API from `navigator.modelContext` to `document.modelContext`. Chrome 150 deprecated the old name. `provideContext()` was removed in March 2026. Only Chromium implements WebMCP today. It exposes tools through `registerTool()` with a JSON schema or through annotated forms. Those tools run in the page as the logged-in user. The agent has to live in the browser, either built in or installed as an extension. A public website that wants the browser's own assistant to fill forms is the right fit.\n\n- **Any process, not a page.** Tesseron can expose actions from a Tauri app, Python daemon, CLI, or game, and a Python daemon can expose `importTodos` while a Tauri app uses system webviews that cannot reach WebMCP through its own UI.\n- **The agent is outside, and it is the one you already use.** Claude Code, Cursor, or Claude Desktop can build, run, and drive the app through Tesseron, while a coding agent cannot reach `document.modelContext`, so Claude Code can call `addTodo` after editing its handler.\n- **One gateway sees every running app.** Tesseron can expose a browser tab and a desktop app together, so a cross-app flow is built in; for example, it can read an invoice from a web app and post it into a local accounting app.\n- **The app can talk back.** Tesseron supports sampling, elicitation, `confirm`, resources with subscriptions, progress, cancellation, and resume; for example, `importTodos` can report each added item while a subscribed resource updates.\n- **Loopback plus an explicit claim.** Tesseron stays on loopback and requires the user's claim code, so a third-party script on the page cannot register a tool; for example, an unrelated analytics script cannot expose `deleteAccount` through the gateway.\n- **One CC BY spec, several languages, conformance-tested.** The protocol is CC BY 4.0, SDKs can be written in several languages, and the conformance suite checks them; for example, the same `addTodo` action can run in TypeScript, Python, Rust, or C++.\n\nTesseron does not build on WebMCP and does not publish into it. Use WebMCP for browser-native agents and Tesseron for the wider set of processes and agents.\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- **Bound to a running app.** The agent can only act while your app is running. A refresh or reload keeps the same session (resume is on by default); fully closing the app ends it. This is a feature - it keeps the agent bound to what the user can actually 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- Desktop and back-end apps too - an Electron editor, a Node daemon, a CLI - that want an agent-callable surface without standing up a separate MCP server.\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/compatibility","title":"Compatibility","description":"Which protocol versions work together, and which package ranges speak them.","section":"protocol","related":["protocol/handshake","protocol/transport","sdk/porting"],"bodyRaw":"\n## The rule\n\nProtocol version decides compatibility. Package version numbers do not need to match across SDKs or the gateway.\n\nA host speaking protocol `1.x` works with a gateway speaking protocol `1.y`, as long as the major version is the same. The [`tesseron/hello` handshake](/protocol/handshake/) negotiates minor differences. A newer minor can add fields, and an older peer can ignore fields it does not know.\n\n## Protocol support\n\n| Protocol version | Packages that speak it |\n| --- | --- |\n| `1.2.0` | `@tesseron/core`, `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, `@tesseron/svelte`, `@tesseron/vue`, `@tesseron/vite`, and `@tesseron/mcp` `>=2.10.0` |\n| `1.2.0` | [`tesseron`](/sdk/python/) (Python) `>=0.1.0`. Published on PyPI. |\n| `1.2.0` | [`tesseron`](/sdk/rust/) (Rust) `0.1.x`. Published on crates.io. |\n| `1.2.0` | [`tesseron::tesseron`](/sdk/cpp/) (C++) `>=0.1.0`. Source-only, built through CMake `FetchContent`. |\n\nThe table starts at `1.2.0`. The history checked for this page does not prove package boundaries for earlier protocol versions.\n\nThe Python and Rust SDKs carry their own versions and move on their own. They speak the same protocol, which is the only thing that has to match. C++ releases use the same rule and get rows here as they land.\n\nThe C++ host does not mint its own claim code and does not speak a unix domain socket, so it skips the `bind/*` fixtures and `uds/file-mode` and passes every other fixture in the suite. See [C++ conformance](/sdk/cpp/conformance/).\n\n## TypeScript package versions\n\nThe seven TypeScript SDK packages (`core`, `web`, `server`, `react`, `svelte`, `vue`, and `vite`) are one fixed release group in [tesseron-typescript](https://github.com/Eigenwise/tesseron-typescript). Install them at the same version. The hub packages `@tesseron/mcp`, `@tesseron/docs-mcp`, and `@tesseron/conformance` release independently. Gateway compatibility follows the protocol version rule above.\n\n## When the handshake fails\n\nA host and gateway with different protocol majors get this JSON-RPC error from the gateway:\n\n```text\nGateway speaks protocol 1.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/\n```\n\nUse a host and gateway that speak the same protocol major.\n\nA legacy gateway that dials a host-minted WebSocket session without a bind subprotocol gets `HTTP/1.1 426 Upgrade Required` with this response body:\n\n```text\nThis Tesseron host requires a v1.2-compatible gateway (tesseron-bind subprotocol). Upgrade @tesseron/mcp to >= 2.4.0.\n```\n\nUpgrade `@tesseron/mcp` to `>=2.4.0`.\n","bodyText":"## The rule\n\nProtocol version decides compatibility. Package version numbers do not need to match across SDKs or the gateway.\n\nA host speaking protocol `1.x` works with a gateway speaking protocol `1.y`, as long as the major version is the same. The [`tesseron/hello` handshake](/protocol/handshake/) negotiates minor differences. A newer minor can add fields, and an older peer can ignore fields it does not know.\n\n## Protocol support\n\n| Protocol version | Packages that speak it |\n| --- | --- |\n| `1.2.0` | `@tesseron/core`, `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, `@tesseron/svelte`, `@tesseron/vue`, `@tesseron/vite`, and `@tesseron/mcp` `>=2.10.0` |\n| `1.2.0` | [`tesseron`](/sdk/python/) (Python) `>=0.1.0`. Published on PyPI. |\n| `1.2.0` | [`tesseron`](/sdk/rust/) (Rust) `0.1.x`. Published on crates.io. |\n| `1.2.0` | [`tesseron::tesseron`](/sdk/cpp/) (C++) `>=0.1.0`. Source-only, built through CMake `FetchContent`. |\n\nThe table starts at `1.2.0`. The history checked for this page does not prove package boundaries for earlier protocol versions.\n\nThe Python and Rust SDKs carry their own versions and move on their own. They speak the same protocol, which is the only thing that has to match. C++ releases use the same rule and get rows here as they land.\n\nThe C++ host does not mint its own claim code and does not speak a unix domain socket, so it skips the `bind/*` fixtures and `uds/file-mode` and passes every other fixture in the suite. See [C++ conformance](/sdk/cpp/conformance/).\n\n## TypeScript package versions\n\nThe seven TypeScript SDK packages (`core`, `web`, `server`, `react`, `svelte`, `vue`, and `vite`) are one fixed release group in [tesseron-typescript](https://github.com/Eigenwise/tesseron-typescript). Install them at the same version. The hub packages `@tesseron/mcp`, `@tesseron/docs-mcp`, and `@tesseron/conformance` release independently. Gateway compatibility follows the protocol version rule above.\n\n## When the handshake fails\n\nA host and gateway with different protocol majors get this JSON-RPC error from the gateway:\n\n```text\nGateway speaks protocol 1.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/\n```\n\nUse a host and gateway that speak the same protocol major.\n\nA legacy gateway that dials a host-minted WebSocket session without a bind subprotocol gets `HTTP/1.1 426 Upgrade Required` with this response body:\n\n```text\nThis Tesseron host requires a v1.2-compatible gateway (tesseron-bind subprotocol). Upgrade @tesseron/mcp to >= 2.4.0.\n```\n\nUpgrade `@tesseron/mcp` to `>=2.4.0`."},{"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`. In protocol 1.2.0, the host validator rejects these shapes with `-32602 InvalidParams`:\n\n- A schema that is not a JSON object.\n- A top-level `type` whose value is anything other than `\"object\"`.\n- A truthy top-level `oneOf`, `anyOf`, `allOf`, or `not` keyword.\n- A property whose checked `type` is `\"object\"`.\n- A property whose checked `type` is `\"array\"`.\n- A property whose checked `type` is any other non-primitive value. Present property types must be `\"string\"`, `\"number\"`, `\"integer\"`, or `\"boolean\"`.\n\nTwo lenient cases are part of the 1.2.0 validator behavior:\n\n- A property without a `type` is accepted. The validator does not infer a type from the property's other keywords.\n- When a property's `type` is an array, only its first entry is checked. A primitive first entry is accepted even when a later entry is unsupported; an unsupported first entry is rejected.\n\nThe lenient cases preserve the current 1.2.0 behavior. Tightening either rule should wait for a future minor, because it would reject schemas that currently pass.\n\nThe SDK enforces these rules on send and surfaces the `InvalidParams` error at the `ctx.elicit` call site. The gateway checks the same schema again when it receives the request.\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`. In protocol 1.2.0, the host validator rejects these shapes with `-32602 InvalidParams`:\n\n- A schema that is not a JSON object.\n- A top-level `type` whose value is anything other than `\"object\"`.\n- A truthy top-level `oneOf`, `anyOf`, `allOf`, or `not` keyword.\n- A property whose checked `type` is `\"object\"`.\n- A property whose checked `type` is `\"array\"`.\n- A property whose checked `type` is any other non-primitive value. Present property types must be `\"string\"`, `\"number\"`, `\"integer\"`, or `\"boolean\"`.\n\nTwo lenient cases are part of the 1.2.0 validator behavior:\n\n- A property without a `type` is accepted. The validator does not infer a type from the property's other keywords.\n- When a property's `type` is an array, only its first entry is checked. A primitive first entry is accepted even when a later entry is unsupported; an unsupported first entry is rejected.\n\nThe lenient cases preserve the current 1.2.0 behavior. Tightening either rule should wait for a future minor, because it would reject schemas that currently pass.\n\nThe SDK enforces these rules on send and surfaces the `InvalidParams` error at the `ctx.elicit` call site. The gateway checks the same schema again when it receives the request.\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, including a missing or non-`\"2.0\"` `jsonrpc` member. The response echoes a usable string, number, or null id, otherwise it uses null. |\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, or tried subscribing to an unknown or non-subscribable resource. |\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, including a missing or non-`\"2.0\"` `jsonrpc` member. The response echoes a usable string, number, or null id, otherwise it uses null. |\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, or tried subscribing to an unknown or non-subscribable resource. |\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: 'YOUR 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.2.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.2.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 app to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).\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. The wire path is one of two flavours, picked by the host and signalled in the instance manifest.\n\n### Host-minted claims and the bind handshake\n\nDefault since `@tesseron/vite@2.2.0` and `@tesseron/mcp@2.4.0`, and the reason the protocol went to `1.2.0`. See [tesseron#60](https://github.com/eigenwise/tesseron/issues/60).\n\nThe host (Vite plugin, `@tesseron/server`) mints the claim code, session id, and resume token at instance creation, writes them into the manifest's `hostMintedClaim`, and sets `helloHandledByHost: true`. That flag means \"do not auto-dial.\" The host answers its own app's `tesseron/hello` locally with a synthesized welcome, so the user sees a claim code without any gateway involved yet.\n\nWhen the user pastes the code into one specific Claude session, that gateway scans every host-mint manifest for a matching `hostMintedClaim.code` and dials only the matching instance, carrying the code in a bind step that the host validates in constant time before accepting. The user's paste deterministically picks the gateway, so there is no race and no \"switch to the right Claude\" detour.\n\nThe bind step is per-binding, because a Unix socket has no upgrade handshake to carry a subprotocol:\n\n| Binding | Mechanism |\n|---|---|\n| [WebSocket](/protocol/transport-bindings/ws/#bind-subprotocol-host-minted-claims) | `Sec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.<code>` on the upgrade |\n| [Unix domain socket](/protocol/transport-bindings/uds/#the-tesseronbind-handshake) | `tesseron/bind { code }` as the first NDJSON frame after connect |\n\nEither way the host validates before the session exists, and four rules hold for both:\n\n- **Constant-time comparison.** A short-circuiting compare leaks the code one character at a time to a process that can already reach the endpoint.\n- **Sliding TTL.** `expiresAt` is `mintedAt + 10 minutes`, refreshed every 5 minutes by rewriting the manifest. The heartbeat stops once the claim is spent. A gateway scanning for a code skips entries whose `expiresAt` has passed.\n- **Rate limit.** 5 mismatches within a 60-second rolling window trip a 60-second lockout. A successful bind resets the window.\n- **One shot.** `boundAgent` goes non-null on success and the claim is never re-bindable.\n\nAfter a successful bind the host replays the app's cached hello to the gateway and swallows the gateway's id-matched reply, so the app keeps the welcome it already resolved and never sees a second one.\n\nA gateway that dials a host-minted instance **without** binding is refused (`426 Upgrade Required` on WebSocket, `-32600 InvalidRequest` and a close on UDS). That gateway predates 1.2, and letting it through would deliver a second welcome against an already-resolved hello promise.\n\nPorting a host? The bind handshake is optional. Leave `helloHandledByHost` unset and the gateway auto-dials and mints the code for you, which is the simpler implementation and stays supported. Advertising the flag without implementing bind is the one combination that breaks. See [Port Tesseron to your language](/sdk/porting/).\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.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/\" } }\n```\n\nAn SDK also refuses an unreadable welcome result or one from a different major version. It closes that transport and does not run later requests from the rejected session.\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.2.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.2.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 app to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).\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. The wire path is one of two flavours, picked by the host and signalled in the instance manifest.\n\n### Host-minted claims and the bind handshake\n\nDefault since `@tesseron/vite@2.2.0` and `@tesseron/mcp@2.4.0`, and the reason the protocol went to `1.2.0`. See [tesseron#60](https://github.com/eigenwise/tesseron/issues/60).\n\nThe host (Vite plugin, `@tesseron/server`) mints the claim code, session id, and resume token at instance creation, writes them into the manifest's `hostMintedClaim`, and sets `helloHandledByHost: true`. That flag means \"do not auto-dial.\" The host answers its own app's `tesseron/hello` locally with a synthesized welcome, so the user sees a claim code without any gateway involved yet.\n\nWhen the user pastes the code into one specific Claude session, that gateway scans every host-mint manifest for a matching `hostMintedClaim.code` and dials only the matching instance, carrying the code in a bind step that the host validates in constant time before accepting. The user's paste deterministically picks the gateway, so there is no race and no \"switch to the right Claude\" detour.\n\nThe bind step is per-binding, because a Unix socket has no upgrade handshake to carry a subprotocol:\n\n| Binding | Mechanism |\n|---|---|\n| [WebSocket](/protocol/transport-bindings/ws/#bind-subprotocol-host-minted-claims) | `Sec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.<code>` on the upgrade |\n| [Unix domain socket](/protocol/transport-bindings/uds/#the-tesseronbind-handshake) | `tesseron/bind { code }` as the first NDJSON frame after connect |\n\nEither way the host validates before the session exists, and four rules hold for both:\n\n- **Constant-time comparison.** A short-circuiting compare leaks the code one character at a time to a process that can already reach the endpoint.\n- **Sliding TTL.** `expiresAt` is `mintedAt + 10 minutes`, refreshed every 5 minutes by rewriting the manifest. The heartbeat stops once the claim is spent. A gateway scanning for a code skips entries whose `expiresAt` has passed.\n- **Rate limit.** 5 mismatches within a 60-second rolling window trip a 60-second lockout. A successful bind resets the window.\n- **One shot.** `boundAgent` goes non-null on success and the claim is never re-bindable.\n\nAfter a successful bind the host replays the app's cached hello to the gateway and swallows the gateway's id-matched reply, so the app keeps the welcome it already resolved and never sees a second one.\n\nA gateway that dials a host-minted instance **without** binding is refused (`426 Upgrade Required` on WebSocket, `-32600 InvalidRequest` and a close on UDS). That gateway predates 1.2, and letting it through would deliver a second welcome against an already-resolved hello promise.\n\nPorting a host? The bind handshake is optional. Leave `helloHandledByHost` unset and the gateway auto-dials and mints the code for you, which is the simpler implementation and stays supported. Advertising the flag without implementing bind is the one combination that breaks. See [Port Tesseron to your language](/sdk/porting/).\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.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/\" } }\n```\n\nAn SDK also refuses an unreadable welcome result or one from a different major version. It closes that transport and does not run later requests from the rejected session.\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/compatibility","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/eigenwise/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.2.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: 'YOUR 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=\"Compatibility\" href=\"./compatibility/\"\n description=\"Protocol version support across SDKs and the gateway.\" />\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.2.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.2.0`**.\n\n## Read the pages in order\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.2.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\nWhen present, `percent` is an integer from 0 through 100 and cannot decrease during one invocation. The host clamps a value outside that range, then raises anything below that invocation's highest sent percent to that ceiling. It forwards `message` and `data` unchanged. An update without `percent` does not change the ceiling.\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\nWhen present, `percent` is an integer from 0 through 100 and cannot decrease during one invocation. The host clamps a value outside that range, then raises anything below that invocation's highest sent percent to that ceiling. It forwards `message` and `data` unchanged. An update without `percent` does not change the ceiling.\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:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"result\": null }\n```\n\nThe `null` result acknowledges that the SDK 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, then acknowledges with `result: null`:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"result\": null }\n```\n\n### Subscription failures\n\nA resource needs a `.subscribe()` handler before the gateway can subscribe. An unknown resource, or a declared resource without that handler, returns `-32003 ActionNotFound` with `Resource not subscribable: <name>`.\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:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"result\": null }\n```\n\nThe `null` result acknowledges that the SDK 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, then acknowledges with `result: null`:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"result\": null }\n```\n\n### Subscription failures\n\nA resource needs a `.subscribe()` handler before the gateway can subscribe. An unknown resource, or a declared resource without that handler, returns `-32003 ActionNotFound` with `Resource not subscribable: <name>`.\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.2.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.2.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.2.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.2.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 owns and enforces `maxSamplingDepth = 3`. Sampling depth is not a field in any Tesseron frame, so a host does not count or increment it and does not need its own depth check. The host forwards `sampling/request`; when the gateway detects that the cap is exceeded, it returns `-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 owns and enforces `maxSamplingDepth = 3`. Sampling depth is not a field in any Tesseron frame, so a host does not count or increment it and does not need its own depth check. The host forwards `sampling/request`; when the gateway detects that the cap is exceeded, it returns `-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 - a socket has no upgrade handshake to carry one. Bytes start flowing the moment `connect()` succeeds, and the app sends `tesseron/hello` (or `tesseron/resume`) as its first message.\n\nHost-minted instances are the exception: there the gateway sends [`tesseron/bind`](#the-tesseronbind-handshake) first and the app's hello is held back until the bind succeeds.\n\n## The `tesseron/bind` handshake\n\nThe WebSocket binding carries a host-minted claim code in a `tesseron-bind.<code>` subprotocol element. A Unix socket has no upgrade to hang that on, so the same gate is a JSON-RPC request instead: the gateway sends `tesseron/bind` as the **first NDJSON frame after connect**, before any other traffic.\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": \"__tesseron-bind-<uuid>\", \"method\": \"tesseron/bind\", \"params\": { \"code\": \"7Q4K-M2\" } }\n```\n\nThe host upper-cases the incoming code, compares it against `hostMintedClaim.code` in constant time, and answers:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": \"__tesseron-bind-<uuid>\", \"result\": { \"ok\": true } }\n```\n\nFailures answer with an error and, where noted, close the socket:\n\n| Condition | Code | Socket |\n|---|---|---|\n| Host is in bind lockout | `-32009 Unauthorized` | Closed |\n| Code does not match | `-32009 Unauthorized` | Closed |\n| Already bound | `-32009 Unauthorized` | Kept |\n| Claim already spent (`boundAgent !== null`) | `-32009 Unauthorized` | Kept |\n| `params.code` missing or not a string | `-32602 InvalidParams` | Kept |\n| Any non-bind frame arrives first | `-32600 InvalidRequest` | Closed |\n\nThat last row is the UDS counterpart to the WebSocket binding's `426 Upgrade Required`. A host that minted its own claim has already answered the app's hello with a synthesized welcome, so a gateway that starts talking without binding would produce a second, conflicting welcome. The host closes instead.\n\nMismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout; a successful bind resets the window.\n\nAfter acking, the host replays the app's cached hello to the gateway and drops the gateway's id-matched reply, so the app never sees the second welcome. Queued non-hello frames drain afterwards.\n\nHosts that leave `helloHandledByHost` unset never take this path: the gateway auto-dials and mints the code itself, and the first frame on the wire is the app's hello.\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\nIf you also mint claims host-side, implement [`tesseron/bind`](#the-tesseronbind-handshake) with constant-time comparison, the rate limit, and the close-on-unbound-frame rule. Skipping it while advertising `helloHandledByHost: true` leaves the app unreachable.\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 - a socket has no upgrade handshake to carry one. Bytes start flowing the moment `connect()` succeeds, and the app sends `tesseron/hello` (or `tesseron/resume`) as its first message.\n\nHost-minted instances are the exception: there the gateway sends [`tesseron/bind`](#the-tesseronbind-handshake) first and the app's hello is held back until the bind succeeds.\n\n## The `tesseron/bind` handshake\n\nThe WebSocket binding carries a host-minted claim code in a `tesseron-bind.<code>` subprotocol element. A Unix socket has no upgrade to hang that on, so the same gate is a JSON-RPC request instead: the gateway sends `tesseron/bind` as the **first NDJSON frame after connect**, before any other traffic.\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": \"__tesseron-bind-<uuid>\", \"method\": \"tesseron/bind\", \"params\": { \"code\": \"7Q4K-M2\" } }\n```\n\nThe host upper-cases the incoming code, compares it against `hostMintedClaim.code` in constant time, and answers:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": \"__tesseron-bind-<uuid>\", \"result\": { \"ok\": true } }\n```\n\nFailures answer with an error and, where noted, close the socket:\n\n| Condition | Code | Socket |\n|---|---|---|\n| Host is in bind lockout | `-32009 Unauthorized` | Closed |\n| Code does not match | `-32009 Unauthorized` | Closed |\n| Already bound | `-32009 Unauthorized` | Kept |\n| Claim already spent (`boundAgent !== null`) | `-32009 Unauthorized` | Kept |\n| `params.code` missing or not a string | `-32602 InvalidParams` | Kept |\n| Any non-bind frame arrives first | `-32600 InvalidRequest` | Closed |\n\nThat last row is the UDS counterpart to the WebSocket binding's `426 Upgrade Required`. A host that minted its own claim has already answered the app's hello with a synthesized welcome, so a gateway that starts talking without binding would produce a second, conflicting welcome. The host closes instead.\n\nMismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout; a successful bind resets the window.\n\nAfter acking, the host replays the app's cached hello to the gateway and drops the gateway's id-matched reply, so the app never sees the second welcome. Queued non-hello frames drain afterwards.\n\nHosts that leave `helloHandledByHost` unset never take this path: the gateway auto-dials and mints the code itself, and the first frame on the wire is the app's hello.\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\nIf you also mint claims host-side, implement [`tesseron/bind`](#the-tesseronbind-handshake) with constant-time comparison, the rate limit, and the close-on-unbound-frame rule. Skipping it while advertising `helloHandledByHost: true` leaves the app unreachable.\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## Bind subprotocol (host-minted claims)\n\nWhen the app minted its own claim code (`helloHandledByHost: true` in the manifest — see [Host-minted claims](/protocol/handshake/#host-minted-claims-and-the-bind-handshake)), the gateway carries a second subprotocol element on the upgrade:\n\n```http\nSec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.7Q4K-M2\n```\n\nThe code element is `tesseron-bind.` followed by the claim code, which must match `[A-Za-z0-9_-]{1,64}`. A request carrying **more than one** `tesseron-bind.` element is rejected outright: two codes in one header is a header-injection signal, not an ambiguity to resolve.\n\nThe host compares the code against its in-memory `hostMintedClaim.code` in constant time and answers on the upgrade, before any WebSocket frame is exchanged:\n\n| Condition | Response | Notes |\n|---|---|---|\n| No `tesseron-gateway` element | Socket destroyed, no HTTP response | Not a Tesseron dial. |\n| Host is in bind lockout | `429 Too Many Requests` | Distinguishable from a mismatch on purpose. |\n| Code does not match | `403 Forbidden` | Counts toward the rate limit. |\n| Claim already spent (`boundAgent !== null`) | `409 Conflict` | One-shot. Mint a fresh session. |\n| A valid bind is already in flight | `409 Conflict` | Closes the concurrent-bind race before `handleUpgrade` attaches. |\n| Malformed `tesseron-bind.` element | `400 Bad Request` | Body names the grammar violation. |\n| No bind element at all | `426 Upgrade Required` | A pre-1.2 gateway. See below. |\n| Valid bind, host already attached | Socket destroyed | Duplicate. |\n\nOnly the `426` deserves explanation. A host that minted its own claim has **already** answered the app's `tesseron/hello` with a synthesized welcome. A gateway that auto-dials without binding would produce a second welcome against a hello promise that has already resolved, so the host refuses the upgrade instead of corrupting the session. Hosts that do not set `helloHandledByHost` never reach this path and keep accepting plain `tesseron-gateway` dials.\n\nMismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout, and a successful bind resets the window.\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\nIf you also mint claims host-side, implement the [bind subprotocol](#bind-subprotocol-host-minted-claims) with all eight upgrade outcomes above, constant-time code comparison, and the rate limit. Skipping it while advertising `helloHandledByHost: true` leaves the app unreachable.\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## Bind subprotocol (host-minted claims)\n\nWhen the app minted its own claim code (`helloHandledByHost: true` in the manifest — see [Host-minted claims](/protocol/handshake/#host-minted-claims-and-the-bind-handshake)), the gateway carries a second subprotocol element on the upgrade:\n\n```http\nSec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.7Q4K-M2\n```\n\nThe code element is `tesseron-bind.` followed by the claim code, which must match `[A-Za-z0-9_-]{1,64}`. A request carrying **more than one** `tesseron-bind.` element is rejected outright: two codes in one header is a header-injection signal, not an ambiguity to resolve.\n\nThe host compares the code against its in-memory `hostMintedClaim.code` in constant time and answers on the upgrade, before any WebSocket frame is exchanged:\n\n| Condition | Response | Notes |\n|---|---|---|\n| No `tesseron-gateway` element | Socket destroyed, no HTTP response | Not a Tesseron dial. |\n| Host is in bind lockout | `429 Too Many Requests` | Distinguishable from a mismatch on purpose. |\n| Code does not match | `403 Forbidden` | Counts toward the rate limit. |\n| Claim already spent (`boundAgent !== null`) | `409 Conflict` | One-shot. Mint a fresh session. |\n| A valid bind is already in flight | `409 Conflict` | Closes the concurrent-bind race before `handleUpgrade` attaches. |\n| Malformed `tesseron-bind.` element | `400 Bad Request` | Body names the grammar violation. |\n| No bind element at all | `426 Upgrade Required` | A pre-1.2 gateway. See below. |\n| Valid bind, host already attached | Socket destroyed | Duplicate. |\n\nOnly the `426` deserves explanation. A host that minted its own claim has **already** answered the app's `tesseron/hello` with a synthesized welcome. A gateway that auto-dials without binding would produce a second welcome against a hello promise that has already resolved, so the host refuses the upgrade instead of corrupting the session. Hosts that do not set `helloHandledByHost` never reach this path and keep accepting plain `tesseron-gateway` dials.\n\nMismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout, and a successful bind resets the window.\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\nIf you also mint claims host-side, implement the [bind subprotocol](#bind-subprotocol-host-minted-claims) with all eight upgrade outcomes above, constant-time code comparison, and the rate limit. Skipping it while advertising `helloHandledByHost: true` leaves the app unreachable.\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| `tesseron/resume` | request | Rejoin a previously claimed session after a transport drop. Replaces hello on that path. See [Resume](/protocol/resume/). |\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| `tesseron/claimed` | notification | The pending claim code was consumed; carries the bound agent's identity. |\n| `tesseron/bind` | request | Host-minted instances only, and only over UDS. Presents the claim code before the session exists. See [the bind handshake](/protocol/handshake/#host-minted-claims-and-the-bind-handshake). |\n\nAnd the **response** to the `tesseron/hello` or `tesseron/resume` 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.2.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| `tesseron/resume` | request | Rejoin a previously claimed session after a transport drop. Replaces hello on that path. See [Resume](/protocol/resume/). |\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| `tesseron/claimed` | notification | The pending claim code was consumed; carries the bound agent's identity. |\n| `tesseron/bind` | request | Host-minted instances only, and only over UDS. Presents the claim code before the session exists. See [the bind handshake](/protocol/handshake/#host-minted-claims-and-the-bind-handshake). |\n\nAnd the **response** to the `tesseron/hello` or `tesseron/resume` 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.2.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/cpp/actions","title":"Actions (C++)","description":"Declaring actions, the Schema builder, the raw JSON Schema escape hatch, and how a handler fails.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/context","sdk/cpp/errors","sdk/cpp/resources","protocol/actions"],"bodyRaw":"\nAn action is a name, a declared input shape, and a coroutine. The builder chains from the host builder and `handler` is the terminal step, which hands the host builder back so the next action follows.\n\nThe canonical todo example registers `addTodo` like this:\n\n```cpp\nbuilder.action(\"addTodo\")\n .description(\"Add one todo\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"text\", tesseron::schema::string().min_length(1)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema(todo_output_schema())\n .handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n Todo todo = state->create(input.at(\"text\").get<std::string>(), tag.value());\n state->publish();\n co_return todo_payload(todo);\n });\n```\n\n`description` is what the agent reads when it decides whether to call this at all, so write it for a reader who has never seen your application. `timeout` overrides the gateway's 60-second default for this action only.\n\n## The handler\n\nA handler is a C++20 coroutine with this shape:\n\n```cpp\nboost::asio::awaitable<Result<Json>> addTodo(Json input, ActionContext context);\n```\n\nThe shipped examples use the same return type for every action handler. The coroutine runs on the host's I/O thread, so a `co_await` on a sample or an elicitation yields instead of blocking the read loop. A handler that blocks that thread stalls the whole session. Push real work onto your own executor and `co_await` the result.\n\n## Declaring input\n\n`Schema` answers both questions with one object: it emits the JSON Schema that goes in the manifest, and it validates the input at dispatch. The contract the agent reads is the contract the handler is protected by, so the two cannot drift apart.\n\nThe canonical `importTodos` action uses the builder for its object, array, and length constraints:\n\n```cpp\nbuilder.action(\"importTodos\")\n .description(\"Import several todos\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"items\", tesseron::schema::array(tesseron::schema::string()).min_items(1).max_items(50)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema({\n {\"type\", \"object\"},\n {\"properties\", {{\"added\", {{\"type\", \"integer\"}}}, {\"ids\", {{\"type\", \"array\"}, {\"items\", {{\"type\", \"string\"}}}}}}},\n {\"required\", {\"added\", \"ids\"}},\n })\n .handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n const Json& items = input.at(\"items\");\n Json identifiers = Json::array();\n for (std::size_t index = 0; index < items.size(); ++index) {\n Todo todo = state->create(items[index].get<std::string>(), tag.value());\n identifiers.push_back(todo.identifier);\n context.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n }\n state->publish();\n co_return Json{{\"added\", identifiers.size()}, {\"ids\", identifiers}};\n });\n```\n\n`min_length` and `max_length` count UTF-8 code points, not bytes, so a schema written for a human-visible field means what it looks like it means.\n\nInput that fails the schema never reaches the handler. The agent gets `-32004` with every issue at once, each carrying the path into the input:\n\n```json\n{\n \"code\": -32004,\n \"message\": \"Invalid input\",\n \"data\": [\n { \"message\": \"required property is missing\", \"path\": [\"sku\"] },\n { \"message\": \"expected type \\\"integer\\\", got string\", \"path\": [\"quantity\"] }\n ]\n}\n```\n\n### The raw escape hatch\n\nFor a shape the builder cannot express, pass the JSON Schema document and the check together:\n\n```cpp\nbuilder.action(\"query\")\n .input_schema(load_schema_document(), [](const Json& input) {\n return your_validator.check(input);\n })\n .handler(run_query);\n```\n\nThe validator is required. A schema nothing enforces is a promise to the agent that the handler does not keep, and the failure surfaces inside the handler instead of as a `-32004` the agent can act on.\n\n## Failing\n\n`ActionError` has three factory methods. The difference between them is what reaches the agent.\n\nA missing todo id in the shipped example returns `-32005 HandlerError` with structured data:\n\n```cpp\nco_return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, \"Todo not found\",\n Json{{\"kind\", \"not_found\"}});\n```\n\nUse `ActionError::handler(message)` for the same `-32005` code without custom data. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and structured detail. Use `ActionError::internal(source)` when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives `-32603 Internal error`. A handler that throws is treated the same way.\n\n## Cancellation\n\nThe gateway sends `actions/cancel`, and the host answers `-32001` immediately: it does not wait for the handler to notice. The stop token gives the handler a chance to stop doing the work.\n\nThe canonical import handler checks its progress loop and reports each item:\n\n```cpp\n.handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n const Json& items = input.at(\"items\");\n Json identifiers = Json::array();\n for (std::size_t index = 0; index < items.size(); ++index) {\n Todo todo = state->create(items[index].get<std::string>(), tag.value());\n identifiers.push_back(todo.identifier);\n context.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n }\n state->publish();\n co_return Json{{\"added\", identifiers.size()}, {\"ids\", identifiers}};\n});\n```\n\n`stop_token()`, `cancelled()`, and `wait_for_cancellation()` expose the same cancellation signal to the handler. Settlement is first-wins between the handler returning, cancellation arriving, and the timeout firing, so one request cannot receive two answers.\n","bodyText":"An action is a name, a declared input shape, and a coroutine. The builder chains from the host builder and `handler` is the terminal step, which hands the host builder back so the next action follows.\n\nThe canonical todo example registers `addTodo` like this:\n\n```cpp\nbuilder.action(\"addTodo\")\n .description(\"Add one todo\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"text\", tesseron::schema::string().min_length(1)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema(todo_output_schema())\n .handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n Todo todo = state->create(input.at(\"text\").get<std::string>(), tag.value());\n state->publish();\n co_return todo_payload(todo);\n });\n```\n\n`description` is what the agent reads when it decides whether to call this at all, so write it for a reader who has never seen your application. `timeout` overrides the gateway's 60-second default for this action only.\n\n## The handler\n\nA handler is a C++20 coroutine with this shape:\n\n```cpp\nboost::asio::awaitable<Result<Json>> addTodo(Json input, ActionContext context);\n```\n\nThe shipped examples use the same return type for every action handler. The coroutine runs on the host's I/O thread, so a `co_await` on a sample or an elicitation yields instead of blocking the read loop. A handler that blocks that thread stalls the whole session. Push real work onto your own executor and `co_await` the result.\n\n## Declaring input\n\n`Schema` answers both questions with one object: it emits the JSON Schema that goes in the manifest, and it validates the input at dispatch. The contract the agent reads is the contract the handler is protected by, so the two cannot drift apart.\n\nThe canonical `importTodos` action uses the builder for its object, array, and length constraints:\n\n```cpp\nbuilder.action(\"importTodos\")\n .description(\"Import several todos\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"items\", tesseron::schema::array(tesseron::schema::string()).min_items(1).max_items(50)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema({\n {\"type\", \"object\"},\n {\"properties\", {{\"added\", {{\"type\", \"integer\"}}}, {\"ids\", {{\"type\", \"array\"}, {\"items\", {{\"type\", \"string\"}}}}}}},\n {\"required\", {\"added\", \"ids\"}},\n })\n .handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n const Json& items = input.at(\"items\");\n Json identifiers = Json::array();\n for (std::size_t index = 0; index < items.size(); ++index) {\n Todo todo = state->create(items[index].get<std::string>(), tag.value());\n identifiers.push_back(todo.identifier);\n context.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n }\n state->publish();\n co_return Json{{\"added\", identifiers.size()}, {\"ids\", identifiers}};\n });\n```\n\n`min_length` and `max_length` count UTF-8 code points, not bytes, so a schema written for a human-visible field means what it looks like it means.\n\nInput that fails the schema never reaches the handler. The agent gets `-32004` with every issue at once, each carrying the path into the input:\n\n```json\n{\n \"code\": -32004,\n \"message\": \"Invalid input\",\n \"data\": [\n { \"message\": \"required property is missing\", \"path\": [\"sku\"] },\n { \"message\": \"expected type \\\"integer\\\", got string\", \"path\": [\"quantity\"] }\n ]\n}\n```\n\n### The raw escape hatch\n\nFor a shape the builder cannot express, pass the JSON Schema document and the check together:\n\n```cpp\nbuilder.action(\"query\")\n .input_schema(load_schema_document(), [](const Json& input) {\n return your_validator.check(input);\n })\n .handler(run_query);\n```\n\nThe validator is required. A schema nothing enforces is a promise to the agent that the handler does not keep, and the failure surfaces inside the handler instead of as a `-32004` the agent can act on.\n\n## Failing\n\n`ActionError` has three factory methods. The difference between them is what reaches the agent.\n\nA missing todo id in the shipped example returns `-32005 HandlerError` with structured data:\n\n```cpp\nco_return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, \"Todo not found\",\n Json{{\"kind\", \"not_found\"}});\n```\n\nUse `ActionError::handler(message)` for the same `-32005` code without custom data. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and structured detail. Use `ActionError::internal(source)` when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives `-32603 Internal error`. A handler that throws is treated the same way.\n\n## Cancellation\n\nThe gateway sends `actions/cancel`, and the host answers `-32001` immediately: it does not wait for the handler to notice. The stop token gives the handler a chance to stop doing the work.\n\nThe canonical import handler checks its progress loop and reports each item:\n\n```cpp\n.handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n const Json& items = input.at(\"items\");\n Json identifiers = Json::array();\n for (std::size_t index = 0; index < items.size(); ++index) {\n Todo todo = state->create(items[index].get<std::string>(), tag.value());\n identifiers.push_back(todo.identifier);\n context.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n }\n state->publish();\n co_return Json{{\"added\", identifiers.size()}, {\"ids\", identifiers}};\n});\n```\n\n`stop_token()`, `cancelled()`, and `wait_for_cancellation()` expose the same cancellation signal to the handler. Settlement is first-wins between the handler returning, cancellation arriving, and the timeout firing, so one request cannot receive two answers."},{"slug":"sdk/cpp/conformance","title":"Conformance (C++)","description":"What the C++ host passes against the language-neutral fixture corpus, and how to run it yourself.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/installation","sdk/cpp/errors","sdk/cpp/threading","sdk/porting","protocol/compatibility"],"bodyRaw":"\nThe [conformance corpus](https://github.com/Eigenwise/tesseron/tree/main/conformance) is language-neutral: a set of JSON fixtures and a Node runner that plays the gateway against whatever host you point it at. The C++ SDK ships a private adapter so it can be pointed at.\n\n## Where it stands\n\n**Every fixture passes except the ten it declares it cannot serve**, on Linux with clang and on Windows with MSVC. At the corpus this was last run against, that is 29 passed, 10 skipped, 0 failed.\n\nThose 10 are the nine `bind/*` fixtures, which need a host-minted claim code, plus `uds/file-mode`, which needs a unix domain socket. The C++ host speaks WebSocket only and takes gateway-minted claims only, and it says so:\n\n`TESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds`\n\nThat list is not cosmetic. The runner cross-checks it against the four capability flags in the host's `tesseron/hello`: for each known capability, the flag has to equal \"not in the unsupported list\". Declaring `streaming: true` while naming `streaming` unsupported fails the run, and so does the reverse. All four flags this SDK declares are `true`, and neither of the two things it leaves out is one of them.\n\n## Running it\n\nFrom the `tesseron-cpp` repository root:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_CONFORMANCE_HOST=ON -DTESSERON_BUILD_EXAMPLES=ON\ncmake --build build\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"./build/conformance-host/tesseron-conformance-host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. Both tags are required on Linux and Windows. The runner uses its bundled corpus; pass `--fixtures <path>` to test a hub checkout's current fixtures. The [example guide](https://github.com/Eigenwise/tesseron-cpp/tree/main/examples) covers running the todo and prompts executables with the gateway.\n\n## The adapter\n\n`TESSERON_BUILD_CONFORMANCE_HOST=ON` builds `tesseron-conformance-host`. It is deliberately never installed and never exported: it exists to be launched from the repository, and shipping it would put a test adapter in a consumer's package.\n\nThe runner starts one process per fixture with `TESSERON_CONFORMANCE_FIXTURE` pointing at the fixture document, waits for a single readiness line on stdout, then plays the gateway against the endpoint that line names.\n\n```\ntesseron-conformance-url=ws://127.0.0.1:52344/\n```\n\nOne line, flushed. Every diagnostic goes to stderr, because a second stdout line fails the fixture. Closing stdin is how the runner asks the process to shut down.\n\nThe adapter reads the fixture's `actions` and `resources` and registers them, applying each action's behaviours in the order the corpus fixes: refuse a call that was not meant to happen, wait to be cancelled, stream progress, confirm, elicit, then answer with the canned value.\n\nTwo things it refuses at launch rather than ignoring:\n\n- a fixture requiring `uds`, or declaring a `hostMintedClaim`, because this host has neither;\n- an `inputSchema` using a JSON Schema keyword the adapter cannot enforce.\n\nThat second one matters more than it looks. The adapter covers the keywords the corpus actually uses instead of pulling a full JSON Schema implementation into a test binary, and a fixture that would otherwise pass *because* a keyword was silently ignored fails the launch instead.\n\n## The unit suite\n\nSeparate from conformance, and cheaper to run while you work:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_TESTS=ON\ncmake --build build\nctest --test-dir build --output-on-failure\n```\n\nCatch2 v3, registered with CTest through `catch_discover_tests`. It covers JSON-RPC framing, the handshake state machine, the progress clamp, resource subscription teardown, and error mapping. The handshake and resource tests talk to a real host over a real loopback socket, because those behaviours only exist in terms of what crosses the wire.\n","bodyText":"The [conformance corpus](https://github.com/Eigenwise/tesseron/tree/main/conformance) is language-neutral: a set of JSON fixtures and a Node runner that plays the gateway against whatever host you point it at. The C++ SDK ships a private adapter so it can be pointed at.\n\n## Where it stands\n\n**Every fixture passes except the ten it declares it cannot serve**, on Linux with clang and on Windows with MSVC. At the corpus this was last run against, that is 29 passed, 10 skipped, 0 failed.\n\nThose 10 are the nine `bind/*` fixtures, which need a host-minted claim code, plus `uds/file-mode`, which needs a unix domain socket. The C++ host speaks WebSocket only and takes gateway-minted claims only, and it says so:\n\n`TESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds`\n\nThat list is not cosmetic. The runner cross-checks it against the four capability flags in the host's `tesseron/hello`: for each known capability, the flag has to equal \"not in the unsupported list\". Declaring `streaming: true` while naming `streaming` unsupported fails the run, and so does the reverse. All four flags this SDK declares are `true`, and neither of the two things it leaves out is one of them.\n\n## Running it\n\nFrom the `tesseron-cpp` repository root:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_CONFORMANCE_HOST=ON -DTESSERON_BUILD_EXAMPLES=ON\ncmake --build build\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"./build/conformance-host/tesseron-conformance-host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. Both tags are required on Linux and Windows. The runner uses its bundled corpus; pass `--fixtures <path>` to test a hub checkout's current fixtures. The [example guide](https://github.com/Eigenwise/tesseron-cpp/tree/main/examples) covers running the todo and prompts executables with the gateway.\n\n## The adapter\n\n`TESSERON_BUILD_CONFORMANCE_HOST=ON` builds `tesseron-conformance-host`. It is deliberately never installed and never exported: it exists to be launched from the repository, and shipping it would put a test adapter in a consumer's package.\n\nThe runner starts one process per fixture with `TESSERON_CONFORMANCE_FIXTURE` pointing at the fixture document, waits for a single readiness line on stdout, then plays the gateway against the endpoint that line names.\n\n```\ntesseron-conformance-url=ws://127.0.0.1:52344/\n```\n\nOne line, flushed. Every diagnostic goes to stderr, because a second stdout line fails the fixture. Closing stdin is how the runner asks the process to shut down.\n\nThe adapter reads the fixture's `actions` and `resources` and registers them, applying each action's behaviours in the order the corpus fixes: refuse a call that was not meant to happen, wait to be cancelled, stream progress, confirm, elicit, then answer with the canned value.\n\nTwo things it refuses at launch rather than ignoring:\n\n- a fixture requiring `uds`, or declaring a `hostMintedClaim`, because this host has neither;\n- an `inputSchema` using a JSON Schema keyword the adapter cannot enforce.\n\nThat second one matters more than it looks. The adapter covers the keywords the corpus actually uses instead of pulling a full JSON Schema implementation into a test binary, and a fixture that would otherwise pass *because* a keyword was silently ignored fails the launch instead.\n\n## The unit suite\n\nSeparate from conformance, and cheaper to run while you work:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_TESTS=ON\ncmake --build build\nctest --test-dir build --output-on-failure\n```\n\nCatch2 v3, registered with CTest through `catch_discover_tests`. It covers JSON-RPC framing, the handshake state machine, the progress clamp, resource subscription teardown, and error mapping. The handshake and resource tests talk to a real host over a real loopback socket, because those behaviours only exist in terms of what crosses the wire."},{"slug":"sdk/cpp/context","title":"ActionContext (C++)","description":"Progress, sampling, confirmation, elicitation, logging, and cancellation from a C++ handler.","section":"sdk","related":["sdk/cpp/actions","sdk/cpp/errors","sdk/cpp/threading","protocol/progress-cancellation","protocol/elicitation","protocol/sampling"],"bodyRaw":"\nEvery handler is called with `(Json input, ActionContext context)`. The context is cheap to copy and every copy talks to the same invocation, including the shared progress ceiling, so a handler can hand one to a helper without losing anything.\n\n## What it knows\n\n```cpp\ncontext.action_name(); // \"addTodo\"\ncontext.invocation_id(); // the gateway's id for this call\ncontext.agent(); // { id, name }; \"pending\" until the session is claimed\ncontext.origin(); // the application's origin\ncontext.route(); // where in the application the agent was, if the gateway said\ncontext.agent_capabilities(); // what the other end negotiated\n```\n\nCheck `agent_capabilities()` before `sample` or `elicit` whenever the handler has a useful non-interactive fallback. It saves a round trip that was always going to fail.\n\n## progress\n\nThe canonical C++ examples report one update for each imported item:\n\n```cpp\ncontext.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n```\n\nEvery field is optional; send whichever the handler actually knows. Fire-and-forget, like every notification.\n\nPercent is clamped into 0 to 100, and never allowed to fall below a value already sent for this invocation. An agent rendering a progress bar reads a backwards jump as a restart, so a lower value is raised to the ceiling rather than dropped. Two copies of the context handed to two helpers share one ceiling.\n\n## log\n\nThe prompts example logs before sampling:\n\n```cpp\ncontext.log(LogEntry::info(\"Testing prompt \" + identifier));\n```\n\nFour levels are available: `debug`, `info`, `warn`, and `error`. Logging is fire-and-forget.\n\n## sample\n\nAsks the agent's model to answer a prompt. The todo example supplies a schema and a token limit:\n\n```cpp\nSampleRequest request(\"Produce exactly \" + std::to_string(count) +\n \" concrete todo items for the theme \\\"\" + theme +\n \"\\\". Return JSON matching { items: string[] }. Items should be short, \"\n \"imperative, and user-friendly. No numbering.\");\nrequest.json_schema(suggested_todos_output_schema()).max_tokens(400);\nauto sampled = co_await context.sample(std::move(request));\nif (!sampled.ok()) co_return sampled.error();\n```\n\nSampling depth is not a field in any Tesseron frame. The gateway owns `maxSamplingDepth` and answers `-32008` itself, so the host forwards the request without counting.\n\n## confirm\n\nA yes-or-no gate in front of something destructive. The prompts example uses it before deleting a prompt:\n\n```cpp\nauto confirmation = co_await context.confirm(\"Delete prompt \\\"\" + prompt->second.name +\n \"\\\" (tested \" + std::to_string(prompt->second.times_tested) +\n \"x)? This cannot be undone.\");\nif (!confirmation.ok()) co_return confirmation.error();\nif (!confirmation.value()) {\n co_return Json{{\"id\", identifier}, {\"deleted\", false}, {\"cancelled\", true}};\n}\n```\n\n`true` only means explicit accept. A decline, a cancel, and an agent that never negotiated elicitation all answer `false`.\n\n## elicit\n\nAsks the user for structured content. The todo example asks for a replacement name:\n\n```cpp\nElicitRequest request(\"Rename \\\"\" + todo->text + \"\\\" to?\");\nrequest.json_schema({\n {\"type\", \"object\"},\n {\"properties\", {{\"newName\", {{\"type\", \"string\"}, {\"minLength\", 1}}}}},\n {\"required\", {\"newName\"}},\n});\nauto elicited = co_await context.elicit(std::move(request));\nif (!elicited.ok()) co_return elicited.error();\nif (!elicited.value().has_value()) {\n co_return Json{{\"id\", identifier}, {\"renamed\", false}, {\"cancelled\", true}};\n}\n```\n\nAn empty optional means a decline or a cancel. Unlike `confirm`, a missing capability is an error rather than a default, because structured content has no safe default and the handler has to branch on it.\n\nThe schema is checked against the [elicitation rules](/protocol/elicitation/) before the frame leaves. MCP renders an elicit prompt as a flat form, so the protocol constrains the schema to a single object of primitive leaves. A top-level `oneOf`, `anyOf`, `allOf`, or `not`, a top-level type other than `object`, or a property typed `object` or `array` all fail with `-32602` at the `elicit` call site.\n\n## Cancellation\n\n`stop_token()`, `cancelled()`, and `co_await wait_for_cancellation()` expose the same signal when the agent cancels, the invocation times out, or the transport closes. See [Actions](/sdk/cpp/actions/#cancellation).\n\nApplication-thread handoff is covered on the [Threading](/sdk/cpp/threading/) page.\n","bodyText":"Every handler is called with `(Json input, ActionContext context)`. The context is cheap to copy and every copy talks to the same invocation, including the shared progress ceiling, so a handler can hand one to a helper without losing anything.\n\n## What it knows\n\n```cpp\ncontext.action_name(); // \"addTodo\"\ncontext.invocation_id(); // the gateway's id for this call\ncontext.agent(); // { id, name }; \"pending\" until the session is claimed\ncontext.origin(); // the application's origin\ncontext.route(); // where in the application the agent was, if the gateway said\ncontext.agent_capabilities(); // what the other end negotiated\n```\n\nCheck `agent_capabilities()` before `sample` or `elicit` whenever the handler has a useful non-interactive fallback. It saves a round trip that was always going to fail.\n\n## progress\n\nThe canonical C++ examples report one update for each imported item:\n\n```cpp\ncontext.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n```\n\nEvery field is optional; send whichever the handler actually knows. Fire-and-forget, like every notification.\n\nPercent is clamped into 0 to 100, and never allowed to fall below a value already sent for this invocation. An agent rendering a progress bar reads a backwards jump as a restart, so a lower value is raised to the ceiling rather than dropped. Two copies of the context handed to two helpers share one ceiling.\n\n## log\n\nThe prompts example logs before sampling:\n\n```cpp\ncontext.log(LogEntry::info(\"Testing prompt \" + identifier));\n```\n\nFour levels are available: `debug`, `info`, `warn`, and `error`. Logging is fire-and-forget.\n\n## sample\n\nAsks the agent's model to answer a prompt. The todo example supplies a schema and a token limit:\n\n```cpp\nSampleRequest request(\"Produce exactly \" + std::to_string(count) +\n \" concrete todo items for the theme \\\"\" + theme +\n \"\\\". Return JSON matching { items: string[] }. Items should be short, \"\n \"imperative, and user-friendly. No numbering.\");\nrequest.json_schema(suggested_todos_output_schema()).max_tokens(400);\nauto sampled = co_await context.sample(std::move(request));\nif (!sampled.ok()) co_return sampled.error();\n```\n\nSampling depth is not a field in any Tesseron frame. The gateway owns `maxSamplingDepth` and answers `-32008` itself, so the host forwards the request without counting.\n\n## confirm\n\nA yes-or-no gate in front of something destructive. The prompts example uses it before deleting a prompt:\n\n```cpp\nauto confirmation = co_await context.confirm(\"Delete prompt \\\"\" + prompt->second.name +\n \"\\\" (tested \" + std::to_string(prompt->second.times_tested) +\n \"x)? This cannot be undone.\");\nif (!confirmation.ok()) co_return confirmation.error();\nif (!confirmation.value()) {\n co_return Json{{\"id\", identifier}, {\"deleted\", false}, {\"cancelled\", true}};\n}\n```\n\n`true` only means explicit accept. A decline, a cancel, and an agent that never negotiated elicitation all answer `false`.\n\n## elicit\n\nAsks the user for structured content. The todo example asks for a replacement name:\n\n```cpp\nElicitRequest request(\"Rename \\\"\" + todo->text + \"\\\" to?\");\nrequest.json_schema({\n {\"type\", \"object\"},\n {\"properties\", {{\"newName\", {{\"type\", \"string\"}, {\"minLength\", 1}}}}},\n {\"required\", {\"newName\"}},\n});\nauto elicited = co_await context.elicit(std::move(request));\nif (!elicited.ok()) co_return elicited.error();\nif (!elicited.value().has_value()) {\n co_return Json{{\"id\", identifier}, {\"renamed\", false}, {\"cancelled\", true}};\n}\n```\n\nAn empty optional means a decline or a cancel. Unlike `confirm`, a missing capability is an error rather than a default, because structured content has no safe default and the handler has to branch on it.\n\nThe schema is checked against the [elicitation rules](/protocol/elicitation/) before the frame leaves. MCP renders an elicit prompt as a flat form, so the protocol constrains the schema to a single object of primitive leaves. A top-level `oneOf`, `anyOf`, `allOf`, or `not`, a top-level type other than `object`, or a property typed `object` or `array` all fail with `-32602` at the `elicit` call site.\n\n## Cancellation\n\n`stop_token()`, `cancelled()`, and `co_await wait_for_cancellation()` expose the same signal when the agent cancels, the invocation times out, or the transport closes. See [Actions](/sdk/cpp/actions/#cancellation).\n\nApplication-thread handoff is covered on the [Threading](/sdk/cpp/threading/) page."},{"slug":"sdk/cpp/errors","title":"Errors (C++)","description":"The C++ error types, the complete protocol code set, and the envelope rules the host follows.","section":"sdk","related":["sdk/cpp/actions","sdk/cpp/context","sdk/cpp/index","protocol/errors","protocol/wire-format"],"bodyRaw":"\nThe C++ SDK keeps three error types separate. `HostError` means the application could not start or shut down. `ActionError` is what a handler returns when an invocation fails. `ProtocolError` is the JSON-RPC error object that crosses the connection.\n\n## Protocol error codes\n\n`TesseronErrorCode` is the closed set of protocol codes. `to_wire_code(...)` returns the JSON-RPC integer, and `from_wire_code(...)` returns `std::nullopt` for an integer this SDK does not define.\n\n| Code | Enumerator | When |\n| --- | --- | --- |\n| `-32700` | `ParseError` | The peer sent bytes that are not valid JSON. |\n| `-32600` | `InvalidRequest` | The envelope is not a valid JSON-RPC 2.0 message. |\n| `-32601` | `MethodNotFound` | The requested method is not part of the Tesseron protocol. |\n| `-32602` | `InvalidParams` | Method parameters do not match the documented shape, including an elicit schema MCP cannot render. |\n| `-32603` | `InternalError` | An unexpected failure occurred. Detail stays local. |\n| `-32000` | `ProtocolMismatch` | The host and gateway disagree on the protocol major version. |\n| `-32001` | `Cancelled` | The agent cancelled the invocation. |\n| `-32002` | `Timeout` | The invocation passed its action timeout. |\n| `-32003` | `ActionNotFound` | No action is registered under the requested name, or a resource is not readable or subscribable. |\n| `-32004` | `InputValidation` | The invocation input failed the action's declared schema. |\n| `-32005` | `HandlerError` | The handler reported a domain failure. |\n| `-32006` | `SamplingNotAvailable` | The agent did not negotiate sampling. |\n| `-32007` | `ElicitationNotAvailable` | The agent did not negotiate elicitation. |\n| `-32008` | `SamplingDepthExceeded` | The gateway's sampling-depth limit was exceeded. |\n| `-32009` | `Unauthorized` | The session is unclaimed or the operation is not permitted. |\n| `-32010` | `TransportClosed` | The transport closed while a request was in flight. |\n| `-32011` | `ResumeFailed` | The gateway refused the resume credentials. |\n\n## Result and ActionError\n\nHandlers return `boost::asio::awaitable<Result<Json>>`. Startup returns `Result<Host, HostError>`, and shutdown returns `Result<void, HostError>`. A handler can return a domain failure through `ActionError::handler(message)`, a chosen code through `ActionError::protocol(code, message, data)`, or a local cause through `ActionError::internal(source)`.\n\nThe todo example's helper returns `-32005 HandlerError` with data when an id is unknown:\n\n```cpp\nActionError todo_not_found() {\n return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, \"Todo not found\",\n Json{{\"kind\", \"not_found\"}});\n}\n```\n\nThe `toggleTodo` and `deleteTodo` handlers `co_return todo_not_found()` when their lookup reaches the end. Use `ActionError::handler(message)` for the same `-32005` code without custom data. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and structured detail. Use `ActionError::internal(source)` when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives `-32603 Internal error`. A handler that throws is treated the same way.\n\n## ProtocolError\n\n`ProtocolError` represents the JSON-RPC `error` member with an integer code, a message, and optional JSON data. The integer stays available even when it is outside `TesseronErrorCode`, so a newer gateway's code can round-trip. `named_code()` returns `std::nullopt` for that unknown integer.\n\nConstruct it with either a `TesseronErrorCode` or a raw integer. `with_data(...)` attaches structured detail, `to_json()` makes the wire payload, and `from_json(...)` reads one when the shape is valid.\n\n## Envelope errors\n\nThe host follows the [wire-format rules](/protocol/wire-format/) for request IDs. An `id: null` member still marks a request, and its response carries `id: null`. Only an absent `id` makes a notification, so a notification receives no response.\n\nA frame without `jsonrpc: \"2.0\"` receives `-32600 InvalidRequest`. The host carries through a usable string, number, or null id, and uses `null` when there is no usable id. The session stays up after this response and can process the next frame.\n\n## HostError\n\nThese errors happen before an invocation reaches a handler:\n\n| Kind | When |\n| --- | --- |\n| `MissingApplication` | No application was registered before `listen()`. |\n| `InvalidApplicationId` | The application id is reserved or fails `^[a-z][a-z0-9_]*$`. |\n| `DuplicateName` | Two actions or two resources use the same name. |\n| `NonLoopbackBindAddress` | `bind_address` was given a non-loopback address. |\n| `Listen` | The loopback listener could not bind. |\n| `Manifest` | The instance manifest could not be written or removed. |\n| `HomeDirectoryUnknown` | The home directory for `~/.tesseron` could not be resolved. |\n\n`listen()` refuses a non-loopback address before binding. `shutdown()` reports manifest removal failures through `Result<void, HostError>`.\n","bodyText":"The C++ SDK keeps three error types separate. `HostError` means the application could not start or shut down. `ActionError` is what a handler returns when an invocation fails. `ProtocolError` is the JSON-RPC error object that crosses the connection.\n\n## Protocol error codes\n\n`TesseronErrorCode` is the closed set of protocol codes. `to_wire_code(...)` returns the JSON-RPC integer, and `from_wire_code(...)` returns `std::nullopt` for an integer this SDK does not define.\n\n| Code | Enumerator | When |\n| --- | --- | --- |\n| `-32700` | `ParseError` | The peer sent bytes that are not valid JSON. |\n| `-32600` | `InvalidRequest` | The envelope is not a valid JSON-RPC 2.0 message. |\n| `-32601` | `MethodNotFound` | The requested method is not part of the Tesseron protocol. |\n| `-32602` | `InvalidParams` | Method parameters do not match the documented shape, including an elicit schema MCP cannot render. |\n| `-32603` | `InternalError` | An unexpected failure occurred. Detail stays local. |\n| `-32000` | `ProtocolMismatch` | The host and gateway disagree on the protocol major version. |\n| `-32001` | `Cancelled` | The agent cancelled the invocation. |\n| `-32002` | `Timeout` | The invocation passed its action timeout. |\n| `-32003` | `ActionNotFound` | No action is registered under the requested name, or a resource is not readable or subscribable. |\n| `-32004` | `InputValidation` | The invocation input failed the action's declared schema. |\n| `-32005` | `HandlerError` | The handler reported a domain failure. |\n| `-32006` | `SamplingNotAvailable` | The agent did not negotiate sampling. |\n| `-32007` | `ElicitationNotAvailable` | The agent did not negotiate elicitation. |\n| `-32008` | `SamplingDepthExceeded` | The gateway's sampling-depth limit was exceeded. |\n| `-32009` | `Unauthorized` | The session is unclaimed or the operation is not permitted. |\n| `-32010` | `TransportClosed` | The transport closed while a request was in flight. |\n| `-32011` | `ResumeFailed` | The gateway refused the resume credentials. |\n\n## Result and ActionError\n\nHandlers return `boost::asio::awaitable<Result<Json>>`. Startup returns `Result<Host, HostError>`, and shutdown returns `Result<void, HostError>`. A handler can return a domain failure through `ActionError::handler(message)`, a chosen code through `ActionError::protocol(code, message, data)`, or a local cause through `ActionError::internal(source)`.\n\nThe todo example's helper returns `-32005 HandlerError` with data when an id is unknown:\n\n```cpp\nActionError todo_not_found() {\n return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, \"Todo not found\",\n Json{{\"kind\", \"not_found\"}});\n}\n```\n\nThe `toggleTodo` and `deleteTodo` handlers `co_return todo_not_found()` when their lookup reaches the end. Use `ActionError::handler(message)` for the same `-32005` code without custom data. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and structured detail. Use `ActionError::internal(source)` when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives `-32603 Internal error`. A handler that throws is treated the same way.\n\n## ProtocolError\n\n`ProtocolError` represents the JSON-RPC `error` member with an integer code, a message, and optional JSON data. The integer stays available even when it is outside `TesseronErrorCode`, so a newer gateway's code can round-trip. `named_code()` returns `std::nullopt` for that unknown integer.\n\nConstruct it with either a `TesseronErrorCode` or a raw integer. `with_data(...)` attaches structured detail, `to_json()` makes the wire payload, and `from_json(...)` reads one when the shape is valid.\n\n## Envelope errors\n\nThe host follows the [wire-format rules](/protocol/wire-format/) for request IDs. An `id: null` member still marks a request, and its response carries `id: null`. Only an absent `id` makes a notification, so a notification receives no response.\n\nA frame without `jsonrpc: \"2.0\"` receives `-32600 InvalidRequest`. The host carries through a usable string, number, or null id, and uses `null` when there is no usable id. The session stays up after this response and can process the next frame.\n\n## HostError\n\nThese errors happen before an invocation reaches a handler:\n\n| Kind | When |\n| --- | --- |\n| `MissingApplication` | No application was registered before `listen()`. |\n| `InvalidApplicationId` | The application id is reserved or fails `^[a-z][a-z0-9_]*$`. |\n| `DuplicateName` | Two actions or two resources use the same name. |\n| `NonLoopbackBindAddress` | `bind_address` was given a non-loopback address. |\n| `Listen` | The loopback listener could not bind. |\n| `Manifest` | The instance manifest could not be written or removed. |\n| `HomeDirectoryUnknown` | The home directory for `~/.tesseron` could not be resolved. |\n\n`listen()` refuses a non-loopback address before binding. `shutdown()` reports manifest removal failures through `Result<void, HostError>`."},{"slug":"sdk/cpp/index","title":"C++ SDK","description":"What the C++ implementation of the Tesseron host covers, and the shape of a host built with it.","section":"sdk","related":["sdk/cpp/installation","sdk/cpp/actions","sdk/cpp/errors","sdk/cpp/threading","sdk/index","protocol/compatibility"],"bodyRaw":"\nSource: [github.com/Eigenwise/tesseron-cpp](https://github.com/Eigenwise/tesseron-cpp)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-cpp)\n\nThe C++ SDK lives in `tesseron-cpp` and builds one static library, `tesseron::tesseron`. Your application binds a loopback WebSocket, writes an instance manifest, and the MCP gateway dials *in*. There is no port to configure and no gateway address to point at.\n\nConsume it from source through CMake's `FetchContent`, linking `tesseron::tesseron`. See [Install & build](/sdk/cpp/installation/) for the declaration. The SDK also fetches its own dependencies.\n\n## What it covers\n\nThe whole host half of protocol 1.2.0:\n\n- The handshake, claiming, and session resume with token rotation.\n- Action invocation with input validation, cancellation, and a per-action timeout.\n- Streaming progress, clamped and monotonic.\n- Resource reads and subscriptions, with a teardown that runs on unsubscribe and on a closing transport.\n- `ActionContext` round trips back into the agent: `sample`, `confirm`, `elicit`, and `log`.\n- The v2 instance manifest, written after the URL is known and removed on shutdown, `0700` on its directory and `0600` on the file where the platform has them.\n\nAll four `Capabilities` flags are declared. [Host-minted claim codes](/protocol/handshake/) are the one thing left out: the gateway mints the code, and a restarted process is a new session.\n\n## A host, end to end\n\nThe shipped todo example registers the same camelCase action names used by the other SDK examples:\n\n```cpp\nvoid register_actions(tesseron::HostBuilder& builder, const std::shared_ptr<TodoState>& state) {\n builder.action(\"addTodo\")\n .description(\"Add one todo\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"text\", tesseron::schema::string().min_length(1)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema(todo_output_schema())\n .handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n Todo todo = state->create(input.at(\"text\").get<std::string>(), tag.value());\n state->publish();\n co_return todo_payload(todo);\n });\n}\n```\n\nA complete app creates a `HostBuilder`, registers the actions and resources, then calls `listen()`:\n\n```cpp\nauto state = std::make_shared<TodoState>();\nauto builder = tesseron::Host::builder();\nbuilder.application(\"cpp_todo\", \"C++ Todo\");\nbuilder.on_event([](const tesseron::HostEvent& event) {\n if (event.kind == tesseron::HostEvent::Kind::Welcome && event.welcome.has_value() &&\n event.welcome->claim_code.has_value()) {\n std::cout << \"Claim code: \" << *event.welcome->claim_code << std::endl;\n }\n});\nregister_actions(builder, state);\nregister_resource(builder, state);\n\nauto listening = builder.listen();\nif (!listening.ok()) {\n std::cerr << \"tesseron-example-todo: \" << listening.error().message() << \"\\n\";\n return 1;\n}\nauto host = std::move(listening).value();\n```\n\nRegister the event listener before `listen()`. The gateway can dial and finish the handshake before `listen()` returns, and a listener installed afterwards misses the welcome that carries the claim code.\n\n## Run the examples\n\nFrom the `tesseron-cpp` repository root, configure and build the two shipped apps:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_EXAMPLES=ON\ncmake --build build --target tesseron-example-todo tesseron-example-prompts\n```\n\nRun the built examples with the gateway installed, then claim the printed code from your MCP client. The [example guide](https://github.com/Eigenwise/tesseron-cpp/tree/main/examples) covers both apps.\n\n## Three things that will surprise you\n\n**Boost.Asio is a public dependency.** A handler is a C++20 coroutine returning `boost::asio::awaitable<tesseron::Result<tesseron::Json>>`, so anything that links this library sees Asio's headers. That is deliberate: a hand-rolled coroutine type would have to be re-taught every executor, timer, and cancellation trick Asio already knows, and would not compose with the Asio code a real application already has. Boost.Beast, which implements the WebSocket listener, stays private.\n\n**Nothing throws across a handler boundary.** Every fallible call answers `Result<T>` or `Result<T, HostError>`, so the error type is part of the signature instead of something a caller has to guess at. A handler that throws anyway is caught and answered as `-32603` with the cause logged locally, never sent.\n\n**The host binds loopback only.** `HostOptions::bind_address` set to anything outside `127.0.0.0/8` or `::1` is a `HostError` before a socket opens. The gateway runs on the same machine by design; there is no configuration that turns this into a network service.\n\n## Threading\n\nOne host owns one `boost::asio::io_context` and one thread to run it. Every handler, reader, and subscriber runs on that thread, so a `co_await` yields rather than blocking the read loop. See [Threading](/sdk/cpp/threading/) for the application dispatcher and UI handoff.\n\n`ResourceEmitter::emit` is safe from any thread and hops onto the host's thread before touching the subscription. `ActionContext::on_application_thread` hands work to the application dispatcher and resumes the handler on the host's thread.\n","bodyText":"Source: [github.com/Eigenwise/tesseron-cpp](https://github.com/Eigenwise/tesseron-cpp)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-cpp)\n\nThe C++ SDK lives in `tesseron-cpp` and builds one static library, `tesseron::tesseron`. Your application binds a loopback WebSocket, writes an instance manifest, and the MCP gateway dials *in*. There is no port to configure and no gateway address to point at.\n\nConsume it from source through CMake's `FetchContent`, linking `tesseron::tesseron`. See [Install & build](/sdk/cpp/installation/) for the declaration. The SDK also fetches its own dependencies.\n\n## What it covers\n\nThe whole host half of protocol 1.2.0:\n\n- The handshake, claiming, and session resume with token rotation.\n- Action invocation with input validation, cancellation, and a per-action timeout.\n- Streaming progress, clamped and monotonic.\n- Resource reads and subscriptions, with a teardown that runs on unsubscribe and on a closing transport.\n- `ActionContext` round trips back into the agent: `sample`, `confirm`, `elicit`, and `log`.\n- The v2 instance manifest, written after the URL is known and removed on shutdown, `0700` on its directory and `0600` on the file where the platform has them.\n\nAll four `Capabilities` flags are declared. [Host-minted claim codes](/protocol/handshake/) are the one thing left out: the gateway mints the code, and a restarted process is a new session.\n\n## A host, end to end\n\nThe shipped todo example registers the same camelCase action names used by the other SDK examples:\n\n```cpp\nvoid register_actions(tesseron::HostBuilder& builder, const std::shared_ptr<TodoState>& state) {\n builder.action(\"addTodo\")\n .description(\"Add one todo\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"text\", tesseron::schema::string().min_length(1)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema(todo_output_schema())\n .handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n Todo todo = state->create(input.at(\"text\").get<std::string>(), tag.value());\n state->publish();\n co_return todo_payload(todo);\n });\n}\n```\n\nA complete app creates a `HostBuilder`, registers the actions and resources, then calls `listen()`:\n\n```cpp\nauto state = std::make_shared<TodoState>();\nauto builder = tesseron::Host::builder();\nbuilder.application(\"cpp_todo\", \"C++ Todo\");\nbuilder.on_event([](const tesseron::HostEvent& event) {\n if (event.kind == tesseron::HostEvent::Kind::Welcome && event.welcome.has_value() &&\n event.welcome->claim_code.has_value()) {\n std::cout << \"Claim code: \" << *event.welcome->claim_code << std::endl;\n }\n});\nregister_actions(builder, state);\nregister_resource(builder, state);\n\nauto listening = builder.listen();\nif (!listening.ok()) {\n std::cerr << \"tesseron-example-todo: \" << listening.error().message() << \"\\n\";\n return 1;\n}\nauto host = std::move(listening).value();\n```\n\nRegister the event listener before `listen()`. The gateway can dial and finish the handshake before `listen()` returns, and a listener installed afterwards misses the welcome that carries the claim code.\n\n## Run the examples\n\nFrom the `tesseron-cpp` repository root, configure and build the two shipped apps:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_EXAMPLES=ON\ncmake --build build --target tesseron-example-todo tesseron-example-prompts\n```\n\nRun the built examples with the gateway installed, then claim the printed code from your MCP client. The [example guide](https://github.com/Eigenwise/tesseron-cpp/tree/main/examples) covers both apps.\n\n## Three things that will surprise you\n\n**Boost.Asio is a public dependency.** A handler is a C++20 coroutine returning `boost::asio::awaitable<tesseron::Result<tesseron::Json>>`, so anything that links this library sees Asio's headers. That is deliberate: a hand-rolled coroutine type would have to be re-taught every executor, timer, and cancellation trick Asio already knows, and would not compose with the Asio code a real application already has. Boost.Beast, which implements the WebSocket listener, stays private.\n\n**Nothing throws across a handler boundary.** Every fallible call answers `Result<T>` or `Result<T, HostError>`, so the error type is part of the signature instead of something a caller has to guess at. A handler that throws anyway is caught and answered as `-32603` with the cause logged locally, never sent.\n\n**The host binds loopback only.** `HostOptions::bind_address` set to anything outside `127.0.0.0/8` or `::1` is a `HostError` before a socket opens. The gateway runs on the same machine by design; there is no configuration that turns this into a network service.\n\n## Threading\n\nOne host owns one `boost::asio::io_context` and one thread to run it. Every handler, reader, and subscriber runs on that thread, so a `co_await` yields rather than blocking the read loop. See [Threading](/sdk/cpp/threading/) for the application dispatcher and UI handoff.\n\n`ResourceEmitter::emit` is safe from any thread and hops onto the host's thread before touching the subscription. `ActionContext::on_application_thread` hands work to the application dispatcher and resumes the handler on the host's thread."},{"slug":"sdk/cpp/installation","title":"Install & build (C++)","description":"Consuming the C++ SDK through CMake FetchContent, the toolchains it is checked on, and the compiler flags it sets for you.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/actions","sdk/cpp/errors","sdk/cpp/threading","sdk/cpp/conformance"],"bodyRaw":"\nCMake 3.24 or newer, and a compiler with C++20 coroutines. Everything else the SDK needs it fetches itself.\n\n## Adding it to your project\n\n```cmake\ninclude(FetchContent)\nFetchContent_Declare(\n tesseron\n GIT_REPOSITORY https://github.com/Eigenwise/tesseron-cpp.git\n GIT_TAG main)\nFetchContent_MakeAvailable(tesseron)\n\ntarget_link_libraries(your_app PRIVATE tesseron::tesseron)\n```\n\nThe CMake project is at the repository root. Pin `GIT_TAG` to a reviewed commit hash for a reproducible build.\n\n## What it pulls in\n\n| Dependency | Visibility | Why |\n|---|---|---|\n| Boost.Asio (>= 1.85) | public | handlers return `boost::asio::awaitable<...>` |\n| nlohmann/json | public | `tesseron::Json` is `nlohmann::json` |\n| Boost.Beast | private | the WebSocket listener |\n| Catch2 v3 | tests only | fetched only when `TESSERON_BUILD_TESTS=ON` |\n\nEvery one arrives through `FetchContent` at a pinned version *and* a pinned SHA-256. No vcpkg, no Conan, no system packages: a clean checkout builds with nothing but a compiler, CMake, and a network connection.\n\n`BOOST_INCLUDE_LIBRARIES` is limited to `asio` and `beast`, so this is not a full Boost build. The first configure still compiles Boost.Context, Boost.Container and Boost.Date_Time from source, which takes a couple of minutes; everything after that is incremental.\n\nIf your project already has its own Boost, declare it before `FetchContent_MakeAvailable(tesseron)` and the SDK will use yours.\n\n## Options\n\n| Option | Default | What it does |\n|---|---|---|\n| `TESSERON_BUILD_TESTS` | `OFF` | builds the Catch2 suite and registers it with CTest |\n| `TESSERON_BUILD_CONFORMANCE_HOST` | `OFF` | builds the fixture adapter the conformance runner drives |\n| `TESSERON_BUILD_EXAMPLES` | `OFF` | builds the canonical headless todo and prompts examples |\n| `TESSERON_INSTALL` | on when top-level | generates install and export rules |\n\n## Building the SDK itself\n\nRun these commands from the `tesseron-cpp` repository root.\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_TESTS=ON\ncmake --build build\nctest --test-dir build --output-on-failure\n```\n\n## Toolchains\n\nCI builds ubuntu-latest with clang and windows-latest with MSVC (through `ilammy/msvc-dev-cmd`), both on Ninja, and runs the full conformance suite on each. Local development of this SDK was done on Windows 11 with clang 22.1.0 targeting `x86_64-pc-windows-msvc`, CMake 4.2.1 and Ninja 1.13.2.\n\nThree compiler settings are attached to the library target rather than left to you, because they have to hold in every consumer too:\n\n- **`_WIN32_WINNT=0x0A00`** on Windows. Asio reads it to pick its I/O completion API, and it has to be defined before any Asio header is included, in your translation units as well as the SDK's.\n- **`/bigobj`** on MSVC. Beast's templates blow past the default object-section limit.\n- **`/Zc:__cplusplus`** on MSVC. Without it the compiler reports C++98 in `__cplusplus`, and header feature checks quietly fall back to pre-C++20 paths.\n\n## Installing it\n\n```bash\ncmake -S . -B build -DCMAKE_INSTALL_PREFIX=/your/prefix\ncmake --build build --target install\n```\n\nThat writes a `tesseron-config.cmake`, so a consumer can `find_package(tesseron)` instead of fetching sources. An installed `tesseron` does not carry Boost or nlohmann/json with it: the config file resolves those through `find_dependency`, the way it would for any other shared dependency. The conformance host is deliberately neither installed nor exported.\n","bodyText":"CMake 3.24 or newer, and a compiler with C++20 coroutines. Everything else the SDK needs it fetches itself.\n\n## Adding it to your project\n\n```cmake\ninclude(FetchContent)\nFetchContent_Declare(\n tesseron\n GIT_REPOSITORY https://github.com/Eigenwise/tesseron-cpp.git\n GIT_TAG main)\nFetchContent_MakeAvailable(tesseron)\n\ntarget_link_libraries(your_app PRIVATE tesseron::tesseron)\n```\n\nThe CMake project is at the repository root. Pin `GIT_TAG` to a reviewed commit hash for a reproducible build.\n\n## What it pulls in\n\n| Dependency | Visibility | Why |\n|---|---|---|\n| Boost.Asio (>= 1.85) | public | handlers return `boost::asio::awaitable<...>` |\n| nlohmann/json | public | `tesseron::Json` is `nlohmann::json` |\n| Boost.Beast | private | the WebSocket listener |\n| Catch2 v3 | tests only | fetched only when `TESSERON_BUILD_TESTS=ON` |\n\nEvery one arrives through `FetchContent` at a pinned version *and* a pinned SHA-256. No vcpkg, no Conan, no system packages: a clean checkout builds with nothing but a compiler, CMake, and a network connection.\n\n`BOOST_INCLUDE_LIBRARIES` is limited to `asio` and `beast`, so this is not a full Boost build. The first configure still compiles Boost.Context, Boost.Container and Boost.Date_Time from source, which takes a couple of minutes; everything after that is incremental.\n\nIf your project already has its own Boost, declare it before `FetchContent_MakeAvailable(tesseron)` and the SDK will use yours.\n\n## Options\n\n| Option | Default | What it does |\n|---|---|---|\n| `TESSERON_BUILD_TESTS` | `OFF` | builds the Catch2 suite and registers it with CTest |\n| `TESSERON_BUILD_CONFORMANCE_HOST` | `OFF` | builds the fixture adapter the conformance runner drives |\n| `TESSERON_BUILD_EXAMPLES` | `OFF` | builds the canonical headless todo and prompts examples |\n| `TESSERON_INSTALL` | on when top-level | generates install and export rules |\n\n## Building the SDK itself\n\nRun these commands from the `tesseron-cpp` repository root.\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_TESTS=ON\ncmake --build build\nctest --test-dir build --output-on-failure\n```\n\n## Toolchains\n\nCI builds ubuntu-latest with clang and windows-latest with MSVC (through `ilammy/msvc-dev-cmd`), both on Ninja, and runs the full conformance suite on each. Local development of this SDK was done on Windows 11 with clang 22.1.0 targeting `x86_64-pc-windows-msvc`, CMake 4.2.1 and Ninja 1.13.2.\n\nThree compiler settings are attached to the library target rather than left to you, because they have to hold in every consumer too:\n\n- **`_WIN32_WINNT=0x0A00`** on Windows. Asio reads it to pick its I/O completion API, and it has to be defined before any Asio header is included, in your translation units as well as the SDK's.\n- **`/bigobj`** on MSVC. Beast's templates blow past the default object-section limit.\n- **`/Zc:__cplusplus`** on MSVC. Without it the compiler reports C++98 in `__cplusplus`, and header feature checks quietly fall back to pre-C++20 paths.\n\n## Installing it\n\n```bash\ncmake -S . -B build -DCMAKE_INSTALL_PREFIX=/your/prefix\ncmake --build build --target install\n```\n\nThat writes a `tesseron-config.cmake`, so a consumer can `find_package(tesseron)` instead of fetching sources. An installed `tesseron` does not carry Boost or nlohmann/json with it: the config file resolves those through `find_dependency`, the way it would for any other shared dependency. The conformance host is deliberately neither installed nor exported."},{"slug":"sdk/cpp/resources","title":"Resources (C++)","description":"Readers, subscribers, the emitter, and teardown for the C++ SDK.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/actions","sdk/cpp/context","sdk/cpp/errors","protocol/resources"],"bodyRaw":"\nA resource is a named value the agent can read, and optionally subscribe to. `reader` is the terminal step of the builder, so `description` and `subscribe` come before it.\n\nThe todo example publishes the resource URI `todos://all`:\n\n```cpp\nbuilder.resource(\"todos://all\")\n .description(\"The complete todo list. Pushed on every mutation.\")\n .subscribe([state](tesseron::ResourceEmitter emitter) {\n state->subscribers.push_back(std::move(emitter));\n return tesseron::Subscription::without_teardown();\n })\n .reader([state]() -> boost::asio::awaitable<Result<Json>> { co_return todo_list_payload(state->todos); });\n```\n\n## The reader\n\nA reader returns the current value for every `resources/read`:\n\n```cpp\n.reader([state]() -> boost::asio::awaitable<Result<Json>> {\n co_return todo_list_payload(state->todos);\n});\n```\n\nIt runs on the host's I/O thread. A reader that fails answers an `ActionError` the same way a handler does. A reader that throws is caught and answered as `-32603`.\n\n## Subscribing\n\nRegistering a subscriber declares the resource `subscribable: true` in the manifest. Leave it off and a `resources/subscribe` for that name is answered `-32003`, the same answer an undeclared resource gets.\n\nThe subscriber starts pushing and hands back the thing that stops it:\n\n```cpp\n.subscribe([state](tesseron::ResourceEmitter emitter) {\n state->subscribers.push_back(std::move(emitter));\n return tesseron::Subscription::without_teardown();\n})\n```\n\n`Subscription::with_teardown(...)` is for a subscriber that registered an application listener. `Subscription::without_teardown()` is for a subscriber that registered nothing needing cleanup.\n\nThe acknowledgement goes out before the subscriber runs, so a value the subscriber emits immediately cannot overtake the response the agent is still waiting on. The acknowledgement is `result: null`, both for subscribe and unsubscribe.\n\n## The emitter\n\nOne emitter belongs to one `resources/subscribe`, so the subscription id is already baked in. Copying is cheap and every copy pushes to the same subscriber, which lets a subscriber hand one to another thread.\n\n`emit` is safe from any thread. It hops onto the host's own thread before touching the subscription, which is also where subscription liveness can be read without racing an unsubscribe. It is fire-and-forget: a value emitted after the agent unsubscribed, or after the transport closed, is dropped rather than queued.\n\n## Teardown\n\nThe teardown runs on `resources/unsubscribe` and when the transport closes, whichever comes first. Write it so it is safe when nothing is listening any more, and let it own everything the subscriber registered.\n\n## Reading it back\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": { \"value\": [] }\n}\n```\n\n`resources/updated` is a notification with no `id`:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub-1\", \"value\": [] }\n}\n```\n","bodyText":"A resource is a named value the agent can read, and optionally subscribe to. `reader` is the terminal step of the builder, so `description` and `subscribe` come before it.\n\nThe todo example publishes the resource URI `todos://all`:\n\n```cpp\nbuilder.resource(\"todos://all\")\n .description(\"The complete todo list. Pushed on every mutation.\")\n .subscribe([state](tesseron::ResourceEmitter emitter) {\n state->subscribers.push_back(std::move(emitter));\n return tesseron::Subscription::without_teardown();\n })\n .reader([state]() -> boost::asio::awaitable<Result<Json>> { co_return todo_list_payload(state->todos); });\n```\n\n## The reader\n\nA reader returns the current value for every `resources/read`:\n\n```cpp\n.reader([state]() -> boost::asio::awaitable<Result<Json>> {\n co_return todo_list_payload(state->todos);\n});\n```\n\nIt runs on the host's I/O thread. A reader that fails answers an `ActionError` the same way a handler does. A reader that throws is caught and answered as `-32603`.\n\n## Subscribing\n\nRegistering a subscriber declares the resource `subscribable: true` in the manifest. Leave it off and a `resources/subscribe` for that name is answered `-32003`, the same answer an undeclared resource gets.\n\nThe subscriber starts pushing and hands back the thing that stops it:\n\n```cpp\n.subscribe([state](tesseron::ResourceEmitter emitter) {\n state->subscribers.push_back(std::move(emitter));\n return tesseron::Subscription::without_teardown();\n})\n```\n\n`Subscription::with_teardown(...)` is for a subscriber that registered an application listener. `Subscription::without_teardown()` is for a subscriber that registered nothing needing cleanup.\n\nThe acknowledgement goes out before the subscriber runs, so a value the subscriber emits immediately cannot overtake the response the agent is still waiting on. The acknowledgement is `result: null`, both for subscribe and unsubscribe.\n\n## The emitter\n\nOne emitter belongs to one `resources/subscribe`, so the subscription id is already baked in. Copying is cheap and every copy pushes to the same subscriber, which lets a subscriber hand one to another thread.\n\n`emit` is safe from any thread. It hops onto the host's own thread before touching the subscription, which is also where subscription liveness can be read without racing an unsubscribe. It is fire-and-forget: a value emitted after the agent unsubscribed, or after the transport closed, is dropped rather than queued.\n\n## Teardown\n\nThe teardown runs on `resources/unsubscribe` and when the transport closes, whichever comes first. Write it so it is safe when nothing is listening any more, and let it own everything the subscriber registered.\n\n## Reading it back\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": { \"value\": [] }\n}\n```\n\n`resources/updated` is a notification with no `id`:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub-1\", \"value\": [] }\n}\n```"},{"slug":"sdk/cpp/threading","title":"Threading (C++)","description":"The C++ host I/O thread, coroutine handlers, and handing UI work to an application's own thread.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/actions","sdk/cpp/context","sdk/cpp/installation"],"bodyRaw":"\nA `Host` owns one `boost::asio::io_context` and one thread that runs it. `HostBuilder::listen()` starts accepting the gateway on that thread. The host's socket reads, JSON-RPC dispatch, `HostBuilder::on_event` callback, action handlers, resource readers, and subscription callbacks all run there.\n\nHandlers return `boost::asio::awaitable<Result<Json>>`. `co_await` yields the host thread while a sampling or elicitation request is in flight. Blocking that thread blocks the session and every other handler on the host.\n\n`ResourceEmitter::emit` is the exception to the caller's thread rule. It is safe to call from any thread and posts the update onto the host's `io_context`; values emitted after unsubscribe or transport close are dropped.\n\n## Application dispatcher\n\n`HostOptions::application_dispatcher` is optional and has one job: it receives a `std::function<void()>` that the SDK wants to run on the application's own thread.\n\n```cpp\ntesseron::HostOptions options;\noptions.application_dispatcher = [](std::function<void()> work) {\n your_toolkit::post_to_main_loop(std::move(work));\n};\nbuilder.options(std::move(options));\n```\n\nThe handler calls `co_await context.on_application_thread(...)`. The dispatcher runs the callback on the UI toolkit's thread, then the handler resumes on the host's I/O thread. Keep the callback small and copy the value the handler needs back into state it owns.\n\nWhen the dispatcher is unset, `on_application_thread(...)` runs the callback inline on the host's I/O thread. That is the right default for a headless app with no second thread.\n","bodyText":"A `Host` owns one `boost::asio::io_context` and one thread that runs it. `HostBuilder::listen()` starts accepting the gateway on that thread. The host's socket reads, JSON-RPC dispatch, `HostBuilder::on_event` callback, action handlers, resource readers, and subscription callbacks all run there.\n\nHandlers return `boost::asio::awaitable<Result<Json>>`. `co_await` yields the host thread while a sampling or elicitation request is in flight. Blocking that thread blocks the session and every other handler on the host.\n\n`ResourceEmitter::emit` is the exception to the caller's thread rule. It is safe to call from any thread and posts the update onto the host's `io_context`; values emitted after unsubscribe or transport close are dropped.\n\n## Application dispatcher\n\n`HostOptions::application_dispatcher` is optional and has one job: it receives a `std::function<void()>` that the SDK wants to run on the application's own thread.\n\n```cpp\ntesseron::HostOptions options;\noptions.application_dispatcher = [](std::function<void()> work) {\n your_toolkit::post_to_main_loop(std::move(work));\n};\nbuilder.options(std::move(options));\n```\n\nThe handler calls `co_await context.on_application_thread(...)`. The dispatcher runs the callback on the UI toolkit's thread, then the handler resumes on the host's I/O thread. Keep the callback small and copy the value the handler needs back into state it owns.\n\nWhen the dispatcher is unset, `on_application_thread(...)` runs the callback inline on the host's I/O thread. That is the right default for a headless app with no second thread."},{"slug":"sdk/index","title":"SDK overview","description":"What a Tesseron SDK has to expose across TypeScript, Rust, Python, and C++.","section":"sdk","related":["sdk/rust/index","sdk/python/index","sdk/cpp/index","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\nThe SDKs live in four repositories: [TypeScript](https://github.com/Eigenwise/tesseron-typescript), [Rust](https://github.com/Eigenwise/tesseron-rust), [Python](https://github.com/Eigenwise/tesseron-python), and [C++](https://github.com/Eigenwise/tesseron-cpp). TypeScript packages are on npm, Rust is on crates.io, Python is on PyPI, and C++ is consumed through CMake FetchContent. The surface they expose, the **SDK contract**, is the portable part. Docs, protocol fixtures, and issue tracking stay in this hub.\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 hub-owned MCP gateway CLI, launched by the 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\" href=\"/sdk/python/\"\n description=\"asyncio and Pydantic v2, the full host surface, passing conformance. Published on PyPI.\" />\n <LinkCard title=\"Rust SDK\" href=\"/sdk/rust/\"\n description=\"Tokio and WebSocket host with typed actions, resources, and the full context API. Published on crates.io.\" />\n <LinkCard title=\"C++ SDK\" href=\"/sdk/cpp/\"\n description=\"C++20 and Boost.Asio host with actions, resources, and the full context API. Consumed through CMake FetchContent.\" />\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\nThe SDKs live in four repositories: [TypeScript](https://github.com/Eigenwise/tesseron-typescript), [Rust](https://github.com/Eigenwise/tesseron-rust), [Python](https://github.com/Eigenwise/tesseron-python), and [C++](https://github.com/Eigenwise/tesseron-cpp). TypeScript packages are on npm, Rust is on crates.io, Python is on PyPI, and C++ is consumed through CMake FetchContent. The surface they expose, the **SDK contract**, is the portable part. Docs, protocol fixtures, and issue tracking stay in this hub.\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","sdk/python/index","sdk/rust/index","sdk/cpp/index"],"bodyRaw":"\nTesseron already has [Rust](/sdk/rust/), [Python](/sdk/python/), and [C++](/sdk/cpp/) SDKs in their own language repositories. All three are working references for a new port.\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: `gateway/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. TypeScript uses a fluent builder (`action(...).describe(...).input(...).handler(...)`), Python uses decorators, and Rust uses a method chain on `TesseronHostBuilder`. 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\nPart of this list is executable. Build a small host adapter that reads `TESSERON_CONFORMANCE_FIXTURE`, registers its canned actions and resources, and prints the readiness line described in the [fixture adapter contract](https://github.com/eigenwise/tesseron/blob/main/conformance/README.md). Then run the shipped protocol 1.2 suite:\n\n```bash\npnpm dlx @tesseron/conformance@1.2.1 --host \"./build/tesseron-conformance-host\"\n```\n\nUse `TESSERON_CONFORMANCE_UNSUPPORTED=uds` on platforms without POSIX Unix domain sockets. The package carries the fixture corpus, reports skips separately, and runs each fixture against a fresh host process. Every language repository uses this published runner with its own host adapter. Docs and fixtures stay in the hub; an SDK release PR is complete only after its corresponding hub docs PR has merged. The prose list below remains the wider implementation checklist.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after the binding's connection becomes ready.\n- [ ] Sends `protocolVersion = \"1.2.0\"`. The gateway compares `major.minor`: a major mismatch is rejected with `-32000`, a minor mismatch is accepted with a warning.\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**Claim minting** — pick one of the two flows. Gateway-minted is the simpler port and stays supported.\n\n*Gateway-minted (default).* Omit `helloHandledByHost` from the manifest. The gateway auto-dials, mints the code, and returns it in the welcome. Nothing extra to implement.\n\n- [ ] Manifest omits `helloHandledByHost` (or sets it `false`) and carries no `hostMintedClaim`.\n\n*Host-minted (opt-in, [tesseron#60](https://github.com/eigenwise/tesseron/issues/60)).* The host mints the code so the user's paste deterministically picks one agent session instead of racing. Adds the [bind handshake](/protocol/handshake/#host-minted-claims-and-the-bind-handshake) as a hard requirement.\n\n- [ ] Mints `code`, `sessionId`, and `resumeToken` at instance creation; writes them into `hostMintedClaim` and sets `helloHandledByHost: true`.\n- [ ] Answers the app's own `tesseron/hello` locally with a synthesized welcome; does not forward it until a gateway binds.\n- [ ] Sets `hostMintedClaim.expiresAt = mintedAt + 10 min` and refreshes both every 5 min by rewriting the manifest, stopping once `boundAgent` is non-null.\n- [ ] Validates the bind code in **constant time**. A short-circuiting string compare leaks the code one character at a time.\n- [ ] Rate-limits mismatches: 5 within a 60 s rolling window trips a 60 s lockout; a successful bind resets the window.\n- [ ] Accepts exactly one bind. A second attempt against a spent claim is rejected, never re-bound.\n- [ ] Rejects a dial that skips the bind step (a pre-1.2 gateway). Letting it through produces a second, conflicting welcome against an already-resolved hello.\n- [ ] Replays the cached hello to the gateway after a successful bind, and drops the gateway's id-matched reply so the app never sees two welcomes.\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- [ ] Forwards `sampling/request` without counting sampling depth. The gateway enforces the depth cap of 3; no Tesseron frame carries depth.\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>/` following the [Rust](/sdk/rust/), [Python](/sdk/python/), and [C++](/sdk/cpp/) section structures.\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 already has [Rust](/sdk/rust/), [Python](/sdk/python/), and [C++](/sdk/cpp/) SDKs in their own language repositories. All three are working references for a new port.\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: `gateway/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. TypeScript uses a fluent builder (`action(...).describe(...).input(...).handler(...)`), Python uses decorators, and Rust uses a method chain on `TesseronHostBuilder`. 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\nPart of this list is executable. Build a small host adapter that reads `TESSERON_CONFORMANCE_FIXTURE`, registers its canned actions and resources, and prints the readiness line described in the [fixture adapter contract](https://github.com/eigenwise/tesseron/blob/main/conformance/README.md). Then run the shipped protocol 1.2 suite:\n\n```bash\npnpm dlx @tesseron/conformance@1.2.1 --host \"./build/tesseron-conformance-host\"\n```\n\nUse `TESSERON_CONFORMANCE_UNSUPPORTED=uds` on platforms without POSIX Unix domain sockets. The package carries the fixture corpus, reports skips separately, and runs each fixture against a fresh host process. Every language repository uses this published runner with its own host adapter. Docs and fixtures stay in the hub; an SDK release PR is complete only after its corresponding hub docs PR has merged. The prose list below remains the wider implementation checklist.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after the binding's connection becomes ready.\n- [ ] Sends `protocolVersion = \"1.2.0\"`. The gateway compares `major.minor`: a major mismatch is rejected with `-32000`, a minor mismatch is accepted with a warning.\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**Claim minting** — pick one of the two flows. Gateway-minted is the simpler port and stays supported.\n\n*Gateway-minted (default).* Omit `helloHandledByHost` from the manifest. The gateway auto-dials, mints the code, and returns it in the welcome. Nothing extra to implement.\n\n- [ ] Manifest omits `helloHandledByHost` (or sets it `false`) and carries no `hostMintedClaim`.\n\n*Host-minted (opt-in, [tesseron#60](https://github.com/eigenwise/tesseron/issues/60)).* The host mints the code so the user's paste deterministically picks one agent session instead of racing. Adds the [bind handshake](/protocol/handshake/#host-minted-claims-and-the-bind-handshake) as a hard requirement.\n\n- [ ] Mints `code`, `sessionId`, and `resumeToken` at instance creation; writes them into `hostMintedClaim` and sets `helloHandledByHost: true`.\n- [ ] Answers the app's own `tesseron/hello` locally with a synthesized welcome; does not forward it until a gateway binds.\n- [ ] Sets `hostMintedClaim.expiresAt = mintedAt + 10 min` and refreshes both every 5 min by rewriting the manifest, stopping once `boundAgent` is non-null.\n- [ ] Validates the bind code in **constant time**. A short-circuiting string compare leaks the code one character at a time.\n- [ ] Rate-limits mismatches: 5 within a 60 s rolling window trips a 60 s lockout; a successful bind resets the window.\n- [ ] Accepts exactly one bind. A second attempt against a spent claim is rejected, never re-bound.\n- [ ] Rejects a dial that skips the bind step (a pre-1.2 gateway). Letting it through produces a second, conflicting welcome against an already-resolved hello.\n- [ ] Replays the cached hello to the gateway after a successful bind, and drops the gateway's id-matched reply so the app never sees two welcomes.\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- [ ] Forwards `sampling/request` without counting sampling depth. The gateway enforces the depth cap of 3; no Tesseron frame carries depth.\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>/` following the [Rust](/sdk/rust/), [Python](/sdk/python/), and [C++](/sdk/cpp/) section structures.\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/actions","title":"Actions (Python)","description":"The @app.action decorator, input inference from the handler annotation, and what a handler may return.","section":"sdk","related":["sdk/python/index","sdk/python/context","sdk/python/errors","protocol/actions"],"bodyRaw":"\nAn action is a named, typed, handler-backed operation the agent can invoke. The gateway projects each one into an MCP tool.\n\n## The decorator\n\n```python\nfrom pydantic import BaseModel, Field\nfrom tesseron import ActionContext, JsonObject, TesseronApp\n\n\nclass AddTodoInput(BaseModel):\n text: str = Field(min_length=1)\n tag: str | None = None\n\n\ndef create_app() -> TesseronApp:\n app = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n\n @app.action(\"addTodo\", description=\"Add one todo\")\n async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject:\n del context\n todo = store.create(input_data.text, input_data.tag)\n await publish_todos()\n return todo_payload(todo)\n\n return app\n```\n\nThe `store`, `publish_todos`, and `todo_payload` names above come directly from the canonical [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py) host.\n\nEvery handler is `async def` and takes `(input_data, context)`. A handler that is not a coroutine function, or that does not take two parameters, raises `HostError` at registration. Registering one name twice raises `DuplicateNameError`, because the manifest has to stay unambiguous for the gateway to project it.\n\n## Input comes from the annotation\n\nIf the first parameter is annotated with a Pydantic `BaseModel`, that model is the input contract. Two things follow from it:\n\n1. The manifest publishes `model_json_schema(mode=\"validation\")`, unchanged. Validation mode is the right one here: the agent is producing input, not reading output, so aliases and defaults have to be described the way the model will accept them.\n2. Dispatch runs `model_validate` before the handler body. Input that does not fit is refused with [`-32004 InputValidation`](/protocol/errors/), and the handler never runs.\n\nThe refusal carries every problem Pydantic found, not just the first:\n\n```json\n{\n \"code\": -32004,\n \"message\": \"Invalid input\",\n \"data\": [\n { \"message\": \"String should have at least 1 character\", \"path\": [\"text\"] },\n { \"message\": \"Input should be a valid string\", \"path\": [\"tag\"] }\n ]\n}\n```\n\n## Raw JSON input\n\nAnnotate the first parameter with anything else and the handler takes the invocation input as raw JSON. Then `input_schema` is what the manifest publishes and `validate` is what enforces it:\n\n```python\nfrom tesseron import ActionContext, JsonValue, ValidationIssue\n\n\ndef positive_amount(raw_input: JsonValue) -> list[ValidationIssue]:\n if isinstance(raw_input, dict) and isinstance(raw_input.get(\"amount\"), int | float):\n return []\n return [ValidationIssue(message=\"amount must be a number\", path=[\"amount\"])]\n\n\n@app.action(\n \"charge\",\n description=\"Charge the saved card\",\n input_schema={\"type\": \"object\", \"properties\": {\"amount\": {\"type\": \"number\"}}},\n validate=positive_amount,\n)\nasync def charge(raw_input: JsonValue, context: ActionContext) -> JsonValue:\n return {\"charged\": True}\n```\n\nA non-empty issue list becomes the same `-32004` failure, with the same `data` shape. Leave `validate` off and nothing is checked: the schema is then documentation for the agent and nothing more.\n\n## The other options\n\n| Argument | What it does |\n| --- | --- |\n| `description` | Published in the manifest. The gateway uses it as the MCP tool description, so write it for the agent. |\n| `input_schema` | Overrides the schema derived from the model, or supplies one for a raw handler. |\n| `output_schema` | Published when set. Nothing validates output against it; it tells the agent what to expect. |\n| `timeout_ms` | Per-action deadline. Past it the invocation answers [`-32002 Timeout`](/protocol/errors/) and the handler task is cancelled. The default is 60 seconds. |\n| `validate` | Extra input check for a raw handler. Ignored when the input type comes from a model. |\n\n## What a handler may return\n\nOutput is converted to JSON before it leaves. Pydantic models go through `model_dump(mode=\"json\")`, enums through their value, mappings and sequences recursively, and `None`, `bool`, `int`, `float`, `str` as they are.\n\nAnything else has no defined wire shape, so it fails as an internal error rather than reaching the agent as the string of a repr. Return a model or a dict.\n\n## Failing on purpose\n\nRaise `ActionError` when the handler cannot produce its output:\n\n```python\nfrom pydantic import BaseModel\nfrom tesseron import ActionContext, ActionError, JsonObject\n\n\nclass TodoIdentifierInput(BaseModel):\n id: str\n\n\n@app.action(\"deleteTodo\", description=\"Delete one todo\")\nasync def delete_todo(input_data: TodoIdentifierInput, context: ActionContext) -> JsonObject:\n del context\n original_length = len(store.todos)\n store.todos[:] = [todo for todo in store.todos if todo.id != input_data.id]\n if len(store.todos) == original_length:\n raise ActionError.handler(\"Todo not found\", {\"kind\": \"not_found\"})\n await publish_todos()\n return {\"id\": input_data.id, \"removed\": True}\n```\n\nThis is the canonical `deleteTodo` shape from [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py).\n\n`ActionError.handler` sends its message and data to the agent as `-32005`. `ActionError.protocol(code, message, data)` does the same under a code you pick. `ActionError.internal(cause)` keeps the cause on your side and answers with a bare `-32603 Internal error`, which is what an unhandled exception in a handler is turned into too. See [errors](/sdk/python/errors/).\n","bodyText":"An action is a named, typed, handler-backed operation the agent can invoke. The gateway projects each one into an MCP tool.\n\n## The decorator\n\n```python\nfrom pydantic import BaseModel, Field\nfrom tesseron import ActionContext, JsonObject, TesseronApp\n\nclass AddTodoInput(BaseModel):\n text: str = Field(min_length=1)\n tag: str | None = None\n\ndef create_app() -> TesseronApp:\n app = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n\n @app.action(\"addTodo\", description=\"Add one todo\")\n async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject:\n del context\n todo = store.create(input_data.text, input_data.tag)\n await publish_todos()\n return todo_payload(todo)\n\n return app\n```\n\nThe `store`, `publish_todos`, and `todo_payload` names above come directly from the canonical [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py) host.\n\nEvery handler is `async def` and takes `(input_data, context)`. A handler that is not a coroutine function, or that does not take two parameters, raises `HostError` at registration. Registering one name twice raises `DuplicateNameError`, because the manifest has to stay unambiguous for the gateway to project it.\n\n## Input comes from the annotation\n\nIf the first parameter is annotated with a Pydantic `BaseModel`, that model is the input contract. Two things follow from it:\n\n1. The manifest publishes `model_json_schema(mode=\"validation\")`, unchanged. Validation mode is the right one here: the agent is producing input, not reading output, so aliases and defaults have to be described the way the model will accept them.\n2. Dispatch runs `model_validate` before the handler body. Input that does not fit is refused with [`-32004 InputValidation`](/protocol/errors/), and the handler never runs.\n\nThe refusal carries every problem Pydantic found, not just the first:\n\n```json\n{\n \"code\": -32004,\n \"message\": \"Invalid input\",\n \"data\": [\n { \"message\": \"String should have at least 1 character\", \"path\": [\"text\"] },\n { \"message\": \"Input should be a valid string\", \"path\": [\"tag\"] }\n ]\n}\n```\n\n## Raw JSON input\n\nAnnotate the first parameter with anything else and the handler takes the invocation input as raw JSON. Then `input_schema` is what the manifest publishes and `validate` is what enforces it:\n\n```python\nfrom tesseron import ActionContext, JsonValue, ValidationIssue\n\ndef positive_amount(raw_input: JsonValue) -> list[ValidationIssue]:\n if isinstance(raw_input, dict) and isinstance(raw_input.get(\"amount\"), int | float):\n return []\n return [ValidationIssue(message=\"amount must be a number\", path=[\"amount\"])]\n\n@app.action(\n \"charge\",\n description=\"Charge the saved card\",\n input_schema={\"type\": \"object\", \"properties\": {\"amount\": {\"type\": \"number\"}}},\n validate=positive_amount,\n)\nasync def charge(raw_input: JsonValue, context: ActionContext) -> JsonValue:\n return {\"charged\": True}\n```\n\nA non-empty issue list becomes the same `-32004` failure, with the same `data` shape. Leave `validate` off and nothing is checked: the schema is then documentation for the agent and nothing more.\n\n## The other options\n\n| Argument | What it does |\n| --- | --- |\n| `description` | Published in the manifest. The gateway uses it as the MCP tool description, so write it for the agent. |\n| `input_schema` | Overrides the schema derived from the model, or supplies one for a raw handler. |\n| `output_schema` | Published when set. Nothing validates output against it; it tells the agent what to expect. |\n| `timeout_ms` | Per-action deadline. Past it the invocation answers [`-32002 Timeout`](/protocol/errors/) and the handler task is cancelled. The default is 60 seconds. |\n| `validate` | Extra input check for a raw handler. Ignored when the input type comes from a model. |\n\n## What a handler may return\n\nOutput is converted to JSON before it leaves. Pydantic models go through `model_dump(mode=\"json\")`, enums through their value, mappings and sequences recursively, and `None`, `bool`, `int`, `float`, `str` as they are.\n\nAnything else has no defined wire shape, so it fails as an internal error rather than reaching the agent as the string of a repr. Return a model or a dict.\n\n## Failing on purpose\n\nRaise `ActionError` when the handler cannot produce its output:\n\n```python\nfrom pydantic import BaseModel\nfrom tesseron import ActionContext, ActionError, JsonObject\n\nclass TodoIdentifierInput(BaseModel):\n id: str\n\n@app.action(\"deleteTodo\", description=\"Delete one todo\")\nasync def delete_todo(input_data: TodoIdentifierInput, context: ActionContext) -> JsonObject:\n del context\n original_length = len(store.todos)\n store.todos[:] = [todo for todo in store.todos if todo.id != input_data.id]\n if len(store.todos) == original_length:\n raise ActionError.handler(\"Todo not found\", {\"kind\": \"not_found\"})\n await publish_todos()\n return {\"id\": input_data.id, \"removed\": True}\n```\n\nThis is the canonical `deleteTodo` shape from [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py).\n\n`ActionError.handler` sends its message and data to the agent as `-32005`. `ActionError.protocol(code, message, data)` does the same under a code you pick. `ActionError.internal(cause)` keeps the cause on your side and answers with a bare `-32603 Internal error`, which is what an unhandled exception in a handler is turned into too. See [errors](/sdk/python/errors/)."},{"slug":"sdk/python/conformance","title":"Conformance (Python)","description":"How the language-neutral runner drives the Python host, what passes, and what it skips.","section":"sdk","related":["sdk/python/index","sdk/porting","protocol/handshake"],"bodyRaw":"\nThe [conformance corpus](https://github.com/eigenwise/tesseron/tree/main/conformance) is the executable half of the protocol spec. It is language-neutral: the runner plays the gateway, and any SDK that can stand up a host from a fixture document can be checked against it.\n\n## Running it\n\nFrom the `tesseron-python` repository root:\n\n```bash\nuv sync --locked\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"uv run --locked python -m conformance_host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. The runner uses its bundled corpus; pass `--fixtures <path>` to test a hub checkout's current fixtures.\n\nThe current result on both Linux and Windows is **29 passed, 10 skipped, 0 failed** across the 39-fixture corpus.\n\n## What it skips, and why\n\nThe runner cross-checks the unsupported list against the four capability flags the host declares in `tesseron/hello`. A capability declared `true` in the SDK and named as unsupported fails the run, so this list cannot be used to hide a gap in something the host claims to do.\n\n- `host-minted-claim` skips the nine `bind/*` fixtures. This host takes gateway-minted claims only.\n- `uds` skips `uds/file-mode`. This host speaks WebSocket only. Set this tag on both platforms.\n\nThose are the only ten skips. WebSocket-only is by design for this release. The [canonical examples](https://github.com/Eigenwise/tesseron-python/tree/main/examples) exercise the same actions through the real gateway.\n\nNeither transport is a negotiated capability, so neither is covered by the four flags. Everything the host declares, streaming, subscriptions, sampling, and elicitation, is exercised by the fixtures that run.\n\n## The host adapter\n\n`conformance_host/` reads a fixture document and registers what it declares. It sits **beside** `src/tesseron` rather than inside it and imports the published package like any other consumer, so `uv build` produces a wheel with the SDK and nothing else.\n\nThe runner starts one host process per fixture with `TESSERON_CONFORMANCE_FIXTURE` pointing at the document, waits for a single readiness line on stdout, then plays the gateway against the endpoint that line names:\n\n```text\ntesseron-conformance-url=ws://127.0.0.1:62454/\n```\n\nExactly one stdout line. Every diagnostic goes to stderr, because a second stdout line fails the fixture. The process ends when the runner closes its stdin.\n\nAnything in the fixture grammar the host cannot serve is refused at launch rather than ignored. A fixture requiring `uds`, a fixture that mints its own claim, a fixture member the adapter does not know, and an `inputSchema` using a JSON Schema keyword the adapter cannot enforce all fail the launch. A fixture that would otherwise pass because a keyword was silently dropped fails the run instead.\n\n## Writing a port of your own\n\nThe [porting guide](/sdk/porting/) covers the contract. The Python adapter is a reasonable second reference next to the Rust one. It uses no test framework and touches nothing private: the fixture document goes in, and the same public API an application would call comes out.\n","bodyText":"The [conformance corpus](https://github.com/eigenwise/tesseron/tree/main/conformance) is the executable half of the protocol spec. It is language-neutral: the runner plays the gateway, and any SDK that can stand up a host from a fixture document can be checked against it.\n\n## Running it\n\nFrom the `tesseron-python` repository root:\n\n```bash\nuv sync --locked\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"uv run --locked python -m conformance_host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. The runner uses its bundled corpus; pass `--fixtures <path>` to test a hub checkout's current fixtures.\n\nThe current result on both Linux and Windows is **29 passed, 10 skipped, 0 failed** across the 39-fixture corpus.\n\n## What it skips, and why\n\nThe runner cross-checks the unsupported list against the four capability flags the host declares in `tesseron/hello`. A capability declared `true` in the SDK and named as unsupported fails the run, so this list cannot be used to hide a gap in something the host claims to do.\n\n- `host-minted-claim` skips the nine `bind/*` fixtures. This host takes gateway-minted claims only.\n- `uds` skips `uds/file-mode`. This host speaks WebSocket only. Set this tag on both platforms.\n\nThose are the only ten skips. WebSocket-only is by design for this release. The [canonical examples](https://github.com/Eigenwise/tesseron-python/tree/main/examples) exercise the same actions through the real gateway.\n\nNeither transport is a negotiated capability, so neither is covered by the four flags. Everything the host declares, streaming, subscriptions, sampling, and elicitation, is exercised by the fixtures that run.\n\n## The host adapter\n\n`conformance_host/` reads a fixture document and registers what it declares. It sits **beside** `src/tesseron` rather than inside it and imports the published package like any other consumer, so `uv build` produces a wheel with the SDK and nothing else.\n\nThe runner starts one host process per fixture with `TESSERON_CONFORMANCE_FIXTURE` pointing at the document, waits for a single readiness line on stdout, then plays the gateway against the endpoint that line names:\n\n```text\ntesseron-conformance-url=ws://127.0.0.1:62454/\n```\n\nExactly one stdout line. Every diagnostic goes to stderr, because a second stdout line fails the fixture. The process ends when the runner closes its stdin.\n\nAnything in the fixture grammar the host cannot serve is refused at launch rather than ignored. A fixture requiring `uds`, a fixture that mints its own claim, a fixture member the adapter does not know, and an `inputSchema` using a JSON Schema keyword the adapter cannot enforce all fail the launch. A fixture that would otherwise pass because a keyword was silently dropped fails the run instead.\n\n## Writing a port of your own\n\nThe [porting guide](/sdk/porting/) covers the contract. The Python adapter is a reasonable second reference next to the Rust one. It uses no test framework and touches nothing private: the fixture document goes in, and the same public API an application would call comes out."},{"slug":"sdk/python/context","title":"Context (Python)","description":"What an ActionContext tells a handler, and everything it can send back while it runs.","section":"sdk","related":["sdk/python/actions","protocol/progress-cancellation","protocol/sampling","protocol/elicitation"],"bodyRaw":"\nEvery handler gets `(input, context)`. The context is what the invocation knows and what it can send while it runs.\n\n## What it knows\n\n| Member | What it is |\n| --- | --- |\n| `action_name` | The name this invocation was made under. |\n| `invocation_id` | The id the agent gave this invocation. Every frame the context sends carries it. |\n| `agent` | `AgentIdentity(id, name)`. `pending` / `Awaiting agent` until the session is claimed. |\n| `agent_capabilities` | The negotiated intersection: `streaming`, `subscriptions`, `sampling`, `elicitation`. |\n| `origin` | The origin the application declared at construction. |\n| `route` | Where the agent was when it invoked, when the gateway sent one. `None` otherwise. |\n| `cancellation` | The shared cancellation signal. |\n| `is_cancelled` | Whether cancellation has already been requested. |\n\nThe context is assembled after the handshake settles, so `agent_capabilities` is never a guess: an invocation the gateway wrote straight after the welcome waits for the welcome to be applied before the handler sees it.\n\n## Progress\n\n```python\nfor index, text in enumerate(input_data.items, start=1):\n todo = store.create(text, input_data.tag)\n identifiers.append(todo.id)\n await context.progress(\n message=f\"{index}/{item_count} imported\", percent=index * 100 // item_count\n )\n```\n\nPercent is an integer from 0 to 100. Out-of-range values are clamped into range, and a value below one already sent for this invocation is raised back up to the running ceiling. An agent rendering a progress bar treats a backwards jump as a restart, and the message is worth more than the regression. Message and data travel unchanged.\n\nEvery argument is optional. Progress with only a message is a perfectly good frame. It is a notification, so nothing answers it and it costs the handler nothing to send.\n\n## Cancellation\n\nThe agent cancels with a notification, so nothing answers `actions/cancel`. The invocation it names answers `-32001` instead, and its task is cancelled.\n\nA handler that ignores the signal still gets its answer replaced, so long handlers should watch for it:\n\n```python\n@app.action(\"importTodos\", description=\"Import several todos\")\nasync def import_todos(input_data: ImportTodosInput, context: ActionContext) -> JsonObject:\n identifiers: list[str] = []\n item_count = len(input_data.items)\n for index, text in enumerate(input_data.items, start=1):\n todo = store.create(text, input_data.tag)\n identifiers.append(todo.id)\n await context.progress(\n message=f\"{index}/{item_count} imported\", percent=index * 100 // item_count\n )\n await publish_todos()\n return {\"added\": len(identifiers), \"ids\": json_string_array(identifiers)}\n```\n\n`await context.cancellation.wait()` resolves as soon as cancellation is requested, immediately if it already was, which is what you race a long await against.\n\n## Sampling\n\n```python\nawait context.progress(message=\"asking LLM...\", percent=25)\nsuggested = await context.sample_as(\n SuggestedTodos,\n (\n f'Produce exactly {count} concrete todo items for the theme \"{input_data.theme}\". '\n \"Return JSON matching { items: string[] }. Items should be short, imperative, \"\n \"and user-friendly. No numbering.\"\n ),\n max_tokens=400,\n)\n```\n\n`sample_as` derives the output schema from a Pydantic model and decodes the structured response into it. The canonical todo host uses `SuggestedTodos` with an `items: list[str]` field.\n\nA model asked for structured output answers with the JSON as text, so a string result is parsed before it is decoded.\n\nAn agent that never negotiated sampling gets you `-32006 SamplingNotAvailable` before a frame goes out. Sampling depth is not a field in any Tesseron frame: the gateway owns `maxSamplingDepth` and answers `-32008` itself, so the host forwards the request without counting.\n\n## Confirmation\n\n```python\nconfirmed = await context.confirm(\n f'Delete prompt \"{prompt.name}\" (tested {prompt.times_tested}x)? This cannot be undone.'\n)\nif not confirmed:\n return {\"id\": input_data.id, \"deleted\": False, \"cancelled\": True}\n```\n\n`True` only on an explicit accept. A decline, a cancel, and an agent that never negotiated elicitation all answer `False`, which is the safe reading for the destructive-operation gates this exists for. It never raises on the user's answer.\n\n## Elicitation\n\n```python\nanswer = await context.elicit_as(RenameTodoAnswer, f'Rename \"{todo.text}\" to?')\nif answer is None:\n return {\"id\": input_data.id, \"renamed\": False, \"cancelled\": True}\ntodo.text = answer.new_name\n```\n\n`None` on a decline or a cancel. Unlike `confirm`, a missing capability is an error here: structured content has no safe default, so the handler has to branch on it explicitly.\n\nMCP renders an elicit prompt as a flat form, so the schema has to be one object of primitive leaves. The host checks that on the send path, before the frame leaves, so a bad schema fails at the `elicit` call site with `-32602 InvalidParams` instead of surfacing as a gateway rejection three hops later. Top-level `oneOf`, `anyOf`, `allOf`, `not`, and object- or array-typed properties are all refused. A property with no usable type is accepted unchanged, and a `type` array is checked on its first entry.\n\nLeave `json_schema` off and the host sends a one-text-field schema, which is the least a client can render.\n\n`elicit_as` derives the form schema from a Pydantic model and decodes the accepted answer into it. The todo example declares the answer this way:\n\n```python\nclass RenameTodoAnswer(BaseModel):\n new_name: str = Field(alias=\"newName\", min_length=1)\n```\n\n\n## Logs\n\n```python\nawait context.log(\"saved\", level=LogLevel.WARN, meta={\"todoId\": \"t-1\"})\n```\n\nFire and forget, forwarded to the agent. Levels are `debug`, `info`, `warn`, `error`, matching the MCP levels the gateway forwards to.\n\n## Testing a handler without a gateway\n\n`ActionContext.detached(action_name)` builds a context with no connection behind it. Notifications go nowhere, which is what a fire-and-forget frame does on a closed socket anyway, and every request answers `-32010 TransportClosed` rather than hanging.\n\n```python\noutput = await add_todo(\n AddTodoInput(text=\"buy milk\"), ActionContext.detached(\"addTodo\")\n)\n```\n\nA live invocation sees the same `-32010` if the transport drops underneath it: every request still waiting on an answer fails with it rather than hanging, and a request started after the socket is gone fails immediately. The invocation itself is cancelled at that point, so a handler that watches `cancellation` gets to unwind.\n","bodyText":"Every handler gets `(input, context)`. The context is what the invocation knows and what it can send while it runs.\n\n## What it knows\n\n| Member | What it is |\n| --- | --- |\n| `action_name` | The name this invocation was made under. |\n| `invocation_id` | The id the agent gave this invocation. Every frame the context sends carries it. |\n| `agent` | `AgentIdentity(id, name)`. `pending` / `Awaiting agent` until the session is claimed. |\n| `agent_capabilities` | The negotiated intersection: `streaming`, `subscriptions`, `sampling`, `elicitation`. |\n| `origin` | The origin the application declared at construction. |\n| `route` | Where the agent was when it invoked, when the gateway sent one. `None` otherwise. |\n| `cancellation` | The shared cancellation signal. |\n| `is_cancelled` | Whether cancellation has already been requested. |\n\nThe context is assembled after the handshake settles, so `agent_capabilities` is never a guess: an invocation the gateway wrote straight after the welcome waits for the welcome to be applied before the handler sees it.\n\n## Progress\n\n```python\nfor index, text in enumerate(input_data.items, start=1):\n todo = store.create(text, input_data.tag)\n identifiers.append(todo.id)\n await context.progress(\n message=f\"{index}/{item_count} imported\", percent=index * 100 // item_count\n )\n```\n\nPercent is an integer from 0 to 100. Out-of-range values are clamped into range, and a value below one already sent for this invocation is raised back up to the running ceiling. An agent rendering a progress bar treats a backwards jump as a restart, and the message is worth more than the regression. Message and data travel unchanged.\n\nEvery argument is optional. Progress with only a message is a perfectly good frame. It is a notification, so nothing answers it and it costs the handler nothing to send.\n\n## Cancellation\n\nThe agent cancels with a notification, so nothing answers `actions/cancel`. The invocation it names answers `-32001` instead, and its task is cancelled.\n\nA handler that ignores the signal still gets its answer replaced, so long handlers should watch for it:\n\n```python\n@app.action(\"importTodos\", description=\"Import several todos\")\nasync def import_todos(input_data: ImportTodosInput, context: ActionContext) -> JsonObject:\n identifiers: list[str] = []\n item_count = len(input_data.items)\n for index, text in enumerate(input_data.items, start=1):\n todo = store.create(text, input_data.tag)\n identifiers.append(todo.id)\n await context.progress(\n message=f\"{index}/{item_count} imported\", percent=index * 100 // item_count\n )\n await publish_todos()\n return {\"added\": len(identifiers), \"ids\": json_string_array(identifiers)}\n```\n\n`await context.cancellation.wait()` resolves as soon as cancellation is requested, immediately if it already was, which is what you race a long await against.\n\n## Sampling\n\n```python\nawait context.progress(message=\"asking LLM...\", percent=25)\nsuggested = await context.sample_as(\n SuggestedTodos,\n (\n f'Produce exactly {count} concrete todo items for the theme \"{input_data.theme}\". '\n \"Return JSON matching { items: string[] }. Items should be short, imperative, \"\n \"and user-friendly. No numbering.\"\n ),\n max_tokens=400,\n)\n```\n\n`sample_as` derives the output schema from a Pydantic model and decodes the structured response into it. The canonical todo host uses `SuggestedTodos` with an `items: list[str]` field.\n\nA model asked for structured output answers with the JSON as text, so a string result is parsed before it is decoded.\n\nAn agent that never negotiated sampling gets you `-32006 SamplingNotAvailable` before a frame goes out. Sampling depth is not a field in any Tesseron frame: the gateway owns `maxSamplingDepth` and answers `-32008` itself, so the host forwards the request without counting.\n\n## Confirmation\n\n```python\nconfirmed = await context.confirm(\n f'Delete prompt \"{prompt.name}\" (tested {prompt.times_tested}x)? This cannot be undone.'\n)\nif not confirmed:\n return {\"id\": input_data.id, \"deleted\": False, \"cancelled\": True}\n```\n\n`True` only on an explicit accept. A decline, a cancel, and an agent that never negotiated elicitation all answer `False`, which is the safe reading for the destructive-operation gates this exists for. It never raises on the user's answer.\n\n## Elicitation\n\n```python\nanswer = await context.elicit_as(RenameTodoAnswer, f'Rename \"{todo.text}\" to?')\nif answer is None:\n return {\"id\": input_data.id, \"renamed\": False, \"cancelled\": True}\ntodo.text = answer.new_name\n```\n\n`None` on a decline or a cancel. Unlike `confirm`, a missing capability is an error here: structured content has no safe default, so the handler has to branch on it explicitly.\n\nMCP renders an elicit prompt as a flat form, so the schema has to be one object of primitive leaves. The host checks that on the send path, before the frame leaves, so a bad schema fails at the `elicit` call site with `-32602 InvalidParams` instead of surfacing as a gateway rejection three hops later. Top-level `oneOf`, `anyOf`, `allOf`, `not`, and object- or array-typed properties are all refused. A property with no usable type is accepted unchanged, and a `type` array is checked on its first entry.\n\nLeave `json_schema` off and the host sends a one-text-field schema, which is the least a client can render.\n\n`elicit_as` derives the form schema from a Pydantic model and decodes the accepted answer into it. The todo example declares the answer this way:\n\n```python\nclass RenameTodoAnswer(BaseModel):\n new_name: str = Field(alias=\"newName\", min_length=1)\n```\n\n## Logs\n\n```python\nawait context.log(\"saved\", level=LogLevel.WARN, meta={\"todoId\": \"t-1\"})\n```\n\nFire and forget, forwarded to the agent. Levels are `debug`, `info`, `warn`, `error`, matching the MCP levels the gateway forwards to.\n\n## Testing a handler without a gateway\n\n`ActionContext.detached(action_name)` builds a context with no connection behind it. Notifications go nowhere, which is what a fire-and-forget frame does on a closed socket anyway, and every request answers `-32010 TransportClosed` rather than hanging.\n\n```python\noutput = await add_todo(\n AddTodoInput(text=\"buy milk\"), ActionContext.detached(\"addTodo\")\n)\n```\n\nA live invocation sees the same `-32010` if the transport drops underneath it: every request still waiting on an answer fails with it rather than hanging, and a request started after the socket is gone fails immediately. The invocation itself is cancelled at that point, so a handler that watches `cancellation` gets to unwind."},{"slug":"sdk/python/errors","title":"Errors (Python)","description":"The closed error-code set, the three ways a handler fails, and the host errors that never reach the wire.","section":"sdk","related":["sdk/python/actions","sdk/python/index","protocol/errors"],"bodyRaw":"\n## Envelope errors\n\nThe host follows the wire-format ID rules. A request with `id: null` is answered with `id: null`; only an absent `id` is a notification. A frame without `jsonrpc: \"2.0\"` is answered with `-32600 Invalid Request`, carrying the readable request id through or using `null` when there is no usable id.\n\n`TesseronErrorCode` is an `IntEnum` carrying every code the protocol defines. The set is closed: a gateway that sends an integer outside it speaks a protocol this package does not implement, so `ProtocolError` keeps the raw integer and `named_code` answers `None` rather than inventing a member.\n\n| Code | Member | When |\n| --- | --- | --- |\n| `-32700` | `PARSE_ERROR` | The peer sent something that is not JSON. |\n| `-32600` | `INVALID_REQUEST` | Not a JSON-RPC 2.0 envelope. |\n| `-32601` | `METHOD_NOT_FOUND` | A method this host does not answer. |\n| `-32602` | `INVALID_PARAMS` | Params the method cannot use, including an elicit schema MCP cannot render. |\n| `-32603` | `INTERNAL_ERROR` | Anything unexpected. Never carries detail. |\n| `-32000` | `PROTOCOL_MISMATCH` | The two sides speak different protocol majors. |\n| `-32001` | `CANCELLED` | The agent cancelled the invocation. |\n| `-32002` | `TIMEOUT` | The invocation passed its deadline. |\n| `-32003` | `ACTION_NOT_FOUND` | No such action, or no such readable or subscribable resource. |\n| `-32004` | `INPUT_VALIDATION` | Input did not satisfy the declared schema. |\n| `-32005` | `HANDLER_ERROR` | A domain failure the handler reported on purpose. |\n| `-32006` | `SAMPLING_NOT_AVAILABLE` | The agent never negotiated sampling. |\n| `-32007` | `ELICITATION_NOT_AVAILABLE` | The agent never negotiated elicitation. |\n| `-32008` | `SAMPLING_DEPTH_EXCEEDED` | The gateway's own sampling-depth guard. |\n| `-32009` | `UNAUTHORIZED` | The session is not claimed, or the claim does not cover this. |\n| `-32010` | `TRANSPORT_CLOSED` | The connection went away with a request still in flight. |\n| `-32011` | `RESUME_FAILED` | The gateway refused the resume credentials. |\n\n`TesseronErrorCode.from_wire_code(code)` names a wire integer, or answers `None` for one this version does not define.\n\n## The three ways a handler fails\n\n```python\nfrom tesseron import ActionError, TesseronErrorCode\n\n\nraise ActionError.handler(\"Todo not found\", {\"kind\": \"not_found\"})\nraise ActionError.protocol(\n TesseronErrorCode.UNAUTHORIZED, \"this agent cannot charge cards\"\n)\nraise ActionError.internal(RuntimeError(\"database unavailable\"))\n```\n\nThe distinction that matters is what crosses the socket. `handler` and `protocol` send their message and data to the agent. `internal` keeps the cause on your side, reachable through `internal_source`, and answers with a bare `-32603 Internal error`: a stack trace or a database URL in a handler error is a leak.\n\nAn exception that is not an `ActionError` is turned into `ActionError.internal` automatically, so an unhandled failure in a handler never spills detail either. `with_data(data)` attaches structured detail the agent can branch on.\n\n## ProtocolError\n\n`ProtocolError` is the `error` member of a JSON-RPC failure, exactly as it travels: `code`, `message`, `data`. It is what the SDK raises when the gateway refuses something the host asked for, and what `to_wire()` produces for a failure the host is sending.\n\n## Host errors\n\nThese never reach the wire. They are how the host tells you it cannot start.\n\n| Exception | When |\n| --- | --- |\n| `HostError` | The base. A handler that is not a coroutine function, or that does not take `(input_data, context)`. |\n| `InvalidApplicationIdError` | The application id is reserved or does not match `^[a-z][a-z0-9_]*$`. |\n| `DuplicateNameError` | Two actions, or two resources, under one name. |\n| `ManifestError` | The instance manifest could not be written or removed. |\n| `MissingApplicationError` | No application descriptor was registered before `listen`. |\n","bodyText":"## Envelope errors\n\nThe host follows the wire-format ID rules. A request with `id: null` is answered with `id: null`; only an absent `id` is a notification. A frame without `jsonrpc: \"2.0\"` is answered with `-32600 Invalid Request`, carrying the readable request id through or using `null` when there is no usable id.\n\n`TesseronErrorCode` is an `IntEnum` carrying every code the protocol defines. The set is closed: a gateway that sends an integer outside it speaks a protocol this package does not implement, so `ProtocolError` keeps the raw integer and `named_code` answers `None` rather than inventing a member.\n\n| Code | Member | When |\n| --- | --- | --- |\n| `-32700` | `PARSE_ERROR` | The peer sent something that is not JSON. |\n| `-32600` | `INVALID_REQUEST` | Not a JSON-RPC 2.0 envelope. |\n| `-32601` | `METHOD_NOT_FOUND` | A method this host does not answer. |\n| `-32602` | `INVALID_PARAMS` | Params the method cannot use, including an elicit schema MCP cannot render. |\n| `-32603` | `INTERNAL_ERROR` | Anything unexpected. Never carries detail. |\n| `-32000` | `PROTOCOL_MISMATCH` | The two sides speak different protocol majors. |\n| `-32001` | `CANCELLED` | The agent cancelled the invocation. |\n| `-32002` | `TIMEOUT` | The invocation passed its deadline. |\n| `-32003` | `ACTION_NOT_FOUND` | No such action, or no such readable or subscribable resource. |\n| `-32004` | `INPUT_VALIDATION` | Input did not satisfy the declared schema. |\n| `-32005` | `HANDLER_ERROR` | A domain failure the handler reported on purpose. |\n| `-32006` | `SAMPLING_NOT_AVAILABLE` | The agent never negotiated sampling. |\n| `-32007` | `ELICITATION_NOT_AVAILABLE` | The agent never negotiated elicitation. |\n| `-32008` | `SAMPLING_DEPTH_EXCEEDED` | The gateway's own sampling-depth guard. |\n| `-32009` | `UNAUTHORIZED` | The session is not claimed, or the claim does not cover this. |\n| `-32010` | `TRANSPORT_CLOSED` | The connection went away with a request still in flight. |\n| `-32011` | `RESUME_FAILED` | The gateway refused the resume credentials. |\n\n`TesseronErrorCode.from_wire_code(code)` names a wire integer, or answers `None` for one this version does not define.\n\n## The three ways a handler fails\n\n```python\nfrom tesseron import ActionError, TesseronErrorCode\n\nraise ActionError.handler(\"Todo not found\", {\"kind\": \"not_found\"})\nraise ActionError.protocol(\n TesseronErrorCode.UNAUTHORIZED, \"this agent cannot charge cards\"\n)\nraise ActionError.internal(RuntimeError(\"database unavailable\"))\n```\n\nThe distinction that matters is what crosses the socket. `handler` and `protocol` send their message and data to the agent. `internal` keeps the cause on your side, reachable through `internal_source`, and answers with a bare `-32603 Internal error`: a stack trace or a database URL in a handler error is a leak.\n\nAn exception that is not an `ActionError` is turned into `ActionError.internal` automatically, so an unhandled failure in a handler never spills detail either. `with_data(data)` attaches structured detail the agent can branch on.\n\n## ProtocolError\n\n`ProtocolError` is the `error` member of a JSON-RPC failure, exactly as it travels: `code`, `message`, `data`. It is what the SDK raises when the gateway refuses something the host asked for, and what `to_wire()` produces for a failure the host is sending.\n\n## Host errors\n\nThese never reach the wire. They are how the host tells you it cannot start.\n\n| Exception | When |\n| --- | --- |\n| `HostError` | The base. A handler that is not a coroutine function, or that does not take `(input_data, context)`. |\n| `InvalidApplicationIdError` | The application id is reserved or does not match `^[a-z][a-z0-9_]*$`. |\n| `DuplicateNameError` | Two actions, or two resources, under one name. |\n| `ManifestError` | The instance manifest could not be written or removed. |\n| `MissingApplicationError` | No application descriptor was registered before `listen`. |"},{"slug":"sdk/python/index","title":"Python SDK","description":"The Python implementation of the Tesseron host protocol, built on asyncio and Pydantic v2.","section":"sdk","related":["sdk/index","sdk/python/actions","sdk/python/conformance","protocol/compatibility"],"bodyRaw":"\nSource: [github.com/Eigenwise/tesseron-python](https://github.com/Eigenwise/tesseron-python)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-python)\n\n`tesseron` is the Python host SDK. Your application listens on loopback, the MCP gateway dials in, and the agent gets typed actions and readable resources.\n\nIt speaks protocol [**1.2.0**](/protocol/), the same version the TypeScript, Rust, and C++ SDKs speak. Compatibility is decided by protocol version, never by matching package numbers: see the [compatibility contract](/protocol/compatibility/).\n\nThe host follows the protocol's envelope rules. A request with `id: null` is still a request and gets an answer with `id: null`; only an absent `id` makes a notification. A frame without `jsonrpc: \"2.0\"` gets `-32600 Invalid Request`, with its readable request id carried through or `null` when there is no usable id.\n\nThe package is published on [PyPI](https://pypi.org/project/tesseron/) and versions independently of the TypeScript SDK. Install it with `uv add tesseron`. Source and examples live in the `tesseron-python` repository.\n\n## Requirements\n\nPython 3.11 or newer. Two runtime dependencies: Pydantic v2 and `websockets`. Everything else is stdlib asyncio.\n\n## A first host\n\n```python\nfrom __future__ import annotations\n\nimport asyncio\n\nfrom pydantic import BaseModel, Field\n\nfrom tesseron import ActionContext, JsonObject, JsonValue, TesseronApp\n\n\nclass AddTodoInput(BaseModel):\n text: str = Field(min_length=1)\n tag: str | None = None\n\n\ndef create_app() -> TesseronApp:\n app = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n store = TodoStore()\n\n async def read_todos() -> JsonValue:\n return [todo_payload(todo) for todo in store.todos]\n\n todos_resource = app.resource(\n \"todos://all\",\n description=\"The complete todo list. Pushed on every mutation.\",\n read=read_todos,\n subscribable=True,\n )\n\n async def publish_todos() -> None:\n await todos_resource.publish(await read_todos())\n\n @app.action(\"addTodo\", description=\"Add one todo\")\n async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject:\n del context\n todo = store.create(input_data.text, input_data.tag)\n await publish_todos()\n return todo_payload(todo)\n\n return app\n\n\nasync def main() -> None:\n app = create_app()\n host = await app.listen()\n try:\n await asyncio.Event().wait()\n finally:\n await host.shutdown()\n```\n\nThe `TodoStore` and `todo_payload` definitions in this excerpt are the ones in [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py). The complete example also registers the other canonical actions.\n\n`app.listen()` binds `127.0.0.1` on a port the OS picks, writes the instance manifest the gateway watches for, and answers with a `TesseronHost` carrying the URL and the manifest path. Nothing dials out.\n\nThe application id has to match `^[a-z][a-z0-9_]*$` and cannot be `tesseron`, `mcp`, or `system`: the gateway uses it as an MCP tool prefix. An id that fails either rule raises `InvalidApplicationIdError` from `listen()` rather than binding a socket nobody can use.\n\n## What it covers\n\nHandshake and claiming, session resume with in-memory token rotation, action invocation with input validation, cancellation, per-action timeouts, streaming progress, sampling, confirmation, schema-checked elicitation, structured logs, and resources with reads, subscriptions, and pushes. All four capability flags are declared true.\n\nGateway-minted claims only, and WebSocket only. There is no Unix domain socket transport and no host-minted bind in this release, so the [conformance suite](/sdk/python/conformance/) skips those fixtures rather than pretending.\n\n## The manifest\n\n`listen()` publishes a v2 instance manifest into `~/.tesseron/instances/` once the URL is known: `0700` on the directory, `0600` on the file, removed again on `shutdown()`. POSIX modes are advisory on Windows, where the user account is the gate.\n\nPoint it somewhere else, or switch it off, with `ManifestPublication`:\n\n```python\nfrom pathlib import Path\n\nfrom tesseron import ManifestPublication, TesseronApp\n\nTesseronApp(id=\"python_todo\", name=\"Python Todo\", manifest=ManifestPublication.in_directory(Path(\"/tmp/x\")))\nTesseronApp(id=\"python_todo\", name=\"Python Todo\", manifest=ManifestPublication.disabled())\n```\n\nDisabling it is what a test harness wants. The conformance host does exactly that, because the runner dials an endpoint it was told about and should never touch a developer's `~/.tesseron`.\n\n## Session events\n\n`app.add_event_listener(listener)` takes a plain callable and gets `WelcomeEvent`, `ClaimedEvent`, `HandshakeFailedEvent`, and `DisconnectedEvent`. A listener that raises is logged and skipped: one bad listener must not break the session it was told about.\n\n```python\nfrom tesseron import ClaimedEvent, HostEvent\n\n\ndef watch(event: HostEvent) -> None:\n if isinstance(event, ClaimedEvent):\n print(\"claimed by\", event.claimed.agent.name)\n\n\napp.add_event_listener(watch)\n```\n\n## Development\n\nRun these checks from the `tesseron-python` repository root:\n\n```bash\nuv sync --locked\nuv run --locked ruff check .\nuv run --locked ruff format --check .\nuv run --locked mypy --strict src tests\nuv run --locked pytest\nuv build\n```\n\nRun the [conformance check](/sdk/python/conformance/) after the unit suite.\n\n## Next\n\n- [Actions](/sdk/python/actions/): the decorator, input inference, and what a handler may return.\n- [Resources](/sdk/python/resources/): reads, subscriptions, and pushes.\n- [Context](/sdk/python/context/): progress, sampling, confirmation, elicitation, logs, cancellation.\n- [Errors](/sdk/python/errors/): the code catalog and the three ways a handler fails.\n- [Conformance](/sdk/python/conformance/): how the runner drives the host, and what it skips.\n","bodyText":"Source: [github.com/Eigenwise/tesseron-python](https://github.com/Eigenwise/tesseron-python)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-python)\n\n`tesseron` is the Python host SDK. Your application listens on loopback, the MCP gateway dials in, and the agent gets typed actions and readable resources.\n\nIt speaks protocol [**1.2.0**](/protocol/), the same version the TypeScript, Rust, and C++ SDKs speak. Compatibility is decided by protocol version, never by matching package numbers: see the [compatibility contract](/protocol/compatibility/).\n\nThe host follows the protocol's envelope rules. A request with `id: null` is still a request and gets an answer with `id: null`; only an absent `id` makes a notification. A frame without `jsonrpc: \"2.0\"` gets `-32600 Invalid Request`, with its readable request id carried through or `null` when there is no usable id.\n\nThe package is published on [PyPI](https://pypi.org/project/tesseron/) and versions independently of the TypeScript SDK. Install it with `uv add tesseron`. Source and examples live in the `tesseron-python` repository.\n\n## Requirements\n\nPython 3.11 or newer. Two runtime dependencies: Pydantic v2 and `websockets`. Everything else is stdlib asyncio.\n\n## A first host\n\n```python\nfrom __future__ import annotations\n\nfrom pydantic import BaseModel, Field\n\nfrom tesseron import ActionContext, JsonObject, JsonValue, TesseronApp\n\nclass AddTodoInput(BaseModel):\n text: str = Field(min_length=1)\n tag: str | None = None\n\ndef create_app() -> TesseronApp:\n app = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n store = TodoStore()\n\n async def read_todos() -> JsonValue:\n return [todo_payload(todo) for todo in store.todos]\n\n todos_resource = app.resource(\n \"todos://all\",\n description=\"The complete todo list. Pushed on every mutation.\",\n read=read_todos,\n subscribable=True,\n )\n\n async def publish_todos() -> None:\n await todos_resource.publish(await read_todos())\n\n @app.action(\"addTodo\", description=\"Add one todo\")\n async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject:\n del context\n todo = store.create(input_data.text, input_data.tag)\n await publish_todos()\n return todo_payload(todo)\n\n return app\n\nasync def main() -> None:\n app = create_app()\n host = await app.listen()\n try:\n await asyncio.Event().wait()\n finally:\n await host.shutdown()\n```\n\nThe `TodoStore` and `todo_payload` definitions in this excerpt are the ones in [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py). The complete example also registers the other canonical actions.\n\n`app.listen()` binds `127.0.0.1` on a port the OS picks, writes the instance manifest the gateway watches for, and answers with a `TesseronHost` carrying the URL and the manifest path. Nothing dials out.\n\nThe application id has to match `^[a-z][a-z0-9_]*$` and cannot be `tesseron`, `mcp`, or `system`: the gateway uses it as an MCP tool prefix. An id that fails either rule raises `InvalidApplicationIdError` from `listen()` rather than binding a socket nobody can use.\n\n## What it covers\n\nHandshake and claiming, session resume with in-memory token rotation, action invocation with input validation, cancellation, per-action timeouts, streaming progress, sampling, confirmation, schema-checked elicitation, structured logs, and resources with reads, subscriptions, and pushes. All four capability flags are declared true.\n\nGateway-minted claims only, and WebSocket only. There is no Unix domain socket transport and no host-minted bind in this release, so the [conformance suite](/sdk/python/conformance/) skips those fixtures rather than pretending.\n\n## The manifest\n\n`listen()` publishes a v2 instance manifest into `~/.tesseron/instances/` once the URL is known: `0700` on the directory, `0600` on the file, removed again on `shutdown()`. POSIX modes are advisory on Windows, where the user account is the gate.\n\nPoint it somewhere else, or switch it off, with `ManifestPublication`:\n\n```python\nfrom pathlib import Path\n\nfrom tesseron import ManifestPublication, TesseronApp\n\nTesseronApp(id=\"python_todo\", name=\"Python Todo\", manifest=ManifestPublication.in_directory(Path(\"/tmp/x\")))\nTesseronApp(id=\"python_todo\", name=\"Python Todo\", manifest=ManifestPublication.disabled())\n```\n\nDisabling it is what a test harness wants. The conformance host does exactly that, because the runner dials an endpoint it was told about and should never touch a developer's `~/.tesseron`.\n\n## Session events\n\n`app.add_event_listener(listener)` takes a plain callable and gets `WelcomeEvent`, `ClaimedEvent`, `HandshakeFailedEvent`, and `DisconnectedEvent`. A listener that raises is logged and skipped: one bad listener must not break the session it was told about.\n\n```python\nfrom tesseron import ClaimedEvent, HostEvent\n\ndef watch(event: HostEvent) -> None:\n if isinstance(event, ClaimedEvent):\n print(\"claimed by\", event.claimed.agent.name)\n\napp.add_event_listener(watch)\n```\n\n## Development\n\nRun these checks from the `tesseron-python` repository root:\n\n```bash\nuv sync --locked\nuv run --locked ruff check .\nuv run --locked ruff format --check .\nuv run --locked mypy --strict src tests\nuv run --locked pytest\nuv build\n```\n\nRun the [conformance check](/sdk/python/conformance/) after the unit suite.\n\n## Next\n\n- [Actions](/sdk/python/actions/): the decorator, input inference, and what a handler may return.\n- [Resources](/sdk/python/resources/): reads, subscriptions, and pushes.\n- [Context](/sdk/python/context/): progress, sampling, confirmation, elicitation, logs, cancellation.\n- [Errors](/sdk/python/errors/): the code catalog and the three ways a handler fails.\n- [Conformance](/sdk/python/conformance/): how the runner drives the host, and what it skips."},{"slug":"sdk/python/resources","title":"Resources (Python)","description":"Readable and optionally subscribable application state, with pushes and cleanup.","section":"sdk","related":["sdk/python/index","sdk/python/actions","protocol/resources"],"bodyRaw":"\nA resource is named application state the agent can read, and optionally follow. Actions change things; resources report them.\n\n## Registering one\n\n```python\nfrom tesseron import JsonValue, TesseronApp\n\napp = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n\n\nasync def read_todos() -> JsonValue:\n return [todo_payload(todo) for todo in store.todos]\n\n\ntodos_resource = app.resource(\n \"todos://all\",\n description=\"The complete todo list. Pushed on every mutation.\",\n read=read_todos,\n subscribable=True,\n)\n```\n\nThis is the resource from the canonical [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py).\n\n`app.resource` answers with the `Resource` handle, which is what you push updates through. The reader is `async` and runs on every `resources/read`, so it always reports the current value rather than a snapshot taken at registration.\n\nRegistering one name twice raises `DuplicateNameError`.\n\n## Pushing updates\n\n```python\nawait todos_resource.publish(await read_todos())\n```\n\n`publish` goes to every agent currently subscribed to that resource, and does nothing when nobody is. Call it from wherever the state actually changes.\n\n## Subscribing with your own source\n\nSome state has a natural event source: a file watcher, a database listener, a queue. Hand `subscribe` a callback that starts it and answers with the cleanup that stops again:\n\n```python\nfrom tesseron import Emit, JsonValue, TesseronApp, Unsubscribe\n\napp = TesseronApp(id=\"python_prompts\", name=\"Python Prompts\")\n\n\nasync def read_library() -> JsonValue:\n return [prompt_payload(prompt) for prompt in store.library()]\n\n\ndef follow(emit: Emit) -> Unsubscribe:\n watcher = start_watching(lambda value: emit(value))\n return watcher.stop\n\n\nlibrary_resource = app.resource(\n \"library\",\n description=\"Live snapshot of every prompt in the library. Pushed on every change.\",\n read=read_library,\n subscribable=True,\n subscribe=follow,\n)\n```\n\n`follow` is the application-specific addition to the canonical prompts app when a separate event source owns the updates.\n\nThe callback is synchronous and runs inside the session's read loop, so start your work and return promptly rather than awaiting in it. The cleanup runs when the agent unsubscribes, and again when the connection drops: a subscriber still holding a listener would emit into a closed socket for as long as the application runs.\n\nPassing `subscribe` implies `subscribable=True`. A resource is also subscribable on `subscribable=True` alone, because `publish` is enough on its own to push updates.\n\n## What the wire does\n\n`resources/subscribe` and `resources/unsubscribe` both acknowledge with `result: null`. The acknowledgement goes out **before** the subscriber runs, so a value the subscriber emits immediately cannot overtake the response the agent is still waiting on.\n\nUnsubscribing an id nobody registered is not an error. The agent and the transport can race, and there is nothing left to tear down either way.\n\nReading a resource that was never declared answers `-32003` with `Resource not readable: <name>`. Subscribing to one that was never declared, or to one that is not subscribable, answers `-32003` with `Resource not subscribable: <name>`. That is the same answer `@tesseron/core` gives.\n\nA reader that raises `ActionError` sends that failure to the agent. A reader that raises anything else answers a bare `-32603`, with the cause kept on your side.\n","bodyText":"A resource is named application state the agent can read, and optionally follow. Actions change things; resources report them.\n\n## Registering one\n\n```python\nfrom tesseron import JsonValue, TesseronApp\n\napp = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n\nasync def read_todos() -> JsonValue:\n return [todo_payload(todo) for todo in store.todos]\n\ntodos_resource = app.resource(\n \"todos://all\",\n description=\"The complete todo list. Pushed on every mutation.\",\n read=read_todos,\n subscribable=True,\n)\n```\n\nThis is the resource from the canonical [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py).\n\n`app.resource` answers with the `Resource` handle, which is what you push updates through. The reader is `async` and runs on every `resources/read`, so it always reports the current value rather than a snapshot taken at registration.\n\nRegistering one name twice raises `DuplicateNameError`.\n\n## Pushing updates\n\n```python\nawait todos_resource.publish(await read_todos())\n```\n\n`publish` goes to every agent currently subscribed to that resource, and does nothing when nobody is. Call it from wherever the state actually changes.\n\n## Subscribing with your own source\n\nSome state has a natural event source: a file watcher, a database listener, a queue. Hand `subscribe` a callback that starts it and answers with the cleanup that stops again:\n\n```python\nfrom tesseron import Emit, JsonValue, TesseronApp, Unsubscribe\n\napp = TesseronApp(id=\"python_prompts\", name=\"Python Prompts\")\n\nasync def read_library() -> JsonValue:\n return [prompt_payload(prompt) for prompt in store.library()]\n\ndef follow(emit: Emit) -> Unsubscribe:\n watcher = start_watching(lambda value: emit(value))\n return watcher.stop\n\nlibrary_resource = app.resource(\n \"library\",\n description=\"Live snapshot of every prompt in the library. Pushed on every change.\",\n read=read_library,\n subscribable=True,\n subscribe=follow,\n)\n```\n\n`follow` is the application-specific addition to the canonical prompts app when a separate event source owns the updates.\n\nThe callback is synchronous and runs inside the session's read loop, so start your work and return promptly rather than awaiting in it. The cleanup runs when the agent unsubscribes, and again when the connection drops: a subscriber still holding a listener would emit into a closed socket for as long as the application runs.\n\nPassing `subscribe` implies `subscribable=True`. A resource is also subscribable on `subscribable=True` alone, because `publish` is enough on its own to push updates.\n\n## What the wire does\n\n`resources/subscribe` and `resources/unsubscribe` both acknowledge with `result: null`. The acknowledgement goes out **before** the subscriber runs, so a value the subscriber emits immediately cannot overtake the response the agent is still waiting on.\n\nUnsubscribing an id nobody registered is not an error. The agent and the transport can race, and there is nothing left to tear down either way.\n\nReading a resource that was never declared answers `-32003` with `Resource not readable: <name>`. Subscribing to one that was never declared, or to one that is not subscribable, answers `-32003` with `Resource not subscribable: <name>`. That is the same answer `@tesseron/core` gives.\n\nA reader that raises `ActionError` sends that failure to the agent. A reader that raises anything else answers a bare `-32603`, with the cause kept on your side."},{"slug":"sdk/rust/actions","title":"Actions (Rust)","description":"Typed and raw JSON actions, schema publication, validation, timeouts, and registration rules.","section":"sdk","related":["sdk/rust/index","sdk/rust/context","sdk/rust/errors","protocol/actions"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nAn action is a named handler the agent can invoke. The gateway projects each registration into an MCP tool.\n\n## Typed actions\n\n`Action::typed(name, handler)` takes an input type that implements `DeserializeOwned + JsonSchema` and an output type that implements `Serialize`. The derived Schemars document is JSON Schema 2020-12, and the same input type is deserialized before the handler runs.\n\nThis is the builder shape used by the crate README:\n\n```rust\nuse tesseron::{Action, ActionContext, ActionError, HostEvent, Tesseron};\nuse serde::{Deserialize, Serialize};\nuse schemars::JsonSchema;\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddTodo {\n title: String,\n}\n\n#[derive(Serialize, JsonSchema)]\nstruct Added {\n id: u64,\n}\n\nasync fn add_todo(input: AddTodo, _context: ActionContext) -> Result<Added, ActionError> {\n Ok(Added { id: store_todo(input.title) })\n}\n\n# fn store_todo(_title: String) -> u64 { 1 }\n# async fn example() -> Result<(), Box<dyn std::error::Error>> {\nlet builder = Tesseron::builder()\n .application(\"todo\", \"Todo\")\n .action(Action::typed(\"add_todo\", add_todo).output_schema_from_type::<Added>());\nlet mut events = builder.subscribe();\nlet host = builder.listen().await?;\n\nwhile let Ok(event) = events.recv().await {\n if let HostEvent::Welcome(welcome) = event {\n if let Some(code) = welcome.claim_code {\n println!(\"Claim this session with {code}\");\n }\n break;\n }\n}\nhost.shutdown().await?;\n# Ok(())\n# }\n```\n\nA typed input schema must have an object root. Use a struct for input, including an empty struct for an action with no input. Scalars, enums, and `Vec<T>` derive non-object roots and are refused when `listen()` runs with `HostError::InvalidTypedActionInputSchema`. The error names the action and the Rust input type. A typed action with `.input_schema(Value)` still has to publish an object-root schema.\n\nInput that cannot deserialize is rejected with `ActionError` carrying `TesseronErrorCode::InputValidation`, and the handler does not run.\n\n## Raw JSON actions\n\n`Action::json(name, handler)` passes a `serde_json::Value` to the handler. It starts with a permissive `{}` input schema. Set `.input_schema(Value)` for the manifest and add `.validate_with(..)` when the schema must be enforced:\n\n```rust\nlet mut action = Action::json(\n fixture.name,\n move |_input: Value, context: ActionContext| {\n let script = Arc::clone(&script);\n async move { run_action(&script, context).await }\n },\n)\n.description(fixture.description);\n\nif let Some(schema) = fixture.input_schema {\n schema_subset::assert_enforceable(&schema)\n .map_err(|problem| format!(\"action {:?}: {problem}\", action.name()))?;\n let enforced = schema.clone();\n action = action\n .input_schema(schema)\n .validate_with(move |input: &Value| schema_subset::check(&enforced, input));\n}\n```\n\nThe validator returns `Ok(())` for accepted input or `Err(Vec<ValidationIssue>)` for rejected input. A rejected value becomes `TesseronErrorCode::InputValidation`. With no validator, the declared schema documents the expected value and the raw JSON reaches the handler unchanged.\n\n## Builder options\n\n| Method | What it does |\n| --- | --- |\n| `.description(...)` | Publishes the text the agent reads for the tool. |\n| `.input_schema(Value)` | Replaces the input schema in the manifest. |\n| `.output_schema(Value)` | Publishes an informational output schema. |\n| `.output_schema_from_type::<Output>()` | Derives and publishes the output schema from `Output`. |\n| `.timeout(Duration)` | Sets the per-invocation deadline instead of the 60-second default. |\n| `.validate_with(..)` | Adds a runtime validator for a raw JSON action. |\n\nOutput schema publication is opt-in. Nothing is published unless you call `.output_schema_from_type::<Output>()` or `.output_schema(Value)`. The schema describes the result for the agent; the crate does not validate handler output against it.\n\n## Registration lifetime\n\nActions are fixed once `listen()` runs. Runtime add and remove with list-change notifications is SQ-42 and is not shipped. Build every action on the `TesseronHostBuilder` before calling `listen()`.\n\nA duplicate action name returns `HostError::DuplicateName`. The application id is checked before the listener starts, and `bind_address(SocketAddr)` accepts loopback addresses only. `listen()` returns `HostError::NonLoopbackBindAddress` for anything else.\n\n## Handler failures\n\nReturn `ActionError::handler(message)` for a domain failure that the agent should see. Use `ActionError::protocol(code, message, data)` when a specific Tesseron code and structured data are part of the contract. Use `ActionError::internal(source)` for an unexpected failure; the cause stays local and the agent receives `-32603 Internal error`. See [Errors](/sdk/rust/errors/).\n","bodyText":"<!-- snippets from examples/todo -->\n\nAn action is a named handler the agent can invoke. The gateway projects each registration into an MCP tool.\n\n## Typed actions\n\n`Action::typed(name, handler)` takes an input type that implements `DeserializeOwned + JsonSchema` and an output type that implements `Serialize`. The derived Schemars document is JSON Schema 2020-12, and the same input type is deserialized before the handler runs.\n\nThis is the builder shape used by the crate README:\n\n```rust\nuse tesseron::{Action, ActionContext, ActionError, HostEvent, Tesseron};\nuse serde::{Deserialize, Serialize};\nuse schemars::JsonSchema;\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddTodo {\n title: String,\n}\n\n#[derive(Serialize, JsonSchema)]\nstruct Added {\n id: u64,\n}\n\nasync fn add_todo(input: AddTodo, _context: ActionContext) -> Result<Added, ActionError> {\n Ok(Added { id: store_todo(input.title) })\n}\n\n# fn store_todo(_title: String) -> u64 { 1 }\n# async fn example() -> Result<(), Box<dyn std::error::Error>> {\nlet builder = Tesseron::builder()\n .application(\"todo\", \"Todo\")\n .action(Action::typed(\"add_todo\", add_todo).output_schema_from_type::<Added>());\nlet mut events = builder.subscribe();\nlet host = builder.listen().await?;\n\nwhile let Ok(event) = events.recv().await {\n if let HostEvent::Welcome(welcome) = event {\n if let Some(code) = welcome.claim_code {\n println!(\"Claim this session with {code}\");\n }\n break;\n }\n}\nhost.shutdown().await?;\n# Ok(())\n# }\n```\n\nA typed input schema must have an object root. Use a struct for input, including an empty struct for an action with no input. Scalars, enums, and `Vec<T>` derive non-object roots and are refused when `listen()` runs with `HostError::InvalidTypedActionInputSchema`. The error names the action and the Rust input type. A typed action with `.input_schema(Value)` still has to publish an object-root schema.\n\nInput that cannot deserialize is rejected with `ActionError` carrying `TesseronErrorCode::InputValidation`, and the handler does not run.\n\n## Raw JSON actions\n\n`Action::json(name, handler)` passes a `serde_json::Value` to the handler. It starts with a permissive `{}` input schema. Set `.input_schema(Value)` for the manifest and add `.validate_with(..)` when the schema must be enforced:\n\n```rust\nlet mut action = Action::json(\n fixture.name,\n move |_input: Value, context: ActionContext| {\n let script = Arc::clone(&script);\n async move { run_action(&script, context).await }\n },\n)\n.description(fixture.description);\n\nif let Some(schema) = fixture.input_schema {\n schema_subset::assert_enforceable(&schema)\n .map_err(|problem| format!(\"action {:?}: {problem}\", action.name()))?;\n let enforced = schema.clone();\n action = action\n .input_schema(schema)\n .validate_with(move |input: &Value| schema_subset::check(&enforced, input));\n}\n```\n\nThe validator returns `Ok(())` for accepted input or `Err(Vec<ValidationIssue>)` for rejected input. A rejected value becomes `TesseronErrorCode::InputValidation`. With no validator, the declared schema documents the expected value and the raw JSON reaches the handler unchanged.\n\n## Builder options\n\n| Method | What it does |\n| --- | --- |\n| `.description(...)` | Publishes the text the agent reads for the tool. |\n| `.input_schema(Value)` | Replaces the input schema in the manifest. |\n| `.output_schema(Value)` | Publishes an informational output schema. |\n| `.output_schema_from_type::<Output>()` | Derives and publishes the output schema from `Output`. |\n| `.timeout(Duration)` | Sets the per-invocation deadline instead of the 60-second default. |\n| `.validate_with(..)` | Adds a runtime validator for a raw JSON action. |\n\nOutput schema publication is opt-in. Nothing is published unless you call `.output_schema_from_type::<Output>()` or `.output_schema(Value)`. The schema describes the result for the agent; the crate does not validate handler output against it.\n\n## Registration lifetime\n\nActions are fixed once `listen()` runs. Runtime add and remove with list-change notifications is SQ-42 and is not shipped. Build every action on the `TesseronHostBuilder` before calling `listen()`.\n\nA duplicate action name returns `HostError::DuplicateName`. The application id is checked before the listener starts, and `bind_address(SocketAddr)` accepts loopback addresses only. `listen()` returns `HostError::NonLoopbackBindAddress` for anything else.\n\n## Handler failures\n\nReturn `ActionError::handler(message)` for a domain failure that the agent should see. Use `ActionError::protocol(code, message, data)` when a specific Tesseron code and structured data are part of the contract. Use `ActionError::internal(source)` for an unexpected failure; the cause stays local and the agent receives `-32603 Internal error`. See [Errors](/sdk/rust/errors/)."},{"slug":"sdk/rust/conformance","title":"Conformance (Rust)","description":"Build the private Rust conformance host and run it against the shared protocol corpus.","section":"sdk","related":["sdk/rust/index","sdk/porting","protocol/handshake"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nThe [conformance corpus](https://github.com/eigenwise/tesseron/tree/main/conformance) is the executable half of the protocol spec. It is language-neutral. The runner plays the gateway, and the Rust host adapts each fixture into actions and resources through the public SDK API.\n\n## Run it\n\nFrom the `tesseron-rust` repository root, build the private host, then run the published runner:\n\n```bash\ncargo build --locked -p tesseron-conformance-host\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"./target/debug/tesseron-conformance-host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. Both tags are required on Linux and Windows. The runner uses its bundled corpus and starts a fresh host for every fixture. Use `--fixtures <path>` to test a hub checkout's current corpus.\n\nThe conformance host is private. It lives at `conformance-host/` as a workspace member and is not part of the published `tesseron` crate. It reads `TESSERON_CONFORMANCE_FIXTURE`, registers the fixture's canned actions and resources, and prints one readiness line before the runner connects. Diagnostics go to stderr.\n\n## Expected result\n\nThe Rust host uses gateway-minted claims and WebSocket transport only. With `host-minted-claim,uds` unsupported, expect **29 passed, 10 skipped, 0 failed** on Linux and Windows.\n\n- The nine `bind/*` fixtures skip because they require a host-minted claim. The Rust host waits for the gateway to mint the claim in the welcome.\n- `uds/file-mode` skips because the Rust host currently speaks WebSocket only in the conformance path and Unix domain sockets are unavailable there.\n\nThe runner treats these as skips, not hidden failures. Every capability the Rust host declares, including streaming, subscriptions, sampling, and elicitation, must agree with the `tesseron/hello` fields and is exercised by the fixtures that run.\n\n## Host launch contract\n\nThe runner gives each fixture a fresh temporary directory and starts one host process with `TESSERON_CONFORMANCE_FIXTURE` set to the fixture path. The host must print exactly one line in this form before any other stdout:\n\n`tesseron-conformance-url=ws://127.0.0.1:<port>/`\n\nThe runner connects to that loopback URL, runs the fixture steps, closes the connection, and ends the child before moving to the next fixture. A crash, extra stdout line, timeout, or non-loopback URL is a fixture failure.\n\n## When a port changes\n\nRun the full corpus again after changing a protocol path, action registration, resource subscription, handshake, or context method. A fixture added after the last host run is the usual reason a port goes red. The runner's `--host` path handling resolves the Rust binary to an absolute native path, which keeps this command working on Windows.\n","bodyText":"<!-- snippets from examples/todo -->\n\nThe [conformance corpus](https://github.com/eigenwise/tesseron/tree/main/conformance) is the executable half of the protocol spec. It is language-neutral. The runner plays the gateway, and the Rust host adapts each fixture into actions and resources through the public SDK API.\n\n## Run it\n\nFrom the `tesseron-rust` repository root, build the private host, then run the published runner:\n\n```bash\ncargo build --locked -p tesseron-conformance-host\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"./target/debug/tesseron-conformance-host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. Both tags are required on Linux and Windows. The runner uses its bundled corpus and starts a fresh host for every fixture. Use `--fixtures <path>` to test a hub checkout's current corpus.\n\nThe conformance host is private. It lives at `conformance-host/` as a workspace member and is not part of the published `tesseron` crate. It reads `TESSERON_CONFORMANCE_FIXTURE`, registers the fixture's canned actions and resources, and prints one readiness line before the runner connects. Diagnostics go to stderr.\n\n## Expected result\n\nThe Rust host uses gateway-minted claims and WebSocket transport only. With `host-minted-claim,uds` unsupported, expect **29 passed, 10 skipped, 0 failed** on Linux and Windows.\n\n- The nine `bind/*` fixtures skip because they require a host-minted claim. The Rust host waits for the gateway to mint the claim in the welcome.\n- `uds/file-mode` skips because the Rust host currently speaks WebSocket only in the conformance path and Unix domain sockets are unavailable there.\n\nThe runner treats these as skips, not hidden failures. Every capability the Rust host declares, including streaming, subscriptions, sampling, and elicitation, must agree with the `tesseron/hello` fields and is exercised by the fixtures that run.\n\n## Host launch contract\n\nThe runner gives each fixture a fresh temporary directory and starts one host process with `TESSERON_CONFORMANCE_FIXTURE` set to the fixture path. The host must print exactly one line in this form before any other stdout:\n\n`tesseron-conformance-url=ws://127.0.0.1:<port>/`\n\nThe runner connects to that loopback URL, runs the fixture steps, closes the connection, and ends the child before moving to the next fixture. A crash, extra stdout line, timeout, or non-loopback URL is a fixture failure.\n\n## When a port changes\n\nRun the full corpus again after changing a protocol path, action registration, resource subscription, handshake, or context method. A fixture added after the last host run is the usual reason a port goes red. The runner's `--host` path handling resolves the Rust binary to an absolute native path, which keeps this command working on Windows."},{"slug":"sdk/rust/context","title":"Context (Rust)","description":"Progress, cancellation, confirmation, elicitation, sampling, and logs available to every Rust handler.","section":"sdk","related":["sdk/rust/actions","sdk/rust/errors","protocol/progress-cancellation","protocol/sampling","protocol/elicitation"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n<!-- snippets from examples/prompts -->\n\nEvery handler receives an `ActionContext` after the gateway handshake. It identifies the action and invocation, exposes the negotiated `Capabilities`, and carries the connection used for requests and notifications. It is cheap to clone, and clones share the progress ceiling.\n\n## Progress\n\n`ProgressUpdate::new()` builds an update. Add `.message(...)`, `.percent(...)`, and `.data(Value)` as needed, then call `context.progress(update)`.\n\nThe protocol progress value is an integer from `0` through `100`. Pass integer-valued percentages. Values below `0` clamp to `0`, values above `100` clamp to `100`, and a value below the highest value already sent for this invocation is raised to that value. The message and data still go out. Progress is fire-and-forget.\n\nThe todo example sends one update per imported item:\n\n```rust\ncontext.progress(\n ProgressUpdate::new()\n .message(format!(\"{}/{} imported\", index + 1, item_count))\n .percent(((index + 1) * 100 / item_count) as f64),\n);\n```\n\nThe shared ceiling applies to cloned contexts too. A handler can report `55`, then `10`, and the second frame carries `55`.\n\n## Cancellation\n\nThe gateway cancels with an `actions/cancel` notification. The invocation answers with `TesseronErrorCode::Cancelled`, and the running handler should unwind. Check `context.is_cancelled()` between units of work, or await `context.cancellation().cancelled()` beside a long operation. The cancellation future resolves immediately when cancellation was already requested.\n\nA handler that ignores cancellation may keep running after the invocation response has been replaced. The host does not turn a late handler result into another response.\n\n## Sampling\n\n`context.sample(SampleRequest::new(prompt))` asks the agent's model and returns a `Value`. `SampleRequest::for_type::<Output>(prompt)` derives a JSON Schema for structured output, and `context.sample_as::<Output>(request)` decodes the result. Add `.max_tokens(...)` to cap the request.\n\nThe todo example asks for structured suggestions:\n\n```rust\nlet suggested = context\n .sample_as::<SuggestedTodos>(\n SampleRequest::for_type::<SuggestedTodos>(format!(\n \"Produce exactly {count} concrete todo items for the theme \\\"{}\\\". Return JSON matching {{ items: string[] }}. Items should be short, imperative, and user-friendly. No numbering.\",\n input.theme\n ))\n .max_tokens(400),\n )\n .await?;\n```\n\nWhen sampling was not negotiated, the call returns `ActionError` with `TesseronErrorCode::SamplingNotAvailable` and sends nothing. Sampling depth is enforced by the gateway with `TesseronErrorCode::SamplingDepthExceeded`; the host does not count nested requests.\n\n## Confirmation\n\n`context.confirm(question)` asks a yes-or-no question through elicitation and returns `Result<bool, ActionError>`. It returns `true` only for an explicit accept. Decline, cancel, and missing elicitation capability return `false`.\n\nThe prompt example uses it before deleting a prompt:\n\n```rust\nlet confirmed = context\n .confirm(format!(\n \"Delete prompt \\\"{}\\\" (tested {}x)? This cannot be undone.\",\n prompt.name, prompt.times_tested\n ))\n .await?;\n```\n\n## Elicitation\n\n`ElicitRequest::new(question)` uses a permissive single-text-field schema. `ElicitRequest::for_type::<Answer>(question)` derives a form schema from a `JsonSchema` type. Pass either request to `context.elicit(...)`, or use `context.elicit_as::<Answer>(request)` to decode accepted content.\n\nThe todo example derives its answer schema:\n\n```rust\nlet answer = context\n .elicit_as::<RenameTodoAnswer>(ElicitRequest::for_type::<RenameTodoAnswer>(\n format!(\"Rename \\\"{previous_text}\\\" to?\"),\n ))\n .await?;\n```\n\nAn accepted answer is `Some(Value)`, or `Some(Answer)` with `elicit_as`. Decline and cancel return `None`. Missing elicitation capability returns `TesseronErrorCode::ElicitationNotAvailable`. The host validates the JSON Schema before sending; an unsupported schema returns `InvalidParams` (`-32602`) at the call site, and no request reaches the agent. Top-level `oneOf`, `anyOf`, `allOf`, `not`, and object- or array-typed properties are refused.\n\n## Logs\n\n`context.log(LogEntry::info(message))` forwards a fire-and-forget log entry. Use `LogEntry::debug`, `LogEntry::warn`, or `LogEntry::error` for the other levels, and `.meta(...)` for structured metadata.\n\nThe session tests exercise the ordinary info level:\n\n```rust\ncontext.log(LogEntry::info(\"halfway\"));\n```\n\n## Capability checks and dropped transports\n\n`context.agent_capabilities()` is the negotiated capability set. Check it when a handler has a useful fallback. `context.agent()` identifies the caller, while `context.action_name()`, `context.invocation_id()`, `context.origin()`, and `context.route()` identify the running invocation.\n\nProgress and logs after a transport drop are discarded. Request methods such as `sample`, `confirm`, and `elicit` return an `ActionError` carrying `TesseronErrorCode::TransportClosed`, including when a cloned context is used after the connection has gone away. They fail instead of hanging.\n","bodyText":"<!-- snippets from examples/todo -->\n<!-- snippets from examples/prompts -->\n\nEvery handler receives an `ActionContext` after the gateway handshake. It identifies the action and invocation, exposes the negotiated `Capabilities`, and carries the connection used for requests and notifications. It is cheap to clone, and clones share the progress ceiling.\n\n## Progress\n\n`ProgressUpdate::new()` builds an update. Add `.message(...)`, `.percent(...)`, and `.data(Value)` as needed, then call `context.progress(update)`.\n\nThe protocol progress value is an integer from `0` through `100`. Pass integer-valued percentages. Values below `0` clamp to `0`, values above `100` clamp to `100`, and a value below the highest value already sent for this invocation is raised to that value. The message and data still go out. Progress is fire-and-forget.\n\nThe todo example sends one update per imported item:\n\n```rust\ncontext.progress(\n ProgressUpdate::new()\n .message(format!(\"{}/{} imported\", index + 1, item_count))\n .percent(((index + 1) * 100 / item_count) as f64),\n);\n```\n\nThe shared ceiling applies to cloned contexts too. A handler can report `55`, then `10`, and the second frame carries `55`.\n\n## Cancellation\n\nThe gateway cancels with an `actions/cancel` notification. The invocation answers with `TesseronErrorCode::Cancelled`, and the running handler should unwind. Check `context.is_cancelled()` between units of work, or await `context.cancellation().cancelled()` beside a long operation. The cancellation future resolves immediately when cancellation was already requested.\n\nA handler that ignores cancellation may keep running after the invocation response has been replaced. The host does not turn a late handler result into another response.\n\n## Sampling\n\n`context.sample(SampleRequest::new(prompt))` asks the agent's model and returns a `Value`. `SampleRequest::for_type::<Output>(prompt)` derives a JSON Schema for structured output, and `context.sample_as::<Output>(request)` decodes the result. Add `.max_tokens(...)` to cap the request.\n\nThe todo example asks for structured suggestions:\n\n```rust\nlet suggested = context\n .sample_as::<SuggestedTodos>(\n SampleRequest::for_type::<SuggestedTodos>(format!(\n \"Produce exactly {count} concrete todo items for the theme \\\"{}\\\". Return JSON matching {{ items: string[] }}. Items should be short, imperative, and user-friendly. No numbering.\",\n input.theme\n ))\n .max_tokens(400),\n )\n .await?;\n```\n\nWhen sampling was not negotiated, the call returns `ActionError` with `TesseronErrorCode::SamplingNotAvailable` and sends nothing. Sampling depth is enforced by the gateway with `TesseronErrorCode::SamplingDepthExceeded`; the host does not count nested requests.\n\n## Confirmation\n\n`context.confirm(question)` asks a yes-or-no question through elicitation and returns `Result<bool, ActionError>`. It returns `true` only for an explicit accept. Decline, cancel, and missing elicitation capability return `false`.\n\nThe prompt example uses it before deleting a prompt:\n\n```rust\nlet confirmed = context\n .confirm(format!(\n \"Delete prompt \\\"{}\\\" (tested {}x)? This cannot be undone.\",\n prompt.name, prompt.times_tested\n ))\n .await?;\n```\n\n## Elicitation\n\n`ElicitRequest::new(question)` uses a permissive single-text-field schema. `ElicitRequest::for_type::<Answer>(question)` derives a form schema from a `JsonSchema` type. Pass either request to `context.elicit(...)`, or use `context.elicit_as::<Answer>(request)` to decode accepted content.\n\nThe todo example derives its answer schema:\n\n```rust\nlet answer = context\n .elicit_as::<RenameTodoAnswer>(ElicitRequest::for_type::<RenameTodoAnswer>(\n format!(\"Rename \\\"{previous_text}\\\" to?\"),\n ))\n .await?;\n```\n\nAn accepted answer is `Some(Value)`, or `Some(Answer)` with `elicit_as`. Decline and cancel return `None`. Missing elicitation capability returns `TesseronErrorCode::ElicitationNotAvailable`. The host validates the JSON Schema before sending; an unsupported schema returns `InvalidParams` (`-32602`) at the call site, and no request reaches the agent. Top-level `oneOf`, `anyOf`, `allOf`, `not`, and object- or array-typed properties are refused.\n\n## Logs\n\n`context.log(LogEntry::info(message))` forwards a fire-and-forget log entry. Use `LogEntry::debug`, `LogEntry::warn`, or `LogEntry::error` for the other levels, and `.meta(...)` for structured metadata.\n\nThe session tests exercise the ordinary info level:\n\n```rust\ncontext.log(LogEntry::info(\"halfway\"));\n```\n\n## Capability checks and dropped transports\n\n`context.agent_capabilities()` is the negotiated capability set. Check it when a handler has a useful fallback. `context.agent()` identifies the caller, while `context.action_name()`, `context.invocation_id()`, `context.origin()`, and `context.route()` identify the running invocation.\n\nProgress and logs after a transport drop are discarded. Request methods such as `sample`, `confirm`, and `elicit` return an `ActionError` carrying `TesseronErrorCode::TransportClosed`, including when a cloned context is used after the connection has gone away. They fail instead of hanging."},{"slug":"sdk/rust/errors","title":"Errors (Rust)","description":"Host startup errors, handler failures, protocol envelopes, and the complete 17-code catalog.","section":"sdk","related":["sdk/rust/actions","sdk/rust/context","sdk/rust/index","protocol/errors"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nThe Rust SDK keeps three error types separate. `HostError` means the application could not start or shut down. `ActionError` is what a handler returns when an invocation fails. `ProtocolError` is the JSON-RPC error object that crosses the connection.\n\n## The code catalog\n\n`TesseronErrorCode` is the closed set of protocol codes. `as_wire_code()` returns the JSON-RPC integer, and `from_wire_code(...)` returns `None` for an integer this SDK does not define.\n\n| Code | Variant | When |\n| --- | --- | --- |\n| `-32700` | `ParseError` | The peer sent bytes that are not valid JSON. |\n| `-32600` | `InvalidRequest` | The envelope is not a valid JSON-RPC 2.0 message. |\n| `-32601` | `MethodNotFound` | The requested method is not part of the Tesseron protocol. |\n| `-32602` | `InvalidParams` | Method parameters do not match the documented shape, including an elicit schema MCP cannot render. |\n| `-32603` | `InternalError` | An unexpected failure occurred. Detail stays local. |\n| `-32000` | `ProtocolMismatch` | The host and gateway disagree on the protocol major version. |\n| `-32001` | `Cancelled` | The agent cancelled the invocation. |\n| `-32002` | `Timeout` | The invocation passed its action timeout. |\n| `-32003` | `ActionNotFound` | No action is registered under the requested name, or a resource is not readable or subscribable. |\n| `-32004` | `InputValidation` | The invocation input failed the action's declared schema. |\n| `-32005` | `HandlerError` | The handler reported a domain failure. |\n| `-32006` | `SamplingNotAvailable` | The agent did not negotiate sampling. |\n| `-32007` | `ElicitationNotAvailable` | The agent did not negotiate elicitation. |\n| `-32008` | `SamplingDepthExceeded` | The gateway's sampling-depth limit was exceeded. |\n| `-32009` | `Unauthorized` | The session is unclaimed or the operation is not permitted. |\n| `-32010` | `TransportClosed` | The transport closed while a request was in flight. |\n| `-32011` | `ResumeFailed` | The gateway refused the resume credentials. |\n\n## ActionError\n\nHandlers return `Result<Output, ActionError>`. Use `ActionError::handler(message)` for a domain failure that should reach the agent as `HandlerError`. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and optional structured `Value`. `ActionError::with_data(data)` adds detail to an existing error.\n\n`ActionError::internal(source)` keeps the source error in `internal_source()` and sends only `-32603 Internal error`. An unexpected error from a handler follows the same redacted path. This keeps panic messages, database URLs, and other local details off the wire.\n\n## ProtocolError\n\n`ProtocolError` represents the JSON-RPC `error` member with public `code: i32`, `message: String`, and optional `data: Value`. It keeps the raw integer so a newer peer's unknown code can round-trip. Call `named_code()` when you want `Option<TesseronErrorCode>`.\n\n`ProtocolError::new(code, message)` builds a known-code payload, and `.with_data(data)` attaches structured detail. The SDK turns gateway responses into `ActionError` when a handler's `sample`, `confirm`, or `elicit` request fails.\n\n## HostError\n\nThese errors occur before an invocation reaches a handler:\n\n| Variant | When |\n| --- | --- |\n| `MissingApplication` | No application was registered before `listen()`. |\n| `InvalidApplicationId(String)` | The id fails `^[a-z][a-z0-9_]*$` or is reserved. |\n| `InvalidTypedActionInputSchema { action_name, input_type_name }` | A typed action's derived or overridden input schema is not an object root. The error names both the action and Rust input type. |\n| `DuplicateName(String)` | Two actions or two resources use the same name. |\n| `NonLoopbackBindAddress(SocketAddr)` | `bind_address(...)` was given a non-loopback address. |\n| `Listen(io::Error)` | The loopback listener could not bind. |\n| `Manifest(io::Error)` | The instance manifest could not be written or removed. |\n| `HomeDirectoryUnknown` | The home directory for `~/.tesseron` could not be resolved. |\n\n`listen()` refuses a non-loopback address before binding. `shutdown().await` reports a manifest removal failure through `HostError::Manifest`.\n","bodyText":"<!-- snippets from examples/todo -->\n\nThe Rust SDK keeps three error types separate. `HostError` means the application could not start or shut down. `ActionError` is what a handler returns when an invocation fails. `ProtocolError` is the JSON-RPC error object that crosses the connection.\n\n## The code catalog\n\n`TesseronErrorCode` is the closed set of protocol codes. `as_wire_code()` returns the JSON-RPC integer, and `from_wire_code(...)` returns `None` for an integer this SDK does not define.\n\n| Code | Variant | When |\n| --- | --- | --- |\n| `-32700` | `ParseError` | The peer sent bytes that are not valid JSON. |\n| `-32600` | `InvalidRequest` | The envelope is not a valid JSON-RPC 2.0 message. |\n| `-32601` | `MethodNotFound` | The requested method is not part of the Tesseron protocol. |\n| `-32602` | `InvalidParams` | Method parameters do not match the documented shape, including an elicit schema MCP cannot render. |\n| `-32603` | `InternalError` | An unexpected failure occurred. Detail stays local. |\n| `-32000` | `ProtocolMismatch` | The host and gateway disagree on the protocol major version. |\n| `-32001` | `Cancelled` | The agent cancelled the invocation. |\n| `-32002` | `Timeout` | The invocation passed its action timeout. |\n| `-32003` | `ActionNotFound` | No action is registered under the requested name, or a resource is not readable or subscribable. |\n| `-32004` | `InputValidation` | The invocation input failed the action's declared schema. |\n| `-32005` | `HandlerError` | The handler reported a domain failure. |\n| `-32006` | `SamplingNotAvailable` | The agent did not negotiate sampling. |\n| `-32007` | `ElicitationNotAvailable` | The agent did not negotiate elicitation. |\n| `-32008` | `SamplingDepthExceeded` | The gateway's sampling-depth limit was exceeded. |\n| `-32009` | `Unauthorized` | The session is unclaimed or the operation is not permitted. |\n| `-32010` | `TransportClosed` | The transport closed while a request was in flight. |\n| `-32011` | `ResumeFailed` | The gateway refused the resume credentials. |\n\n## ActionError\n\nHandlers return `Result<Output, ActionError>`. Use `ActionError::handler(message)` for a domain failure that should reach the agent as `HandlerError`. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and optional structured `Value`. `ActionError::with_data(data)` adds detail to an existing error.\n\n`ActionError::internal(source)` keeps the source error in `internal_source()` and sends only `-32603 Internal error`. An unexpected error from a handler follows the same redacted path. This keeps panic messages, database URLs, and other local details off the wire.\n\n## ProtocolError\n\n`ProtocolError` represents the JSON-RPC `error` member with public `code: i32`, `message: String`, and optional `data: Value`. It keeps the raw integer so a newer peer's unknown code can round-trip. Call `named_code()` when you want `Option<TesseronErrorCode>`.\n\n`ProtocolError::new(code, message)` builds a known-code payload, and `.with_data(data)` attaches structured detail. The SDK turns gateway responses into `ActionError` when a handler's `sample`, `confirm`, or `elicit` request fails.\n\n## HostError\n\nThese errors occur before an invocation reaches a handler:\n\n| Variant | When |\n| --- | --- |\n| `MissingApplication` | No application was registered before `listen()`. |\n| `InvalidApplicationId(String)` | The id fails `^[a-z][a-z0-9_]*$` or is reserved. |\n| `InvalidTypedActionInputSchema { action_name, input_type_name }` | A typed action's derived or overridden input schema is not an object root. The error names both the action and Rust input type. |\n| `DuplicateName(String)` | Two actions or two resources use the same name. |\n| `NonLoopbackBindAddress(SocketAddr)` | `bind_address(...)` was given a non-loopback address. |\n| `Listen(io::Error)` | The loopback listener could not bind. |\n| `Manifest(io::Error)` | The instance manifest could not be written or removed. |\n| `HomeDirectoryUnknown` | The home directory for `~/.tesseron` could not be resolved. |\n\n`listen()` refuses a non-loopback address before binding. `shutdown().await` reports a manifest removal failure through `HostError::Manifest`."},{"slug":"sdk/rust/index","title":"Rust SDK","description":"The Rust implementation of the Tesseron host protocol, with typed actions, resources, and the full context API.","section":"sdk","related":["sdk/index","sdk/rust/actions","sdk/rust/conformance","sdk/porting","protocol/compatibility"],"bodyRaw":"\nSource: [github.com/Eigenwise/tesseron-rust](https://github.com/Eigenwise/tesseron-rust)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-rust)\n\n<!-- snippets from examples/todo -->\n\n`tesseron` is the Rust host SDK. Your application binds a loopback WebSocket, writes an instance manifest, and the MCP gateway dials in. The agent gets typed actions and readable resources from the process that owns the state.\n\nIt speaks protocol [**1.2.0**](/protocol/), the same version as the TypeScript and Python SDKs. Compatibility follows protocol version, never package numbers. See the [compatibility contract](/protocol/compatibility/).\n\nThe crate is published on [crates.io](https://crates.io/crates/tesseron). Install it with `cargo add tesseron`. Source and examples live in the `tesseron-rust` repository.\n\n## Requirements\n\nRust 1.85 or newer, edition 2024. The crate uses Tokio, `tokio-tungstenite`, Serde, Serde JSON, and Schemars. `Action::typed` inputs derive `Deserialize` and `JsonSchema`; serializable outputs can opt into a published schema.\n\n## A first host\n\nThis is the small host from the crate README. The `#` lines are doctest helpers kept by the source crate.\n\n```rust\nuse tesseron::{Action, ActionContext, ActionError, HostEvent, Tesseron};\nuse serde::{Deserialize, Serialize};\nuse schemars::JsonSchema;\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddTodo {\n title: String,\n}\n\n#[derive(Serialize, JsonSchema)]\nstruct Added {\n id: u64,\n}\n\nasync fn add_todo(input: AddTodo, _context: ActionContext) -> Result<Added, ActionError> {\n Ok(Added { id: store_todo(input.title) })\n}\n\n# fn store_todo(_title: String) -> u64 { 1 }\n# async fn example() -> Result<(), Box<dyn std::error::Error>> {\nlet builder = Tesseron::builder()\n .application(\"todo\", \"Todo\")\n .action(Action::typed(\"add_todo\", add_todo).output_schema_from_type::<Added>());\nlet mut events = builder.subscribe();\nlet host = builder.listen().await?;\n\nwhile let Ok(event) = events.recv().await {\n if let HostEvent::Welcome(welcome) = event {\n if let Some(code) = welcome.claim_code {\n println!(\"Claim this session with {code}\");\n }\n break;\n }\n}\nhost.shutdown().await?;\n# Ok(())\n# }\n```\n\nSubscribe before `listen()`. The gateway can finish the handshake before `listen()` returns, and `Welcome` carries the claim code for a fresh session. `host.url()` returns the loopback WebSocket URL when you need to inspect it or connect a test gateway.\n\nRun the complete headless todo app from the `tesseron-rust` repository root with `cargo run --manifest-path examples/todo/Cargo.toml`. It prints a claim code after the gateway connects. With the Tesseron plugin loaded in Claude Code, tell Claude Code to claim that code, then call the actions.\n\n## What it covers\n\nHandshake and claiming, session resume with in-memory token rotation, typed and raw JSON actions, input validation, cancellation, per-action timeouts, streaming progress, sampling, confirmation, schema-checked elicitation, structured logs, and resources with reads, subscriptions, and pushes are included.\n\nClaims are gateway-minted and transport is WebSocket only in this release. Host-minted bind claims and Unix domain sockets are outside the crate's shipped surface, so the [conformance suite](/sdk/rust/conformance/) skips those fixtures.\n\n## Manifest and shutdown\n\n`listen()` binds `127.0.0.1` on an OS-selected port and writes a v2 instance manifest into `~/.tesseron/instances/` once the URL is known. The directory is `0700`, the file is `0600`, and `shutdown().await` removes the manifest. Modes are advisory on Windows, where the user account is the access boundary.\n\n`host.welcome()` returns the most recent `WelcomeResult`, with `claim_code` cleared after the agent claims the session. `host.subscribe()` can observe later events. To catch the first welcome, use `builder.subscribe()` before `listen()` as shown above.\n\n## Next\n\n- [Actions](/sdk/rust/actions/): typed and raw handlers, schemas, timeouts, and fixed registrations.\n- [Resources](/sdk/rust/resources/): reads, subscriptions, emitters, and cleanup.\n- [Context](/sdk/rust/context/): progress, confirmation, elicitation, sampling, logs, and cancellation.\n- [Errors](/sdk/rust/errors/): `HostError`, `ActionError`, `ProtocolError`, and the 17 codes.\n- [Conformance](/sdk/rust/conformance/): build the private host and run the shared corpus.\n- [Tauri](/sdk/rust/tauri/): keep a host in `tauri::State` and update the window after agent mutations.\n","bodyText":"Source: [github.com/Eigenwise/tesseron-rust](https://github.com/Eigenwise/tesseron-rust)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-rust)\n\n<!-- snippets from examples/todo -->\n\n`tesseron` is the Rust host SDK. Your application binds a loopback WebSocket, writes an instance manifest, and the MCP gateway dials in. The agent gets typed actions and readable resources from the process that owns the state.\n\nIt speaks protocol [**1.2.0**](/protocol/), the same version as the TypeScript and Python SDKs. Compatibility follows protocol version, never package numbers. See the [compatibility contract](/protocol/compatibility/).\n\nThe crate is published on [crates.io](https://crates.io/crates/tesseron). Install it with `cargo add tesseron`. Source and examples live in the `tesseron-rust` repository.\n\n## Requirements\n\nRust 1.85 or newer, edition 2024. The crate uses Tokio, `tokio-tungstenite`, Serde, Serde JSON, and Schemars. `Action::typed` inputs derive `Deserialize` and `JsonSchema`; serializable outputs can opt into a published schema.\n\n## A first host\n\nThis is the small host from the crate README. The `#` lines are doctest helpers kept by the source crate.\n\n```rust\nuse tesseron::{Action, ActionContext, ActionError, HostEvent, Tesseron};\nuse serde::{Deserialize, Serialize};\nuse schemars::JsonSchema;\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddTodo {\n title: String,\n}\n\n#[derive(Serialize, JsonSchema)]\nstruct Added {\n id: u64,\n}\n\nasync fn add_todo(input: AddTodo, _context: ActionContext) -> Result<Added, ActionError> {\n Ok(Added { id: store_todo(input.title) })\n}\n\n# fn store_todo(_title: String) -> u64 { 1 }\n# async fn example() -> Result<(), Box<dyn std::error::Error>> {\nlet builder = Tesseron::builder()\n .application(\"todo\", \"Todo\")\n .action(Action::typed(\"add_todo\", add_todo).output_schema_from_type::<Added>());\nlet mut events = builder.subscribe();\nlet host = builder.listen().await?;\n\nwhile let Ok(event) = events.recv().await {\n if let HostEvent::Welcome(welcome) = event {\n if let Some(code) = welcome.claim_code {\n println!(\"Claim this session with {code}\");\n }\n break;\n }\n}\nhost.shutdown().await?;\n# Ok(())\n# }\n```\n\nSubscribe before `listen()`. The gateway can finish the handshake before `listen()` returns, and `Welcome` carries the claim code for a fresh session. `host.url()` returns the loopback WebSocket URL when you need to inspect it or connect a test gateway.\n\nRun the complete headless todo app from the `tesseron-rust` repository root with `cargo run --manifest-path examples/todo/Cargo.toml`. It prints a claim code after the gateway connects. With the Tesseron plugin loaded in Claude Code, tell Claude Code to claim that code, then call the actions.\n\n## What it covers\n\nHandshake and claiming, session resume with in-memory token rotation, typed and raw JSON actions, input validation, cancellation, per-action timeouts, streaming progress, sampling, confirmation, schema-checked elicitation, structured logs, and resources with reads, subscriptions, and pushes are included.\n\nClaims are gateway-minted and transport is WebSocket only in this release. Host-minted bind claims and Unix domain sockets are outside the crate's shipped surface, so the [conformance suite](/sdk/rust/conformance/) skips those fixtures.\n\n## Manifest and shutdown\n\n`listen()` binds `127.0.0.1` on an OS-selected port and writes a v2 instance manifest into `~/.tesseron/instances/` once the URL is known. The directory is `0700`, the file is `0600`, and `shutdown().await` removes the manifest. Modes are advisory on Windows, where the user account is the access boundary.\n\n`host.welcome()` returns the most recent `WelcomeResult`, with `claim_code` cleared after the agent claims the session. `host.subscribe()` can observe later events. To catch the first welcome, use `builder.subscribe()` before `listen()` as shown above.\n\n## Next\n\n- [Actions](/sdk/rust/actions/): typed and raw handlers, schemas, timeouts, and fixed registrations.\n- [Resources](/sdk/rust/resources/): reads, subscriptions, emitters, and cleanup.\n- [Context](/sdk/rust/context/): progress, confirmation, elicitation, sampling, logs, and cancellation.\n- [Errors](/sdk/rust/errors/): `HostError`, `ActionError`, `ProtocolError`, and the 17 codes.\n- [Conformance](/sdk/rust/conformance/): build the private host and run the shared corpus.\n- [Tauri](/sdk/rust/tauri/): keep a host in `tauri::State` and update the window after agent mutations."},{"slug":"sdk/rust/resources","title":"Resources (Rust)","description":"Readable and subscribable application state with ResourceEmitter pushes and cleanup.","section":"sdk","related":["sdk/rust/index","sdk/rust/actions","protocol/resources"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nA resource is named application state the agent can read, and optionally follow. Actions change things; resources report the current value.\n\n## Registering a resource\n\n`Resource::new(name, read)` takes a synchronous callback that returns a future resolving to `Result<Value, ActionError>`. The callback runs on every `resources/read`, so it reads current state rather than a snapshot captured during registration.\n\nThe todo example registers a readable and subscribable `todos://all` resource like this:\n\n```rust\nfn todo_resource(todos: TodoList) -> Resource {\n let resource_todos = todos.clone();\n\n Resource::new(\"todos://all\", move || {\n let todos = resource_todos.clone();\n async move {\n let todos = todos.snapshot()?;\n serde_json::to_value(todos).map_err(ActionError::internal)\n }\n })\n .description(\"The complete todo list. Pushed on every mutation.\")\n .subscribe(move |emitter| {\n let mut updates = todos.subscribe();\n let task = tokio::spawn(async move {\n while let Ok(todos) = updates.recv().await {\n if let Ok(value) = serde_json::to_value(todos) {\n emitter.emit(value);\n }\n }\n });\n Subscription::new(move || task.abort())\n })\n}\n```\n\n`.description(...)` publishes the text the agent sees. Calling `.subscribe(..)` marks the resource as subscribable and gives the callback one `ResourceEmitter` for that subscription.\n\n## Pushing updates\n\n`ResourceEmitter::emit(Value)` sends a `resources/updated` notification to the agent subscribed through that emitter. It is fire-and-forget. Emitting after the transport closes or after unsubscribe is dropped. Cloning an emitter keeps the same subscription id, which lets a spawned task keep pushing until its `Subscription` cleanup runs.\n\n`Subscription::new(stop)` stores a `FnOnce` cleanup. The SDK runs it when the agent unsubscribes and when the transport closes. Use `Subscription::without_cleanup()` when the callback started nothing that needs teardown.\n\nThe subscribe callback is synchronous. Start the event source and return the subscription promptly. A spawned task, as in the example, can wait for updates without blocking the session loop.\n\n## Wire behavior\n\n`resources/subscribe` and `resources/unsubscribe` acknowledge with `result: null`. The acknowledgement is sent before the subscriber starts, so an immediate push cannot overtake it. Unsubscribing an unknown id is harmless.\n\nReading an undeclared resource returns `TesseronErrorCode::ActionNotFound` with `Resource not readable: <name>`. Subscribing to an undeclared or non-subscribable resource returns the same code with `Resource not subscribable: <name>`.\n\nA reader can return `ActionError` for a domain failure. An unexpected reader error is reported as `-32603 Internal error`.\n\n## Registration lifetime\n\nResource registrations are fixed once `listen()` runs. Runtime add and remove with list-change notifications is SQ-42 and is not shipped. Register every `Resource` on `TesseronHostBuilder` before starting the host.\n","bodyText":"<!-- snippets from examples/todo -->\n\nA resource is named application state the agent can read, and optionally follow. Actions change things; resources report the current value.\n\n## Registering a resource\n\n`Resource::new(name, read)` takes a synchronous callback that returns a future resolving to `Result<Value, ActionError>`. The callback runs on every `resources/read`, so it reads current state rather than a snapshot captured during registration.\n\nThe todo example registers a readable and subscribable `todos://all` resource like this:\n\n```rust\nfn todo_resource(todos: TodoList) -> Resource {\n let resource_todos = todos.clone();\n\n Resource::new(\"todos://all\", move || {\n let todos = resource_todos.clone();\n async move {\n let todos = todos.snapshot()?;\n serde_json::to_value(todos).map_err(ActionError::internal)\n }\n })\n .description(\"The complete todo list. Pushed on every mutation.\")\n .subscribe(move |emitter| {\n let mut updates = todos.subscribe();\n let task = tokio::spawn(async move {\n while let Ok(todos) = updates.recv().await {\n if let Ok(value) = serde_json::to_value(todos) {\n emitter.emit(value);\n }\n }\n });\n Subscription::new(move || task.abort())\n })\n}\n```\n\n`.description(...)` publishes the text the agent sees. Calling `.subscribe(..)` marks the resource as subscribable and gives the callback one `ResourceEmitter` for that subscription.\n\n## Pushing updates\n\n`ResourceEmitter::emit(Value)` sends a `resources/updated` notification to the agent subscribed through that emitter. It is fire-and-forget. Emitting after the transport closes or after unsubscribe is dropped. Cloning an emitter keeps the same subscription id, which lets a spawned task keep pushing until its `Subscription` cleanup runs.\n\n`Subscription::new(stop)` stores a `FnOnce` cleanup. The SDK runs it when the agent unsubscribes and when the transport closes. Use `Subscription::without_cleanup()` when the callback started nothing that needs teardown.\n\nThe subscribe callback is synchronous. Start the event source and return the subscription promptly. A spawned task, as in the example, can wait for updates without blocking the session loop.\n\n## Wire behavior\n\n`resources/subscribe` and `resources/unsubscribe` acknowledge with `result: null`. The acknowledgement is sent before the subscriber starts, so an immediate push cannot overtake it. Unsubscribing an unknown id is harmless.\n\nReading an undeclared resource returns `TesseronErrorCode::ActionNotFound` with `Resource not readable: <name>`. Subscribing to an undeclared or non-subscribable resource returns the same code with `Resource not subscribable: <name>`.\n\nA reader can return `ActionError` for a domain failure. An unexpected reader error is reported as `-32603 Internal error`.\n\n## Registration lifetime\n\nResource registrations are fixed once `listen()` runs. Runtime add and remove with list-change notifications is SQ-42 and is not shipped. Register every `Resource` on `TesseronHostBuilder` before starting the host."},{"slug":"sdk/rust/tauri","title":"Tauri (Rust)","description":"Put a Rust host in tauri::State and refresh the window when an agent mutates shared todo state.","section":"sdk","related":["sdk/rust/index","sdk/rust/resources","sdk/rust/context","examples/vanilla-todo"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nThe `tauri-todo` example uses the same Rust host as a headless app. `setup()` creates the application from `examples/todo`, stores the host in Tauri state, and forwards updates to the window.\n\n## The setup pattern\n\nThis is the setup closure from `examples/tauri-todo/src/main.rs`:\n\n```rust\nfn main() {\n let application = tauri::Builder::default()\n .setup(|application| {\n let (builder, todos) = todo_application(\"rust_tauri_todo\", \"Rust Tauri Todo\");\n let events = builder.subscribe();\n let host = tauri::async_runtime::block_on(builder.listen())?;\n\n forward_todo_updates(application.handle().clone(), &todos);\n forward_connection_updates(application.handle().clone(), events);\n application.manage(todos);\n application.manage(TesseronState::new(Arc::new(host)));\n Ok(())\n })\n .invoke_handler(tauri::generate_handler![\n list_todos,\n add_todo,\n toggle_todo,\n delete_todo,\n connection_status\n ])\n .build(tauri::generate_context!())\n .expect(\"error while building Tesseron Todo\");\n```\n\nThe full example keeps `Arc<TesseronHost>` inside `TesseronState`, wrapped in a `Mutex<Option<...>>`. Tauri commands read and mutate the shared `TodoList`. On `RunEvent::Exit`, the app takes the host from state and calls `host.shutdown()` so the accept loop stops and the manifest is removed.\n\n`todo_application(...)` comes from `examples/todo/src/lib.rs`. It owns the action registrations, typed input and output shapes, and the `todos://all` resource. The headless and Tauri binaries import that same function, so their agent surface stays aligned.\n\n## Refreshing the window\n\nThe shared list publishes a new snapshot after every mutation. The Tauri side listens to that channel and emits `todos-updated`:\n\n```rust\nfn forward_todo_updates(application_handle: AppHandle, todos: &TodoList) {\n let mut updates = todos.subscribe();\n tauri::async_runtime::spawn(async move {\n while let Ok(todos) = updates.recv().await {\n if application_handle.emit(TODO_UPDATED_EVENT, todos).is_err() {\n break;\n }\n }\n });\n}\n```\n\nThe frontend listens for the `todos-updated` event and replaces its list. Agent mutations therefore update the open window without a refresh. Connection events follow the same pattern with `connection-updated`; `HostEvent::Welcome` exposes the claim code, `HostEvent::Claimed` identifies the agent, and `HostEvent::Disconnected` reports a dropped gateway connection.\n\n## Run the example\n\nThe exact Windows sequence is in the crate README: install the Tauri CLI, check the example, change into its directory, and run `cargo tauri dev`. The window shows the claim code from the gateway. Claim it in Claude Code, then call `rust_tauri_todo__addTodo`; the new item appears in the list.\n\nTauri is checked separately on Windows in CI. The main Rust workspace checks exclude `tauri-todo` on Linux because its GTK and WebKit development stack adds desktop dependencies without adding protocol coverage.\n","bodyText":"<!-- snippets from examples/todo -->\n\nThe `tauri-todo` example uses the same Rust host as a headless app. `setup()` creates the application from `examples/todo`, stores the host in Tauri state, and forwards updates to the window.\n\n## The setup pattern\n\nThis is the setup closure from `examples/tauri-todo/src/main.rs`:\n\n```rust\nfn main() {\n let application = tauri::Builder::default()\n .setup(|application| {\n let (builder, todos) = todo_application(\"rust_tauri_todo\", \"Rust Tauri Todo\");\n let events = builder.subscribe();\n let host = tauri::async_runtime::block_on(builder.listen())?;\n\n forward_todo_updates(application.handle().clone(), &todos);\n forward_connection_updates(application.handle().clone(), events);\n application.manage(todos);\n application.manage(TesseronState::new(Arc::new(host)));\n Ok(())\n })\n .invoke_handler(tauri::generate_handler![\n list_todos,\n add_todo,\n toggle_todo,\n delete_todo,\n connection_status\n ])\n .build(tauri::generate_context!())\n .expect(\"error while building Tesseron Todo\");\n```\n\nThe full example keeps `Arc<TesseronHost>` inside `TesseronState`, wrapped in a `Mutex<Option<...>>`. Tauri commands read and mutate the shared `TodoList`. On `RunEvent::Exit`, the app takes the host from state and calls `host.shutdown()` so the accept loop stops and the manifest is removed.\n\n`todo_application(...)` comes from `examples/todo/src/lib.rs`. It owns the action registrations, typed input and output shapes, and the `todos://all` resource. The headless and Tauri binaries import that same function, so their agent surface stays aligned.\n\n## Refreshing the window\n\nThe shared list publishes a new snapshot after every mutation. The Tauri side listens to that channel and emits `todos-updated`:\n\n```rust\nfn forward_todo_updates(application_handle: AppHandle, todos: &TodoList) {\n let mut updates = todos.subscribe();\n tauri::async_runtime::spawn(async move {\n while let Ok(todos) = updates.recv().await {\n if application_handle.emit(TODO_UPDATED_EVENT, todos).is_err() {\n break;\n }\n }\n });\n}\n```\n\nThe frontend listens for the `todos-updated` event and replaces its list. Agent mutations therefore update the open window without a refresh. Connection events follow the same pattern with `connection-updated`; `HostEvent::Welcome` exposes the claim code, `HostEvent::Claimed` identifies the agent, and `HostEvent::Disconnected` reports a dropped gateway connection.\n\n## Run the example\n\nThe exact Windows sequence is in the crate README: install the Tauri CLI, check the example, change into its directory, and run `cargo tauri dev`. The window shows the claim code from the gateway. Claim it in Claude Code, then call `rust_tauri_todo__addTodo`; the new item appears in the list.\n\nTauri is checked separately on Windows in CI. The main Rust workspace checks exclude `tauri-todo` on Linux because its GTK and WebKit development stack adds desktop dependencies without adding protocol coverage."},{"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":"\nSource: [github.com/Eigenwise/tesseron-typescript](https://github.com/Eigenwise/tesseron-typescript)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-typescript)\n\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":"Source: [github.com/Eigenwise/tesseron-typescript](https://github.com/Eigenwise/tesseron-typescript)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-typescript)\n\n## 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/eigenwise/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/eigenwise/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## How the plugin gets it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo ships no bundled gateway. `plugin/.mcp.json` fetches the published package instead, pinned to the plugin's own version:\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": { \"type\": \"stdio\", \"command\": \"npx\", \"args\": [\"-y\", \"@tesseron/mcp@2.10.1\"] }\n }\n}\n```\n\nThat pin is one of eight surfaces carrying the plugin version, all owned by `scripts/sync-plugin-version.mjs`. Run `pnpm sync-plugin-version` to fix drift; CI runs `--check`.\n\nIf you're hacking on the gateway, point the plugin at your checkout rather than editing the pin.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `gateway/src/cli.ts` - entry point.\n- `gateway/src/gateway.ts` - session management, dialer dispatcher, instances-directory watcher.\n- `gateway/src/dialer.ts` - per-binding dialers (`WsDialer`, `UdsDialer`).\n- `gateway/src/session.ts` - a single session's state + claim code.\n- `gateway/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/eigenwise/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/eigenwise/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## How the plugin gets it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo ships no bundled gateway. `plugin/.mcp.json` fetches the published package instead, pinned to the plugin's own version:\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": { \"type\": \"stdio\", \"command\": \"npx\", \"args\": [\"-y\", \"@tesseron/mcp@2.10.1\"] }\n }\n}\n```\n\nThat pin is one of eight surfaces carrying the plugin version, all owned by `scripts/sync-plugin-version.mjs`. Run `pnpm sync-plugin-version` to fix drift; CI runs `--check`.\n\nIf you're hacking on the gateway, point the plugin at your checkout rather than editing the pin.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `gateway/src/cli.ts` - entry point.\n- `gateway/src/gateway.ts` - session management, dialer dispatcher, instances-directory watcher.\n- `gateway/src/dialer.ts` - per-binding dialers (`WsDialer`, `UdsDialer`).\n- `gateway/src/session.ts` - a single session's state + claim code.\n- `gateway/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."}]}
1
+ {"version":"a1c8064","generatedAt":"2026-09-09T13:31:17.238Z","count":64,"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/Eigenwise/tesseron-typescript/tree/main/examples/express-prompts)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/express-prompts)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/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/Eigenwise/tesseron-typescript\ncd tesseron-typescript\npnpm install --frozen-lockfile\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/Eigenwise/tesseron-typescript/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/Eigenwise/tesseron-typescript\ncd tesseron-typescript\npnpm install --frozen-lockfile\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/Eigenwise/tesseron-typescript/tree/main/examples/node-prompts)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/node-prompts)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/react-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/react-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/svelte-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/svelte-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/vanilla-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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 three subscribable resources (`currentFilter`, `todoStats`, and `todos://all`, the full list) - 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/Eigenwise/tesseron-typescript/tree/main/examples/vanilla-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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 three subscribable resources (`currentFilter`, `todoStats`, and `todos://all`, the full list) - 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/Eigenwise/tesseron-typescript/tree/main/examples/vue-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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/Eigenwise/tesseron-typescript/tree/main/examples/vue-todo)\n\n## Run it\n\nFrom the `tesseron-typescript` repository root, after [installing its dependencies](/examples/#running-any-of-them):\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":"An accessibility layer for AI agents. Expose typed app actions to MCP-compatible 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 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**The agent doesn't need to click a button - it needs to do the thing the button does.** You declare the typed actions your app already performs; Tesseron exposes them to any MCP agent as tools, and your real handler runs in your real process against your real state. It's a protocol, not just a TypeScript library, and not just for the web - the SDKs cover browser, Node, and desktop today, and anything that speaks WebSocket + JSON-RPC 2.0 can host actions in any language ([Python and Rust are on the roadmap](/sdk/porting/)).\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 use the Rust and Python SDKs. Port Tesseron to another language when you need one.\"\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":"**The agent doesn't need to click a button - it needs to do the thing the button does.** You declare the typed actions your app already performs; Tesseron exposes them to any MCP agent as tools, and your real handler runs in your real process against your real state. It's a protocol, not just a TypeScript library, and not just for the web - the SDKs cover browser, Node, and desktop today, and anything that speaks WebSocket + JSON-RPC 2.0 can host actions in any language ([Python and Rust are on the roadmap](/sdk/porting/)).\n\n## 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 eigenwise/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/what-you-can-build","title":"What you can build","description":"Worked scenarios for Tesseron - a live copilot, bulk operations, a desktop app, a headless service - written agnostic to your stack, with the bindings and languages covered at the end.","section":"overview","related":["overview/why","overview/quickstart","sdk/index"],"bodyRaw":"\nTesseron is an accessibility layer for AI agents: you declare the typed actions your app already performs, and any MCP-compatible agent can call them against your real, running state. This page is about what that lets you build.\n\nNone of these scenarios care what your app is built with. You declare an app, some actions, and a resource or two with one builder, then connect - the same three steps whether your app is a browser tab, a desktop app, or a background service with no UI at all. The examples show the `tesseron` builder directly; where you import it from - and the framework adapters and other-language bindings - is covered at the end, under [Bindings and languages](#bindings-and-languages). Every code block uses the real API.\n\n## A live copilot inside a complex editor\n\nYou are building an editor - say a video editor - and you want an agent that co-edits alongside the human, drafting a rough cut the human then fine-tunes by hand. You declare the editor's real operations once, and the agent calls them straight against your live app.\n\n**The problem.** Dragging clips onto a timeline, snapping them in order, nudging trim handles - that is exactly the fiddly, pixel-precise UI manipulation that browser automation gets wrong, slowly, one brittle round-trip at a time. But \"drop these 6 clips in this order, then a title card before clip 3\" is one structured intent. The agent does not need to drive your timeline widget, it needs to do the thing the widget does.\n\n**What you declare.** A bulk timeline action, a title-card insert, a long-running preview render, an irreversible export, and a subscribable resource for the current timeline so the agent reads structured state instead of scraping the UI.\n\n```ts\nimport { z } from \"zod\";\n\ntesseron.app({ id: \"video_editor\", name: \"Video Editor\" });\n\ntesseron.action(\"add_clips_to_timeline\")\n .describe(\"Insert multiple clips onto the timeline in one ordered batch\")\n .input(z.object({\n trackId: z.string(),\n clips: z.array(z.object({ assetId: z.string(), startMs: z.number() })),\n }))\n .handler((input) => {\n // one-shot bulk insert against your real editor store\n return editorStore.insertClips(input.trackId, input.clips);\n });\n\ntesseron.action(\"insert_title_card\")\n .describe(\"Insert a title card at a given position\")\n .input(z.object({ trackId: z.string(), atMs: z.number(), text: z.string() }))\n .handler((input) => editorStore.insertTitleCard(input));\n\ntesseron.action(\"render_preview\")\n .describe(\"Render a preview of the current timeline\")\n .input(z.object({ fromMs: z.number(), toMs: z.number() }))\n .annotate({ readOnly: true })\n .handler(async (input, ctx) => {\n // long-running: stream progress and forward cancellation\n return renderer.preview(input, {\n signal: ctx.signal,\n onProgress: (percent) =>\n ctx.progress({ message: \"rendering preview\", percent }),\n });\n });\n\ntesseron.action(\"export_video\")\n .describe(\"Export the final video to a file (expensive, irreversible)\")\n .input(z.object({ format: z.enum([\"mp4\", \"webm\"]), quality: z.enum([\"1080p\", \"4k\"]) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .timeout({ ms: 600000 })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({\n question: `Export the full timeline as ${input.quality} ${input.format}? This burns render minutes.`,\n });\n if (!ok) return { exported: false };\n return renderer.export(input, {\n signal: ctx.signal,\n onProgress: (percent) => ctx.progress({ message: \"exporting\", percent }),\n });\n });\n\ntesseron.resource(\"timeline\")\n .describe(\"The current timeline: tracks, clips, and ordering\")\n .read(() => editorStore.getTimeline())\n .subscribe((emit) => {\n emit(editorStore.getTimeline()); // initial value\n const off = editorStore.on(\"change\", () => emit(editorStore.getTimeline()));\n return () => off(); // cleanup the listener\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(\"Pair the agent with this code:\", welcome.claimCode);\n```\n\n**What the agent does.** When the human says \"build me a 30 second rough cut from the beach footage, clips in chronological order,\" the agent reads the `timeline` resource for available assets, then makes a single `video_editor__add_clips_to_timeline` call with all six clips ordered - no dragging, no per-clip round-trips. \"Put a title card that says Day One before the third clip\" is one `insert_title_card` call. When the human says \"let me see it,\" the agent calls `render_preview`, which streams `ctx.progress` so the human watches the percent climb and can cancel mid-render through `ctx.signal`. Then the human takes over - nudging a trim handle, sliding clip four left by 200ms - and the agent sees the edit because the `timeline` resource re-emits. Finally \"ship the 4K mp4\" triggers `export_video`, which gates on `ctx.confirm`; if the agent's host cannot prompt, `ctx.confirm` returns false and nothing irreversible runs.\n\n*Dragging clips is the UI manipulation automation fails at; \"insert these 6 clips in this order\" is one typed action, and your real handler runs against your real editor state.*\n\n## One-shot bulk operations across a data-heavy app\n\nAn internal admin panel or data dashboard already has buttons for refunds, tagging, and bans. You expose those same handlers to the agent so it can run them in bulk, across exactly what the operator is looking at.\n\n**The problem.** Every bulk job is N brittle UI round-trips: select a row, click refund, confirm the modal, wait, repeat for the next forty orders. The agent doesn't need to click those buttons - it needs to do the thing the buttons do, once, across the whole selection.\n\n**What you declare.** Register the same handlers your buttons already call, plus a resource that exposes the live table filter and row selection so the agent can act on the current view.\n\n```ts\nimport { z } from \"zod\";\n\ntesseron.app({ id: \"dashboard\", name: \"Admin Dashboard\" });\n\n// expose the live table filter + selection\ntesseron.resource(\"current_selection\")\n .describe(\"The operator's current table filter and selected row ids\")\n .read(() => store.getSelection()) // { filter, orderIds, customerIds, userIds }\n .subscribe((emit) => {\n const onChange = () => emit(store.getSelection());\n store.on(\"change\", onChange);\n return () => store.off(\"change\", onChange); // cleanup the listener\n });\n\ntesseron.action(\"refund_orders\")\n .describe(\"Refund every order in the given list\")\n .input(z.object({ orderIds: z.array(z.string()).min(1) }))\n .handler(async ({ orderIds }, ctx) => {\n let done = 0;\n for (const id of orderIds) {\n await refundOrder(id); // same code path the refund button calls\n ctx.progress({ message: `refunded ${id}`, percent: ++done / orderIds.length });\n }\n return { refunded: done };\n });\n\ntesseron.action(\"tag_customers\")\n .describe(\"Apply a tag to a list of customers\")\n .input(z.object({ customerIds: z.array(z.string()).min(1), tag: z.string() }))\n .handler(async ({ customerIds, tag }) => {\n await tagCustomers(customerIds, tag); // your real customer store\n return { tagged: customerIds.length };\n });\n\ntesseron.action(\"ban_users\")\n .describe(\"Permanently ban a list of users\")\n .input(z.object({ userIds: z.array(z.string()).min(1), reason: z.string() }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async ({ userIds, reason }, ctx) => {\n const ok = await ctx.confirm({\n question: `Ban ${userIds.length} users for \"${reason}\"? This is irreversible.`,\n });\n if (!ok) return { banned: 0 };\n await banUsers(userIds, reason); // same code path the ban button calls\n return { banned: userIds.length };\n });\n\nawait tesseron.connect();\n```\n\n`ban_users` is annotated destructive, and the `ctx.confirm` gate returns `false` on decline and on agents without elicitation - so the safe path needs no capability guard. The `current_selection` resource is subscribable, so the agent always acts on the operator's current view rather than a stale snapshot.\n\n**What the agent does.** An operator filters the table to last week's failed charges, selects the rows, and types \"refund the orders I currently have selected.\" The agent reads the `current_selection` resource, pulls the `orderIds`, and calls `dashboard__refund_orders` with the whole array - five clicks become one call, and `ctx.progress` streams each refund back into the chat. \"Tag everyone in this view as priority_support\" reads the same resource and fires `dashboard__tag_customers` once. \"Ban these three accounts for fraud\" triggers `dashboard__ban_users`, which calls `ctx.confirm` - the operator approves in the agent, the ban runs, and a decline returns `{ banned: 0 }`.\n\n*Your real handler runs against your real state - no separate MCP server, no backend duplication, just one-shot bulk where the UI made you click N times.*\n\n## A desktop or local-first app\n\nNot every app is a web page. A desktop tool - a markdown notes vault, a local-database GUI, an offline knowledge base - exposes actions exactly the same way. Tesseron runs inside your app's own process (for Electron or Tauri, the main process), mutates real state on disk, and pushes the result to your UI; no browser tab is involved anywhere.\n\n**The problem.** A desktop app has no public API and no URL an agent can hit - the only way in is the UI, so an agent would have to drive menus, dialogs, and a tree view it cannot see. \"Reorganize my 400 notes into folders by topic\" is hundreds of brittle clicks. The agent doesn't need to click the New Note button, it needs to do the thing that button does, against your real vault.\n\n**What you declare.** The same builder, running in the process that owns the files.\n\n```ts\nimport { z } from \"zod\";\n\ntesseron.app({\n id: \"notes_vault\",\n name: \"Notes Vault\",\n description: \"A local markdown notes vault.\",\n});\n\nconst folderSchema = z.object({ folder: z.string() });\n\ntesseron.action(\"create_note\")\n .describe(\"Create a markdown note in a folder.\")\n .input(z.object({ title: z.string(), body: z.string(), folder: z.string().optional() }))\n .handler(async (input, ctx) => {\n let folder = input.folder;\n if (!folder) {\n // no destination given - ask the human which folder\n const picked = await ctx.elicit({\n question: \"Which folder should this note go in?\",\n schema: folderSchema,\n jsonSchema: z.toJSONSchema(folderSchema),\n });\n if (picked === null) return { created: false };\n folder = picked.folder;\n }\n // writes a real .md file and notifies the UI\n const path = await vault.writeNote(folder, input.title, input.body);\n return { created: true, path };\n });\n\ntesseron.action(\"search_notes\")\n .describe(\"Full-text search across the vault.\")\n .input(z.object({ query: z.string() }))\n .annotate({ readOnly: true })\n .handler(async (input) => {\n // runs against your real local index\n return { hits: await vault.search(input.query) };\n });\n\ntesseron.action(\"organize_vault\")\n .describe(\"Move notes into topic folders in bulk.\")\n .input(z.object({ moves: z.array(z.object({ path: z.string(), folder: z.string() })) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({ question: `Move ${input.moves.length} notes?` });\n if (!ok) return { moved: 0 };\n for (let i = 0; i < input.moves.length; i++) {\n ctx.progress({ message: `moving ${input.moves[i].path}`, percent: (i / input.moves.length) * 100 });\n await vault.move(input.moves[i].path, input.moves[i].folder); // real fs move\n }\n return { moved: input.moves.length };\n });\n\ntesseron.resource(\"vault_tree\")\n .describe(\"The current folder and note tree.\")\n .read(() => vault.tree())\n .subscribe((emit) => {\n const onChange = () => emit(vault.tree());\n vault.on(\"change\", onChange);\n return () => vault.off(\"change\", onChange); // tear down the watcher\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(`Pair the agent with claim code: ${welcome.claimCode}`);\n```\n\n**What the agent does.** \"Jot down a note about today's standup\" calls `notes_vault__create_note` with no folder, so the handler runs `ctx.elicit` and a native dialog asks which folder - you pick \"Work\", the note is written, and your UI refreshes. \"Reorganize my vault by topic\" reads the `vault_tree` resource, then calls `notes_vault__organize_vault`; because it is annotated destructive, `ctx.confirm` gates the move (\"Move 412 notes?\") and `ctx.progress` streams each file as it lands. \"Find everything I wrote about Postgres\" hits the read-only `notes_vault__search_notes` and returns hits straight from your local index. One bulk call replaces hundreds of drag-and-drop round-trips.\n\n*The agent does the thing your buttons do - your real handler runs against your real vault, no browser in sight.*\n\n## A service or daemon with no UI\n\nSome apps have no UI at all - a deploy runner, a data-pipeline supervisor, an internal CLI. There is nothing to render; there are just typed handlers in a long-running process. Expose them and an agent can operate the service directly.\n\n**The problem.** To let an agent drive a service like this, you would normally stand up a parallel REST API just to feed an LLM - new routes, new auth, new serialization - and then watch it drift from the real internal functions it wraps. Tesseron skips that layer: you expose your existing typed handlers directly, and your real handler runs against your real state.\n\n**What you declare.**\n\n```ts\nimport { z } from \"zod\";\n\ntesseron.app({\n id: \"deploy_ops\",\n name: \"Deploy Ops Daemon\",\n description: \"Headless control plane for deploys and rollbacks\",\n});\n\ntesseron.action(\"trigger_deploy\")\n .describe(\"Build and roll out a service to an environment. Returns the result.\")\n .input(z.object({ service: z.string(), env: z.enum([\"staging\", \"prod\"]), ref: z.string() }))\n .timeout({ ms: 600_000 })\n .handler(async (input, ctx) => {\n ctx.progress({ message: `building ${input.service}@${input.ref}`, percent: 10 });\n // runs against your real deploy pipeline; signal cancels the in-flight rollout\n const res = await fetch(`http://internal/deploy/${input.service}`, {\n method: \"POST\",\n body: JSON.stringify(input),\n signal: ctx.signal,\n });\n ctx.progress({ message: \"rollout complete\", percent: 100 });\n\n // optionally have the agent summarize the tail of the deploy log\n if (ctx.agentCapabilities.sampling) {\n const tail = await getLogTail(input.service); // your real log store\n const summary = await ctx.sample({\n prompt: `Summarize this deploy log tail in two lines:\\n${tail}`,\n maxTokens: 200,\n });\n return { ok: res.ok, summary };\n }\n return { ok: res.ok };\n });\n\ntesseron.action(\"rollback\")\n .describe(\"Roll a service back to its previous release.\")\n .input(z.object({ service: z.string(), env: z.enum([\"staging\", \"prod\"]) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({\n question: `Roll back ${input.service} in ${input.env} to the previous release?`,\n });\n if (!ok) return { rolledBack: false };\n await rollbackService(input.service, input.env); // your real state\n return { rolledBack: true };\n });\n\ntesseron.resource(\"deploy_status\")\n .describe(\"Live status of in-flight and recent deploys.\")\n .read(() => readDeployStatus()) // your real status store\n .subscribe((emit) => {\n emit(readDeployStatus()); // initial value so the first read resolves\n const off = onDeployChange((status) => emit(status)); // your event source\n return () => off(); // cleanup the listener\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(`Pair the agent with claim code: ${welcome.claimCode}`);\n```\n\n**What the agent does.** When you say \"ship payments at ref a1b2c3 to staging\", the agent calls `deploy_ops__trigger_deploy`; your handler streams `ctx.progress` updates as the build and rollout advance, forwards `ctx.signal` so a cancel actually aborts the fetch, and - if the connected agent supports sampling - uses `ctx.sample` to fold a log tail into a two-line summary. \"What is deploying right now?\" reads the `deploy_status` resource and, because it is subscribable, the agent watches it change live instead of polling. \"Roll back prod payments\" hits the rollback action, where `ctx.confirm` gates the destructive step - decline, or connect an agent without elicitation, and it returns false, so nothing happens.\n\n*Expose your typed actions instead of standing up a parallel REST API just to feed an LLM - the agent does not need a button to click, it needs the thing the button does.*\n\n## Bindings and languages\n\nNothing above was specific to a framework. The `tesseron` builder is the same whichever package you import it from - pick the one that matches your runtime:\n\n- **`@tesseron/web`** - any browser app, vanilla or alongside any framework.\n- **`@tesseron/react`**, **`@tesseron/svelte`**, **`@tesseron/vue`** - ergonomic adapters that register actions and resources from inside your components and tear them down on unmount. The admin and editor examples above could use these instead of the bare builder; the declared actions are identical.\n- **`@tesseron/server`** - Node: backend services, CLIs, daemons, and the main process of an Electron or Tauri desktop app. The desktop and daemon examples above run on this.\n- **`@tesseron/core`** - the builder and protocol types with no transport, for wiring your own.\n\nAnd it is not limited to TypeScript. Tesseron is a protocol - the spec is published under CC BY 4.0 - and the JS/TS packages are the reference implementation, not the only possible one. Anything that speaks JSON-RPC 2.0 over a duplex channel can host actions: a Python data daemon, a Rust Tauri app, a .NET line-of-business tool. Those SDKs are not written yet - today TypeScript ships, and a Python SDK and Rust bindings for Tauri are on the roadmap. To expose actions from another language right now, [port the protocol](/sdk/porting/) - it is a small wire contract; for the planned Python SDK and its status, see [its page](/sdk/python/).\n\n## Where to start\n\nThe [5-minute quickstart](/overview/quickstart/) takes any runtime from zero to a claimed session. Then browse the [SDK overview](/sdk/) for the package that fits your stack.\n","bodyText":"Tesseron is an accessibility layer for AI agents: you declare the typed actions your app already performs, and any MCP-compatible agent can call them against your real, running state. This page is about what that lets you build.\n\nNone of these scenarios care what your app is built with. You declare an app, some actions, and a resource or two with one builder, then connect - the same three steps whether your app is a browser tab, a desktop app, or a background service with no UI at all. The examples show the `tesseron` builder directly; where you import it from - and the framework adapters and other-language bindings - is covered at the end, under [Bindings and languages](#bindings-and-languages). Every code block uses the real API.\n\n## A live copilot inside a complex editor\n\nYou are building an editor - say a video editor - and you want an agent that co-edits alongside the human, drafting a rough cut the human then fine-tunes by hand. You declare the editor's real operations once, and the agent calls them straight against your live app.\n\n**The problem.** Dragging clips onto a timeline, snapping them in order, nudging trim handles - that is exactly the fiddly, pixel-precise UI manipulation that browser automation gets wrong, slowly, one brittle round-trip at a time. But \"drop these 6 clips in this order, then a title card before clip 3\" is one structured intent. The agent does not need to drive your timeline widget, it needs to do the thing the widget does.\n\n**What you declare.** A bulk timeline action, a title-card insert, a long-running preview render, an irreversible export, and a subscribable resource for the current timeline so the agent reads structured state instead of scraping the UI.\n\n```ts\n\ntesseron.app({ id: \"video_editor\", name: \"Video Editor\" });\n\ntesseron.action(\"add_clips_to_timeline\")\n .describe(\"Insert multiple clips onto the timeline in one ordered batch\")\n .input(z.object({\n trackId: z.string(),\n clips: z.array(z.object({ assetId: z.string(), startMs: z.number() })),\n }))\n .handler((input) => {\n // one-shot bulk insert against your real editor store\n return editorStore.insertClips(input.trackId, input.clips);\n });\n\ntesseron.action(\"insert_title_card\")\n .describe(\"Insert a title card at a given position\")\n .input(z.object({ trackId: z.string(), atMs: z.number(), text: z.string() }))\n .handler((input) => editorStore.insertTitleCard(input));\n\ntesseron.action(\"render_preview\")\n .describe(\"Render a preview of the current timeline\")\n .input(z.object({ fromMs: z.number(), toMs: z.number() }))\n .annotate({ readOnly: true })\n .handler(async (input, ctx) => {\n // long-running: stream progress and forward cancellation\n return renderer.preview(input, {\n signal: ctx.signal,\n onProgress: (percent) =>\n ctx.progress({ message: \"rendering preview\", percent }),\n });\n });\n\ntesseron.action(\"export_video\")\n .describe(\"Export the final video to a file (expensive, irreversible)\")\n .input(z.object({ format: z.enum([\"mp4\", \"webm\"]), quality: z.enum([\"1080p\", \"4k\"]) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .timeout({ ms: 600000 })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({\n question: `Export the full timeline as ${input.quality} ${input.format}? This burns render minutes.`,\n });\n if (!ok) return { exported: false };\n return renderer.export(input, {\n signal: ctx.signal,\n onProgress: (percent) => ctx.progress({ message: \"exporting\", percent }),\n });\n });\n\ntesseron.resource(\"timeline\")\n .describe(\"The current timeline: tracks, clips, and ordering\")\n .read(() => editorStore.getTimeline())\n .subscribe((emit) => {\n emit(editorStore.getTimeline()); // initial value\n const off = editorStore.on(\"change\", () => emit(editorStore.getTimeline()));\n return () => off(); // cleanup the listener\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(\"Pair the agent with this code:\", welcome.claimCode);\n```\n\n**What the agent does.** When the human says \"build me a 30 second rough cut from the beach footage, clips in chronological order,\" the agent reads the `timeline` resource for available assets, then makes a single `video_editor__add_clips_to_timeline` call with all six clips ordered - no dragging, no per-clip round-trips. \"Put a title card that says Day One before the third clip\" is one `insert_title_card` call. When the human says \"let me see it,\" the agent calls `render_preview`, which streams `ctx.progress` so the human watches the percent climb and can cancel mid-render through `ctx.signal`. Then the human takes over - nudging a trim handle, sliding clip four left by 200ms - and the agent sees the edit because the `timeline` resource re-emits. Finally \"ship the 4K mp4\" triggers `export_video`, which gates on `ctx.confirm`; if the agent's host cannot prompt, `ctx.confirm` returns false and nothing irreversible runs.\n\n*Dragging clips is the UI manipulation automation fails at; \"insert these 6 clips in this order\" is one typed action, and your real handler runs against your real editor state.*\n\n## One-shot bulk operations across a data-heavy app\n\nAn internal admin panel or data dashboard already has buttons for refunds, tagging, and bans. You expose those same handlers to the agent so it can run them in bulk, across exactly what the operator is looking at.\n\n**The problem.** Every bulk job is N brittle UI round-trips: select a row, click refund, confirm the modal, wait, repeat for the next forty orders. The agent doesn't need to click those buttons - it needs to do the thing the buttons do, once, across the whole selection.\n\n**What you declare.** Register the same handlers your buttons already call, plus a resource that exposes the live table filter and row selection so the agent can act on the current view.\n\n```ts\n\ntesseron.app({ id: \"dashboard\", name: \"Admin Dashboard\" });\n\n// expose the live table filter + selection\ntesseron.resource(\"current_selection\")\n .describe(\"The operator's current table filter and selected row ids\")\n .read(() => store.getSelection()) // { filter, orderIds, customerIds, userIds }\n .subscribe((emit) => {\n const onChange = () => emit(store.getSelection());\n store.on(\"change\", onChange);\n return () => store.off(\"change\", onChange); // cleanup the listener\n });\n\ntesseron.action(\"refund_orders\")\n .describe(\"Refund every order in the given list\")\n .input(z.object({ orderIds: z.array(z.string()).min(1) }))\n .handler(async ({ orderIds }, ctx) => {\n let done = 0;\n for (const id of orderIds) {\n await refundOrder(id); // same code path the refund button calls\n ctx.progress({ message: `refunded ${id}`, percent: ++done / orderIds.length });\n }\n return { refunded: done };\n });\n\ntesseron.action(\"tag_customers\")\n .describe(\"Apply a tag to a list of customers\")\n .input(z.object({ customerIds: z.array(z.string()).min(1), tag: z.string() }))\n .handler(async ({ customerIds, tag }) => {\n await tagCustomers(customerIds, tag); // your real customer store\n return { tagged: customerIds.length };\n });\n\ntesseron.action(\"ban_users\")\n .describe(\"Permanently ban a list of users\")\n .input(z.object({ userIds: z.array(z.string()).min(1), reason: z.string() }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async ({ userIds, reason }, ctx) => {\n const ok = await ctx.confirm({\n question: `Ban ${userIds.length} users for \"${reason}\"? This is irreversible.`,\n });\n if (!ok) return { banned: 0 };\n await banUsers(userIds, reason); // same code path the ban button calls\n return { banned: userIds.length };\n });\n\nawait tesseron.connect();\n```\n\n`ban_users` is annotated destructive, and the `ctx.confirm` gate returns `false` on decline and on agents without elicitation - so the safe path needs no capability guard. The `current_selection` resource is subscribable, so the agent always acts on the operator's current view rather than a stale snapshot.\n\n**What the agent does.** An operator filters the table to last week's failed charges, selects the rows, and types \"refund the orders I currently have selected.\" The agent reads the `current_selection` resource, pulls the `orderIds`, and calls `dashboard__refund_orders` with the whole array - five clicks become one call, and `ctx.progress` streams each refund back into the chat. \"Tag everyone in this view as priority_support\" reads the same resource and fires `dashboard__tag_customers` once. \"Ban these three accounts for fraud\" triggers `dashboard__ban_users`, which calls `ctx.confirm` - the operator approves in the agent, the ban runs, and a decline returns `{ banned: 0 }`.\n\n*Your real handler runs against your real state - no separate MCP server, no backend duplication, just one-shot bulk where the UI made you click N times.*\n\n## A desktop or local-first app\n\nNot every app is a web page. A desktop tool - a markdown notes vault, a local-database GUI, an offline knowledge base - exposes actions exactly the same way. Tesseron runs inside your app's own process (for Electron or Tauri, the main process), mutates real state on disk, and pushes the result to your UI; no browser tab is involved anywhere.\n\n**The problem.** A desktop app has no public API and no URL an agent can hit - the only way in is the UI, so an agent would have to drive menus, dialogs, and a tree view it cannot see. \"Reorganize my 400 notes into folders by topic\" is hundreds of brittle clicks. The agent doesn't need to click the New Note button, it needs to do the thing that button does, against your real vault.\n\n**What you declare.** The same builder, running in the process that owns the files.\n\n```ts\n\ntesseron.app({\n id: \"notes_vault\",\n name: \"Notes Vault\",\n description: \"A local markdown notes vault.\",\n});\n\nconst folderSchema = z.object({ folder: z.string() });\n\ntesseron.action(\"create_note\")\n .describe(\"Create a markdown note in a folder.\")\n .input(z.object({ title: z.string(), body: z.string(), folder: z.string().optional() }))\n .handler(async (input, ctx) => {\n let folder = input.folder;\n if (!folder) {\n // no destination given - ask the human which folder\n const picked = await ctx.elicit({\n question: \"Which folder should this note go in?\",\n schema: folderSchema,\n jsonSchema: z.toJSONSchema(folderSchema),\n });\n if (picked === null) return { created: false };\n folder = picked.folder;\n }\n // writes a real .md file and notifies the UI\n const path = await vault.writeNote(folder, input.title, input.body);\n return { created: true, path };\n });\n\ntesseron.action(\"search_notes\")\n .describe(\"Full-text search across the vault.\")\n .input(z.object({ query: z.string() }))\n .annotate({ readOnly: true })\n .handler(async (input) => {\n // runs against your real local index\n return { hits: await vault.search(input.query) };\n });\n\ntesseron.action(\"organize_vault\")\n .describe(\"Move notes into topic folders in bulk.\")\n .input(z.object({ moves: z.array(z.object({ path: z.string(), folder: z.string() })) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({ question: `Move ${input.moves.length} notes?` });\n if (!ok) return { moved: 0 };\n for (let i = 0; i < input.moves.length; i++) {\n ctx.progress({ message: `moving ${input.moves[i].path}`, percent: (i / input.moves.length) * 100 });\n await vault.move(input.moves[i].path, input.moves[i].folder); // real fs move\n }\n return { moved: input.moves.length };\n });\n\ntesseron.resource(\"vault_tree\")\n .describe(\"The current folder and note tree.\")\n .read(() => vault.tree())\n .subscribe((emit) => {\n const onChange = () => emit(vault.tree());\n vault.on(\"change\", onChange);\n return () => vault.off(\"change\", onChange); // tear down the watcher\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(`Pair the agent with claim code: ${welcome.claimCode}`);\n```\n\n**What the agent does.** \"Jot down a note about today's standup\" calls `notes_vault__create_note` with no folder, so the handler runs `ctx.elicit` and a native dialog asks which folder - you pick \"Work\", the note is written, and your UI refreshes. \"Reorganize my vault by topic\" reads the `vault_tree` resource, then calls `notes_vault__organize_vault`; because it is annotated destructive, `ctx.confirm` gates the move (\"Move 412 notes?\") and `ctx.progress` streams each file as it lands. \"Find everything I wrote about Postgres\" hits the read-only `notes_vault__search_notes` and returns hits straight from your local index. One bulk call replaces hundreds of drag-and-drop round-trips.\n\n*The agent does the thing your buttons do - your real handler runs against your real vault, no browser in sight.*\n\n## A service or daemon with no UI\n\nSome apps have no UI at all - a deploy runner, a data-pipeline supervisor, an internal CLI. There is nothing to render; there are just typed handlers in a long-running process. Expose them and an agent can operate the service directly.\n\n**The problem.** To let an agent drive a service like this, you would normally stand up a parallel REST API just to feed an LLM - new routes, new auth, new serialization - and then watch it drift from the real internal functions it wraps. Tesseron skips that layer: you expose your existing typed handlers directly, and your real handler runs against your real state.\n\n**What you declare.**\n\n```ts\n\ntesseron.app({\n id: \"deploy_ops\",\n name: \"Deploy Ops Daemon\",\n description: \"Headless control plane for deploys and rollbacks\",\n});\n\ntesseron.action(\"trigger_deploy\")\n .describe(\"Build and roll out a service to an environment. Returns the result.\")\n .input(z.object({ service: z.string(), env: z.enum([\"staging\", \"prod\"]), ref: z.string() }))\n .timeout({ ms: 600_000 })\n .handler(async (input, ctx) => {\n ctx.progress({ message: `building ${input.service}@${input.ref}`, percent: 10 });\n // runs against your real deploy pipeline; signal cancels the in-flight rollout\n const res = await fetch(`http://internal/deploy/${input.service}`, {\n method: \"POST\",\n body: JSON.stringify(input),\n signal: ctx.signal,\n });\n ctx.progress({ message: \"rollout complete\", percent: 100 });\n\n // optionally have the agent summarize the tail of the deploy log\n if (ctx.agentCapabilities.sampling) {\n const tail = await getLogTail(input.service); // your real log store\n const summary = await ctx.sample({\n prompt: `Summarize this deploy log tail in two lines:\\n${tail}`,\n maxTokens: 200,\n });\n return { ok: res.ok, summary };\n }\n return { ok: res.ok };\n });\n\ntesseron.action(\"rollback\")\n .describe(\"Roll a service back to its previous release.\")\n .input(z.object({ service: z.string(), env: z.enum([\"staging\", \"prod\"]) }))\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (input, ctx) => {\n const ok = await ctx.confirm({\n question: `Roll back ${input.service} in ${input.env} to the previous release?`,\n });\n if (!ok) return { rolledBack: false };\n await rollbackService(input.service, input.env); // your real state\n return { rolledBack: true };\n });\n\ntesseron.resource(\"deploy_status\")\n .describe(\"Live status of in-flight and recent deploys.\")\n .read(() => readDeployStatus()) // your real status store\n .subscribe((emit) => {\n emit(readDeployStatus()); // initial value so the first read resolves\n const off = onDeployChange((status) => emit(status)); // your event source\n return () => off(); // cleanup the listener\n });\n\nconst welcome = await tesseron.connect();\nconsole.log(`Pair the agent with claim code: ${welcome.claimCode}`);\n```\n\n**What the agent does.** When you say \"ship payments at ref a1b2c3 to staging\", the agent calls `deploy_ops__trigger_deploy`; your handler streams `ctx.progress` updates as the build and rollout advance, forwards `ctx.signal` so a cancel actually aborts the fetch, and - if the connected agent supports sampling - uses `ctx.sample` to fold a log tail into a two-line summary. \"What is deploying right now?\" reads the `deploy_status` resource and, because it is subscribable, the agent watches it change live instead of polling. \"Roll back prod payments\" hits the rollback action, where `ctx.confirm` gates the destructive step - decline, or connect an agent without elicitation, and it returns false, so nothing happens.\n\n*Expose your typed actions instead of standing up a parallel REST API just to feed an LLM - the agent does not need a button to click, it needs the thing the button does.*\n\n## Bindings and languages\n\nNothing above was specific to a framework. The `tesseron` builder is the same whichever package you import it from - pick the one that matches your runtime:\n\n- **`@tesseron/web`** - any browser app, vanilla or alongside any framework.\n- **`@tesseron/react`**, **`@tesseron/svelte`**, **`@tesseron/vue`** - ergonomic adapters that register actions and resources from inside your components and tear them down on unmount. The admin and editor examples above could use these instead of the bare builder; the declared actions are identical.\n- **`@tesseron/server`** - Node: backend services, CLIs, daemons, and the main process of an Electron or Tauri desktop app. The desktop and daemon examples above run on this.\n- **`@tesseron/core`** - the builder and protocol types with no transport, for wiring your own.\n\nAnd it is not limited to TypeScript. Tesseron is a protocol - the spec is published under CC BY 4.0 - and the JS/TS packages are the reference implementation, not the only possible one. Anything that speaks JSON-RPC 2.0 over a duplex channel can host actions: a Python data daemon, a Rust Tauri app, a .NET line-of-business tool. Those SDKs are not written yet - today TypeScript ships, and a Python SDK and Rust bindings for Tauri are on the roadmap. To expose actions from another language right now, [port the protocol](/sdk/porting/) - it is a small wire contract; for the planned Python SDK and its status, see [its page](/sdk/python/).\n\n## Where to start\n\nThe [5-minute quickstart](/overview/quickstart/) takes any runtime from zero to a claimed session. Then browse the [SDK overview](/sdk/) for the package that fits your stack."},{"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\nThe agent doesn't need to click a button - it needs to *do the thing the button does*. Tesseron is the layer that lets it: you instrument your app once, the way you'd add ARIA to a web page, and any MCP-compatible agent can call the typed actions you expose. An accessibility layer for AI agents, in other words - or an API for agents, written by the people who built the app.\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 app, with their real state, their real auth.\n\n### Not just for the web\n\nTesseron is a protocol, not a web framework. The shipped SDKs cover TypeScript, Python, Rust, and C++. The SDKs live in [separate language repositories](/sdk/). TypeScript, Rust, and Python are published on npm, crates.io, and PyPI; C++ is consumed through CMake FetchContent. Any process that can open a WebSocket and speak JSON-RPC 2.0 can host actions, including a Python daemon, a Rust desktop app, a C++ service, or a .NET line-of-business tool. See [Porting Tesseron](/sdk/porting/), the [Python SDK](/sdk/python/), the [Rust SDK](/sdk/rust/), and the [C++ SDK](/sdk/cpp/).\n\n## 5. Tesseron and WebMCP\n\nThe [W3C WebMCP draft](https://webmachinelearning.github.io/webmcp/) is a W3C Community Group draft co-authored by Google and Microsoft. It lets a website expose tools to the browser's own agent. Chrome put it behind a flag in Chrome 146 in February 2026, and the [Chrome origin trial](https://developer.chrome.com/blog/ai-webmcp-origin-trial) is ongoing. The July 2026 draft moved the API from `navigator.modelContext` to `document.modelContext`. Chrome 150 deprecated the old name. `provideContext()` was removed in March 2026. Only Chromium implements WebMCP today. It exposes tools through `registerTool()` with a JSON schema or through annotated forms. Those tools run in the page as the logged-in user. The agent has to live in the browser, either built in or installed as an extension. A public website that wants the browser's own assistant to fill forms is the right fit.\n\n- **Any process, not a page.** Tesseron can expose actions from a Tauri app, Python daemon, CLI, or game, and a Python daemon can expose `importTodos` while a Tauri app uses system webviews that cannot reach WebMCP through its own UI.\n- **The agent is outside, and it is the one you already use.** Claude Code, Cursor, or Claude Desktop can build, run, and drive the app through Tesseron, while a coding agent cannot reach `document.modelContext`, so Claude Code can call `addTodo` after editing its handler.\n- **One gateway sees every running app.** Tesseron can expose a browser tab and a desktop app together, so a cross-app flow is built in; for example, it can read an invoice from a web app and post it into a local accounting app.\n- **The app can talk back.** Tesseron supports sampling, elicitation, `confirm`, resources with subscriptions, progress, cancellation, and resume; for example, `importTodos` can report each added item while a subscribed resource updates.\n- **Loopback plus an explicit claim.** Tesseron stays on loopback and requires the user's claim code, so a third-party script on the page cannot register a tool; for example, an unrelated analytics script cannot expose `deleteAccount` through the gateway.\n- **One CC BY spec, several languages, conformance-tested.** The protocol is CC BY 4.0, SDKs can be written in several languages, and the conformance suite checks them; for example, the same `addTodo` action can run in TypeScript, Python, Rust, or C++.\n\nTesseron does not build on WebMCP and does not publish into it. Use WebMCP for browser-native agents and Tesseron for the wider set of processes and agents.\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- **Bound to a running app.** The agent can only act while your app is running. A refresh or reload keeps the same session (resume is on by default); fully closing the app ends it. This is a feature - it keeps the agent bound to what the user can actually 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- Desktop and back-end apps too - an Electron editor, a Node daemon, a CLI - that want an agent-callable surface without standing up a separate MCP server.\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\nThe agent doesn't need to click a button - it needs to *do the thing the button does*. Tesseron is the layer that lets it: you instrument your app once, the way you'd add ARIA to a web page, and any MCP-compatible agent can call the typed actions you expose. An accessibility layer for AI agents, in other words - or an API for agents, written by the people who built the app.\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 app, with their real state, their real auth.\n\n### Not just for the web\n\nTesseron is a protocol, not a web framework. The shipped SDKs cover TypeScript, Python, Rust, and C++. The SDKs live in [separate language repositories](/sdk/). TypeScript, Rust, and Python are published on npm, crates.io, and PyPI; C++ is consumed through CMake FetchContent. Any process that can open a WebSocket and speak JSON-RPC 2.0 can host actions, including a Python daemon, a Rust desktop app, a C++ service, or a .NET line-of-business tool. See [Porting Tesseron](/sdk/porting/), the [Python SDK](/sdk/python/), the [Rust SDK](/sdk/rust/), and the [C++ SDK](/sdk/cpp/).\n\n## 5. Tesseron and WebMCP\n\nThe [W3C WebMCP draft](https://webmachinelearning.github.io/webmcp/) is a W3C Community Group draft co-authored by Google and Microsoft. It lets a website expose tools to the browser's own agent. Chrome put it behind a flag in Chrome 146 in February 2026, and the [Chrome origin trial](https://developer.chrome.com/blog/ai-webmcp-origin-trial) is ongoing. The July 2026 draft moved the API from `navigator.modelContext` to `document.modelContext`. Chrome 150 deprecated the old name. `provideContext()` was removed in March 2026. Only Chromium implements WebMCP today. It exposes tools through `registerTool()` with a JSON schema or through annotated forms. Those tools run in the page as the logged-in user. The agent has to live in the browser, either built in or installed as an extension. A public website that wants the browser's own assistant to fill forms is the right fit.\n\n- **Any process, not a page.** Tesseron can expose actions from a Tauri app, Python daemon, CLI, or game, and a Python daemon can expose `importTodos` while a Tauri app uses system webviews that cannot reach WebMCP through its own UI.\n- **The agent is outside, and it is the one you already use.** Claude Code, Cursor, or Claude Desktop can build, run, and drive the app through Tesseron, while a coding agent cannot reach `document.modelContext`, so Claude Code can call `addTodo` after editing its handler.\n- **One gateway sees every running app.** Tesseron can expose a browser tab and a desktop app together, so a cross-app flow is built in; for example, it can read an invoice from a web app and post it into a local accounting app.\n- **The app can talk back.** Tesseron supports sampling, elicitation, `confirm`, resources with subscriptions, progress, cancellation, and resume; for example, `importTodos` can report each added item while a subscribed resource updates.\n- **Loopback plus an explicit claim.** Tesseron stays on loopback and requires the user's claim code, so a third-party script on the page cannot register a tool; for example, an unrelated analytics script cannot expose `deleteAccount` through the gateway.\n- **One CC BY spec, several languages, conformance-tested.** The protocol is CC BY 4.0, SDKs can be written in several languages, and the conformance suite checks them; for example, the same `addTodo` action can run in TypeScript, Python, Rust, or C++.\n\nTesseron does not build on WebMCP and does not publish into it. Use WebMCP for browser-native agents and Tesseron for the wider set of processes and agents.\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- **Bound to a running app.** The agent can only act while your app is running. A refresh or reload keeps the same session (resume is on by default); fully closing the app ends it. This is a feature - it keeps the agent bound to what the user can actually 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- Desktop and back-end apps too - an Electron editor, a Node daemon, a CLI - that want an agent-callable surface without standing up a separate MCP server.\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/compatibility","title":"Compatibility","description":"Which protocol versions work together, and which package ranges speak them.","section":"protocol","related":["protocol/handshake","protocol/transport","sdk/porting"],"bodyRaw":"\n## The rule\n\nProtocol version decides compatibility. Package version numbers do not need to match across SDKs or the gateway.\n\nA host speaking protocol `1.x` works with a gateway speaking protocol `1.y`, as long as the major version is the same. The [`tesseron/hello` handshake](/protocol/handshake/) negotiates minor differences. A newer minor can add fields, and an older peer can ignore fields it does not know.\n\n## Protocol support\n\n| Protocol version | Packages that speak it |\n| --- | --- |\n| `1.2.0` | `@tesseron/core`, `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, `@tesseron/svelte`, `@tesseron/vue`, `@tesseron/vite`, and `@tesseron/mcp` `>=2.10.0` |\n| `1.2.0` | [`tesseron`](/sdk/python/) (Python) `>=0.1.0`. Published on PyPI. |\n| `1.2.0` | [`tesseron`](/sdk/rust/) (Rust) `0.1.x`. Published on crates.io. |\n| `1.2.0` | [`tesseron::tesseron`](/sdk/cpp/) (C++) `>=0.1.0`. Source-only, built through CMake `FetchContent`. |\n\nThe table starts at `1.2.0`. The history checked for this page does not prove package boundaries for earlier protocol versions.\n\nThe Python and Rust SDKs carry their own versions and move on their own. They speak the same protocol, which is the only thing that has to match. C++ releases use the same rule and get rows here as they land.\n\nThe C++ host does not mint its own claim code and does not speak a unix domain socket, so it skips the `bind/*` fixtures and `uds/file-mode` and passes every other fixture in the suite. See [C++ conformance](/sdk/cpp/conformance/).\n\n## TypeScript package versions\n\nThe seven TypeScript SDK packages (`core`, `web`, `server`, `react`, `svelte`, `vue`, and `vite`) are one fixed release group in [tesseron-typescript](https://github.com/Eigenwise/tesseron-typescript). Install them at the same version. The hub packages `@tesseron/mcp`, `@tesseron/docs-mcp`, and `@tesseron/conformance` release independently. Gateway compatibility follows the protocol version rule above.\n\n## When the handshake fails\n\nA host and gateway with different protocol majors get this JSON-RPC error from the gateway:\n\n```text\nGateway speaks protocol 1.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/\n```\n\nUse a host and gateway that speak the same protocol major.\n\nA legacy gateway that dials a host-minted WebSocket session without a bind subprotocol gets `HTTP/1.1 426 Upgrade Required` with this response body:\n\n```text\nThis Tesseron host requires a v1.2-compatible gateway (tesseron-bind subprotocol). Upgrade @tesseron/mcp to >= 2.4.0.\n```\n\nUpgrade `@tesseron/mcp` to `>=2.4.0`.\n","bodyText":"## The rule\n\nProtocol version decides compatibility. Package version numbers do not need to match across SDKs or the gateway.\n\nA host speaking protocol `1.x` works with a gateway speaking protocol `1.y`, as long as the major version is the same. The [`tesseron/hello` handshake](/protocol/handshake/) negotiates minor differences. A newer minor can add fields, and an older peer can ignore fields it does not know.\n\n## Protocol support\n\n| Protocol version | Packages that speak it |\n| --- | --- |\n| `1.2.0` | `@tesseron/core`, `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, `@tesseron/svelte`, `@tesseron/vue`, `@tesseron/vite`, and `@tesseron/mcp` `>=2.10.0` |\n| `1.2.0` | [`tesseron`](/sdk/python/) (Python) `>=0.1.0`. Published on PyPI. |\n| `1.2.0` | [`tesseron`](/sdk/rust/) (Rust) `0.1.x`. Published on crates.io. |\n| `1.2.0` | [`tesseron::tesseron`](/sdk/cpp/) (C++) `>=0.1.0`. Source-only, built through CMake `FetchContent`. |\n\nThe table starts at `1.2.0`. The history checked for this page does not prove package boundaries for earlier protocol versions.\n\nThe Python and Rust SDKs carry their own versions and move on their own. They speak the same protocol, which is the only thing that has to match. C++ releases use the same rule and get rows here as they land.\n\nThe C++ host does not mint its own claim code and does not speak a unix domain socket, so it skips the `bind/*` fixtures and `uds/file-mode` and passes every other fixture in the suite. See [C++ conformance](/sdk/cpp/conformance/).\n\n## TypeScript package versions\n\nThe seven TypeScript SDK packages (`core`, `web`, `server`, `react`, `svelte`, `vue`, and `vite`) are one fixed release group in [tesseron-typescript](https://github.com/Eigenwise/tesseron-typescript). Install them at the same version. The hub packages `@tesseron/mcp`, `@tesseron/docs-mcp`, and `@tesseron/conformance` release independently. Gateway compatibility follows the protocol version rule above.\n\n## When the handshake fails\n\nA host and gateway with different protocol majors get this JSON-RPC error from the gateway:\n\n```text\nGateway speaks protocol 1.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/\n```\n\nUse a host and gateway that speak the same protocol major.\n\nA legacy gateway that dials a host-minted WebSocket session without a bind subprotocol gets `HTTP/1.1 426 Upgrade Required` with this response body:\n\n```text\nThis Tesseron host requires a v1.2-compatible gateway (tesseron-bind subprotocol). Upgrade @tesseron/mcp to >= 2.4.0.\n```\n\nUpgrade `@tesseron/mcp` to `>=2.4.0`."},{"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`. In protocol 1.2.0, the host validator rejects these shapes with `-32602 InvalidParams`:\n\n- A schema that is not a JSON object.\n- A top-level `type` whose value is anything other than `\"object\"`.\n- A truthy top-level `oneOf`, `anyOf`, `allOf`, or `not` keyword.\n- A property whose checked `type` is `\"object\"`.\n- A property whose checked `type` is `\"array\"`.\n- A property whose checked `type` is any other non-primitive value. Present property types must be `\"string\"`, `\"number\"`, `\"integer\"`, or `\"boolean\"`.\n\nTwo lenient cases are part of the 1.2.0 validator behavior:\n\n- A property without a `type` is accepted. The validator does not infer a type from the property's other keywords.\n- When a property's `type` is an array, only its first entry is checked. A primitive first entry is accepted even when a later entry is unsupported; an unsupported first entry is rejected.\n\nThe lenient cases preserve the current 1.2.0 behavior. Tightening either rule should wait for a future minor, because it would reject schemas that currently pass.\n\nThe SDK enforces these rules on send and surfaces the `InvalidParams` error at the `ctx.elicit` call site. The gateway checks the same schema again when it receives the request.\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`. In protocol 1.2.0, the host validator rejects these shapes with `-32602 InvalidParams`:\n\n- A schema that is not a JSON object.\n- A top-level `type` whose value is anything other than `\"object\"`.\n- A truthy top-level `oneOf`, `anyOf`, `allOf`, or `not` keyword.\n- A property whose checked `type` is `\"object\"`.\n- A property whose checked `type` is `\"array\"`.\n- A property whose checked `type` is any other non-primitive value. Present property types must be `\"string\"`, `\"number\"`, `\"integer\"`, or `\"boolean\"`.\n\nTwo lenient cases are part of the 1.2.0 validator behavior:\n\n- A property without a `type` is accepted. The validator does not infer a type from the property's other keywords.\n- When a property's `type` is an array, only its first entry is checked. A primitive first entry is accepted even when a later entry is unsupported; an unsupported first entry is rejected.\n\nThe lenient cases preserve the current 1.2.0 behavior. Tightening either rule should wait for a future minor, because it would reject schemas that currently pass.\n\nThe SDK enforces these rules on send and surfaces the `InvalidParams` error at the `ctx.elicit` call site. The gateway checks the same schema again when it receives the request.\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, including a missing or non-`\"2.0\"` `jsonrpc` member. The response echoes a usable string, number, or null id, otherwise it uses null. |\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, or tried subscribing to an unknown or non-subscribable resource. |\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, including a missing or non-`\"2.0\"` `jsonrpc` member. The response echoes a usable string, number, or null id, otherwise it uses null. |\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, or tried subscribing to an unknown or non-subscribable resource. |\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: 'YOUR 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.2.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.2.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 app to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).\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. The wire path is one of two flavours, picked by the host and signalled in the instance manifest.\n\n### Host-minted claims and the bind handshake\n\nDefault since `@tesseron/vite@2.2.0` and `@tesseron/mcp@2.4.0`, and the reason the protocol went to `1.2.0`. See [tesseron#60](https://github.com/eigenwise/tesseron/issues/60).\n\nThe host (Vite plugin, `@tesseron/server`) mints the claim code, session id, and resume token at instance creation, writes them into the manifest's `hostMintedClaim`, and sets `helloHandledByHost: true`. That flag means \"do not auto-dial.\" The host answers its own app's `tesseron/hello` locally with a synthesized welcome, so the user sees a claim code without any gateway involved yet.\n\nWhen the user pastes the code into one specific Claude session, that gateway scans every host-mint manifest for a matching `hostMintedClaim.code` and dials only the matching instance, carrying the code in a bind step that the host validates in constant time before accepting. The user's paste deterministically picks the gateway, so there is no race and no \"switch to the right Claude\" detour.\n\nThe bind step is per-binding, because a Unix socket has no upgrade handshake to carry a subprotocol:\n\n| Binding | Mechanism |\n|---|---|\n| [WebSocket](/protocol/transport-bindings/ws/#bind-subprotocol-host-minted-claims) | `Sec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.<code>` on the upgrade |\n| [Unix domain socket](/protocol/transport-bindings/uds/#the-tesseronbind-handshake) | `tesseron/bind { code }` as the first NDJSON frame after connect |\n\nEither way the host validates before the session exists, and four rules hold for both:\n\n- **Constant-time comparison.** A short-circuiting compare leaks the code one character at a time to a process that can already reach the endpoint.\n- **Sliding TTL.** `expiresAt` is `mintedAt + 10 minutes`, refreshed every 5 minutes by rewriting the manifest. The heartbeat stops once the claim is spent. A gateway scanning for a code skips entries whose `expiresAt` has passed.\n- **Rate limit.** 5 mismatches within a 60-second rolling window trip a 60-second lockout. A successful bind resets the window.\n- **One shot.** `boundAgent` goes non-null on success and the claim is never re-bindable.\n\nAfter a successful bind the host replays the app's cached hello to the gateway and swallows the gateway's id-matched reply, so the app keeps the welcome it already resolved and never sees a second one.\n\nA gateway that dials a host-minted instance **without** binding is refused (`426 Upgrade Required` on WebSocket, `-32600 InvalidRequest` and a close on UDS). That gateway predates 1.2, and letting it through would deliver a second welcome against an already-resolved hello promise.\n\nPorting a host? The bind handshake is optional. Leave `helloHandledByHost` unset and the gateway auto-dials and mints the code for you, which is the simpler implementation and stays supported. Advertising the flag without implementing bind is the one combination that breaks. See [Port Tesseron to your language](/sdk/porting/).\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.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/\" } }\n```\n\nAn SDK also refuses an unreadable welcome result or one from a different major version. It closes that transport and does not run later requests from the rejected session.\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.2.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.2.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 app to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).\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. The wire path is one of two flavours, picked by the host and signalled in the instance manifest.\n\n### Host-minted claims and the bind handshake\n\nDefault since `@tesseron/vite@2.2.0` and `@tesseron/mcp@2.4.0`, and the reason the protocol went to `1.2.0`. See [tesseron#60](https://github.com/eigenwise/tesseron/issues/60).\n\nThe host (Vite plugin, `@tesseron/server`) mints the claim code, session id, and resume token at instance creation, writes them into the manifest's `hostMintedClaim`, and sets `helloHandledByHost: true`. That flag means \"do not auto-dial.\" The host answers its own app's `tesseron/hello` locally with a synthesized welcome, so the user sees a claim code without any gateway involved yet.\n\nWhen the user pastes the code into one specific Claude session, that gateway scans every host-mint manifest for a matching `hostMintedClaim.code` and dials only the matching instance, carrying the code in a bind step that the host validates in constant time before accepting. The user's paste deterministically picks the gateway, so there is no race and no \"switch to the right Claude\" detour.\n\nThe bind step is per-binding, because a Unix socket has no upgrade handshake to carry a subprotocol:\n\n| Binding | Mechanism |\n|---|---|\n| [WebSocket](/protocol/transport-bindings/ws/#bind-subprotocol-host-minted-claims) | `Sec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.<code>` on the upgrade |\n| [Unix domain socket](/protocol/transport-bindings/uds/#the-tesseronbind-handshake) | `tesseron/bind { code }` as the first NDJSON frame after connect |\n\nEither way the host validates before the session exists, and four rules hold for both:\n\n- **Constant-time comparison.** A short-circuiting compare leaks the code one character at a time to a process that can already reach the endpoint.\n- **Sliding TTL.** `expiresAt` is `mintedAt + 10 minutes`, refreshed every 5 minutes by rewriting the manifest. The heartbeat stops once the claim is spent. A gateway scanning for a code skips entries whose `expiresAt` has passed.\n- **Rate limit.** 5 mismatches within a 60-second rolling window trip a 60-second lockout. A successful bind resets the window.\n- **One shot.** `boundAgent` goes non-null on success and the claim is never re-bindable.\n\nAfter a successful bind the host replays the app's cached hello to the gateway and swallows the gateway's id-matched reply, so the app keeps the welcome it already resolved and never sees a second one.\n\nA gateway that dials a host-minted instance **without** binding is refused (`426 Upgrade Required` on WebSocket, `-32600 InvalidRequest` and a close on UDS). That gateway predates 1.2, and letting it through would deliver a second welcome against an already-resolved hello promise.\n\nPorting a host? The bind handshake is optional. Leave `helloHandledByHost` unset and the gateway auto-dials and mints the code for you, which is the simpler implementation and stays supported. Advertising the flag without implementing bind is the one combination that breaks. See [Port Tesseron to your language](/sdk/porting/).\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.2.0; SDK sent 2.0.0. Major version mismatch. See https://eigenwise.github.io/tesseron/protocol/compatibility/\" } }\n```\n\nAn SDK also refuses an unreadable welcome result or one from a different major version. It closes that transport and does not run later requests from the rejected session.\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/compatibility","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/eigenwise/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.2.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: 'YOUR 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=\"Compatibility\" href=\"./compatibility/\"\n description=\"Protocol version support across SDKs and the gateway.\" />\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.2.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.2.0`**.\n\n## Read the pages in order\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.2.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\nWhen present, `percent` is an integer from 0 through 100 and cannot decrease during one invocation. The host clamps a value outside that range, then raises anything below that invocation's highest sent percent to that ceiling. It forwards `message` and `data` unchanged. An update without `percent` does not change the ceiling.\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\nWhen present, `percent` is an integer from 0 through 100 and cannot decrease during one invocation. The host clamps a value outside that range, then raises anything below that invocation's highest sent percent to that ceiling. It forwards `message` and `data` unchanged. An update without `percent` does not change the ceiling.\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:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"result\": null }\n```\n\nThe `null` result acknowledges that the SDK 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, then acknowledges with `result: null`:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"result\": null }\n```\n\n### Subscription failures\n\nA resource needs a `.subscribe()` handler before the gateway can subscribe. An unknown resource, or a declared resource without that handler, returns `-32003 ActionNotFound` with `Resource not subscribable: <name>`.\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:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"result\": null }\n```\n\nThe `null` result acknowledges that the SDK 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, then acknowledges with `result: null`:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"result\": null }\n```\n\n### Subscription failures\n\nA resource needs a `.subscribe()` handler before the gateway can subscribe. An unknown resource, or a declared resource without that handler, returns `-32003 ActionNotFound` with `Resource not subscribable: <name>`.\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.2.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.2.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.2.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.2.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 owns and enforces `maxSamplingDepth = 3`. Sampling depth is not a field in any Tesseron frame, so a host does not count or increment it and does not need its own depth check. The host forwards `sampling/request`; when the gateway detects that the cap is exceeded, it returns `-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 owns and enforces `maxSamplingDepth = 3`. Sampling depth is not a field in any Tesseron frame, so a host does not count or increment it and does not need its own depth check. The host forwards `sampling/request`; when the gateway detects that the cap is exceeded, it returns `-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 - a socket has no upgrade handshake to carry one. Bytes start flowing the moment `connect()` succeeds, and the app sends `tesseron/hello` (or `tesseron/resume`) as its first message.\n\nHost-minted instances are the exception: there the gateway sends [`tesseron/bind`](#the-tesseronbind-handshake) first and the app's hello is held back until the bind succeeds.\n\n## The `tesseron/bind` handshake\n\nThe WebSocket binding carries a host-minted claim code in a `tesseron-bind.<code>` subprotocol element. A Unix socket has no upgrade to hang that on, so the same gate is a JSON-RPC request instead: the gateway sends `tesseron/bind` as the **first NDJSON frame after connect**, before any other traffic.\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": \"__tesseron-bind-<uuid>\", \"method\": \"tesseron/bind\", \"params\": { \"code\": \"7Q4K-M2\" } }\n```\n\nThe host upper-cases the incoming code, compares it against `hostMintedClaim.code` in constant time, and answers:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": \"__tesseron-bind-<uuid>\", \"result\": { \"ok\": true } }\n```\n\nFailures answer with an error and, where noted, close the socket:\n\n| Condition | Code | Socket |\n|---|---|---|\n| Host is in bind lockout | `-32009 Unauthorized` | Closed |\n| Code does not match | `-32009 Unauthorized` | Closed |\n| Already bound | `-32009 Unauthorized` | Kept |\n| Claim already spent (`boundAgent !== null`) | `-32009 Unauthorized` | Kept |\n| `params.code` missing or not a string | `-32602 InvalidParams` | Kept |\n| Any non-bind frame arrives first | `-32600 InvalidRequest` | Closed |\n\nThat last row is the UDS counterpart to the WebSocket binding's `426 Upgrade Required`. A host that minted its own claim has already answered the app's hello with a synthesized welcome, so a gateway that starts talking without binding would produce a second, conflicting welcome. The host closes instead.\n\nMismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout; a successful bind resets the window.\n\nAfter acking, the host replays the app's cached hello to the gateway and drops the gateway's id-matched reply, so the app never sees the second welcome. Queued non-hello frames drain afterwards.\n\nHosts that leave `helloHandledByHost` unset never take this path: the gateway auto-dials and mints the code itself, and the first frame on the wire is the app's hello.\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\nIf you also mint claims host-side, implement [`tesseron/bind`](#the-tesseronbind-handshake) with constant-time comparison, the rate limit, and the close-on-unbound-frame rule. Skipping it while advertising `helloHandledByHost: true` leaves the app unreachable.\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 - a socket has no upgrade handshake to carry one. Bytes start flowing the moment `connect()` succeeds, and the app sends `tesseron/hello` (or `tesseron/resume`) as its first message.\n\nHost-minted instances are the exception: there the gateway sends [`tesseron/bind`](#the-tesseronbind-handshake) first and the app's hello is held back until the bind succeeds.\n\n## The `tesseron/bind` handshake\n\nThe WebSocket binding carries a host-minted claim code in a `tesseron-bind.<code>` subprotocol element. A Unix socket has no upgrade to hang that on, so the same gate is a JSON-RPC request instead: the gateway sends `tesseron/bind` as the **first NDJSON frame after connect**, before any other traffic.\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": \"__tesseron-bind-<uuid>\", \"method\": \"tesseron/bind\", \"params\": { \"code\": \"7Q4K-M2\" } }\n```\n\nThe host upper-cases the incoming code, compares it against `hostMintedClaim.code` in constant time, and answers:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": \"__tesseron-bind-<uuid>\", \"result\": { \"ok\": true } }\n```\n\nFailures answer with an error and, where noted, close the socket:\n\n| Condition | Code | Socket |\n|---|---|---|\n| Host is in bind lockout | `-32009 Unauthorized` | Closed |\n| Code does not match | `-32009 Unauthorized` | Closed |\n| Already bound | `-32009 Unauthorized` | Kept |\n| Claim already spent (`boundAgent !== null`) | `-32009 Unauthorized` | Kept |\n| `params.code` missing or not a string | `-32602 InvalidParams` | Kept |\n| Any non-bind frame arrives first | `-32600 InvalidRequest` | Closed |\n\nThat last row is the UDS counterpart to the WebSocket binding's `426 Upgrade Required`. A host that minted its own claim has already answered the app's hello with a synthesized welcome, so a gateway that starts talking without binding would produce a second, conflicting welcome. The host closes instead.\n\nMismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout; a successful bind resets the window.\n\nAfter acking, the host replays the app's cached hello to the gateway and drops the gateway's id-matched reply, so the app never sees the second welcome. Queued non-hello frames drain afterwards.\n\nHosts that leave `helloHandledByHost` unset never take this path: the gateway auto-dials and mints the code itself, and the first frame on the wire is the app's hello.\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\nIf you also mint claims host-side, implement [`tesseron/bind`](#the-tesseronbind-handshake) with constant-time comparison, the rate limit, and the close-on-unbound-frame rule. Skipping it while advertising `helloHandledByHost: true` leaves the app unreachable.\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## Bind subprotocol (host-minted claims)\n\nWhen the app minted its own claim code (`helloHandledByHost: true` in the manifest — see [Host-minted claims](/protocol/handshake/#host-minted-claims-and-the-bind-handshake)), the gateway carries a second subprotocol element on the upgrade:\n\n```http\nSec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.7Q4K-M2\n```\n\nThe code element is `tesseron-bind.` followed by the claim code, which must match `[A-Za-z0-9_-]{1,64}`. A request carrying **more than one** `tesseron-bind.` element is rejected outright: two codes in one header is a header-injection signal, not an ambiguity to resolve.\n\nThe host compares the code against its in-memory `hostMintedClaim.code` in constant time and answers on the upgrade, before any WebSocket frame is exchanged:\n\n| Condition | Response | Notes |\n|---|---|---|\n| No `tesseron-gateway` element | Socket destroyed, no HTTP response | Not a Tesseron dial. |\n| Host is in bind lockout | `429 Too Many Requests` | Distinguishable from a mismatch on purpose. |\n| Code does not match | `403 Forbidden` | Counts toward the rate limit. |\n| Claim already spent (`boundAgent !== null`) | `409 Conflict` | One-shot. Mint a fresh session. |\n| A valid bind is already in flight | `409 Conflict` | Closes the concurrent-bind race before `handleUpgrade` attaches. |\n| Malformed `tesseron-bind.` element | `400 Bad Request` | Body names the grammar violation. |\n| No bind element at all | `426 Upgrade Required` | A pre-1.2 gateway. See below. |\n| Valid bind, host already attached | Socket destroyed | Duplicate. |\n\nOnly the `426` deserves explanation. A host that minted its own claim has **already** answered the app's `tesseron/hello` with a synthesized welcome. A gateway that auto-dials without binding would produce a second welcome against a hello promise that has already resolved, so the host refuses the upgrade instead of corrupting the session. Hosts that do not set `helloHandledByHost` never reach this path and keep accepting plain `tesseron-gateway` dials.\n\nMismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout, and a successful bind resets the window.\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\nIf you also mint claims host-side, implement the [bind subprotocol](#bind-subprotocol-host-minted-claims) with all eight upgrade outcomes above, constant-time code comparison, and the rate limit. Skipping it while advertising `helloHandledByHost: true` leaves the app unreachable.\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## Bind subprotocol (host-minted claims)\n\nWhen the app minted its own claim code (`helloHandledByHost: true` in the manifest — see [Host-minted claims](/protocol/handshake/#host-minted-claims-and-the-bind-handshake)), the gateway carries a second subprotocol element on the upgrade:\n\n```http\nSec-WebSocket-Protocol: tesseron-gateway, tesseron-bind.7Q4K-M2\n```\n\nThe code element is `tesseron-bind.` followed by the claim code, which must match `[A-Za-z0-9_-]{1,64}`. A request carrying **more than one** `tesseron-bind.` element is rejected outright: two codes in one header is a header-injection signal, not an ambiguity to resolve.\n\nThe host compares the code against its in-memory `hostMintedClaim.code` in constant time and answers on the upgrade, before any WebSocket frame is exchanged:\n\n| Condition | Response | Notes |\n|---|---|---|\n| No `tesseron-gateway` element | Socket destroyed, no HTTP response | Not a Tesseron dial. |\n| Host is in bind lockout | `429 Too Many Requests` | Distinguishable from a mismatch on purpose. |\n| Code does not match | `403 Forbidden` | Counts toward the rate limit. |\n| Claim already spent (`boundAgent !== null`) | `409 Conflict` | One-shot. Mint a fresh session. |\n| A valid bind is already in flight | `409 Conflict` | Closes the concurrent-bind race before `handleUpgrade` attaches. |\n| Malformed `tesseron-bind.` element | `400 Bad Request` | Body names the grammar violation. |\n| No bind element at all | `426 Upgrade Required` | A pre-1.2 gateway. See below. |\n| Valid bind, host already attached | Socket destroyed | Duplicate. |\n\nOnly the `426` deserves explanation. A host that minted its own claim has **already** answered the app's `tesseron/hello` with a synthesized welcome. A gateway that auto-dials without binding would produce a second welcome against a hello promise that has already resolved, so the host refuses the upgrade instead of corrupting the session. Hosts that do not set `helloHandledByHost` never reach this path and keep accepting plain `tesseron-gateway` dials.\n\nMismatches are rate-limited: 5 within a 60-second rolling window trip a 60-second lockout, and a successful bind resets the window.\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\nIf you also mint claims host-side, implement the [bind subprotocol](#bind-subprotocol-host-minted-claims) with all eight upgrade outcomes above, constant-time code comparison, and the rate limit. Skipping it while advertising `helloHandledByHost: true` leaves the app unreachable.\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| `tesseron/resume` | request | Rejoin a previously claimed session after a transport drop. Replaces hello on that path. See [Resume](/protocol/resume/). |\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| `tesseron/claimed` | notification | The pending claim code was consumed; carries the bound agent's identity. |\n| `tesseron/bind` | request | Host-minted instances only, and only over UDS. Presents the claim code before the session exists. See [the bind handshake](/protocol/handshake/#host-minted-claims-and-the-bind-handshake). |\n\nAnd the **response** to the `tesseron/hello` or `tesseron/resume` 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.2.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| `tesseron/resume` | request | Rejoin a previously claimed session after a transport drop. Replaces hello on that path. See [Resume](/protocol/resume/). |\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| `tesseron/claimed` | notification | The pending claim code was consumed; carries the bound agent's identity. |\n| `tesseron/bind` | request | Host-minted instances only, and only over UDS. Presents the claim code before the session exists. See [the bind handshake](/protocol/handshake/#host-minted-claims-and-the-bind-handshake). |\n\nAnd the **response** to the `tesseron/hello` or `tesseron/resume` 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.2.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/cpp/actions","title":"Actions (C++)","description":"Declaring actions, the Schema builder, the raw JSON Schema escape hatch, and how a handler fails.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/context","sdk/cpp/errors","sdk/cpp/resources","protocol/actions"],"bodyRaw":"\nAn action is a name, a declared input shape, and a coroutine. The builder chains from the host builder and `handler` is the terminal step, which hands the host builder back so the next action follows.\n\nThe canonical todo example registers `addTodo` like this:\n\n```cpp\nbuilder.action(\"addTodo\")\n .description(\"Add one todo\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"text\", tesseron::schema::string().min_length(1)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema(todo_output_schema())\n .handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n Todo todo = state->create(input.at(\"text\").get<std::string>(), tag.value());\n state->publish();\n co_return todo_payload(todo);\n });\n```\n\n`description` is what the agent reads when it decides whether to call this at all, so write it for a reader who has never seen your application. `timeout` overrides the gateway's 60-second default for this action only.\n\n## The handler\n\nA handler is a C++20 coroutine with this shape:\n\n```cpp\nboost::asio::awaitable<Result<Json>> addTodo(Json input, ActionContext context);\n```\n\nThe shipped examples use the same return type for every action handler. The coroutine runs on the host's I/O thread, so a `co_await` on a sample or an elicitation yields instead of blocking the read loop. A handler that blocks that thread stalls the whole session. Push real work onto your own executor and `co_await` the result.\n\n## Declaring input\n\n`Schema` answers both questions with one object: it emits the JSON Schema that goes in the manifest, and it validates the input at dispatch. The contract the agent reads is the contract the handler is protected by, so the two cannot drift apart.\n\nThe canonical `importTodos` action uses the builder for its object, array, and length constraints:\n\n```cpp\nbuilder.action(\"importTodos\")\n .description(\"Import several todos\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"items\", tesseron::schema::array(tesseron::schema::string()).min_items(1).max_items(50)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema({\n {\"type\", \"object\"},\n {\"properties\", {{\"added\", {{\"type\", \"integer\"}}}, {\"ids\", {{\"type\", \"array\"}, {\"items\", {{\"type\", \"string\"}}}}}}},\n {\"required\", {\"added\", \"ids\"}},\n })\n .handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n const Json& items = input.at(\"items\");\n Json identifiers = Json::array();\n for (std::size_t index = 0; index < items.size(); ++index) {\n Todo todo = state->create(items[index].get<std::string>(), tag.value());\n identifiers.push_back(todo.identifier);\n context.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n }\n state->publish();\n co_return Json{{\"added\", identifiers.size()}, {\"ids\", identifiers}};\n });\n```\n\n`min_length` and `max_length` count UTF-8 code points, not bytes, so a schema written for a human-visible field means what it looks like it means.\n\nInput that fails the schema never reaches the handler. The agent gets `-32004` with every issue at once, each carrying the path into the input:\n\n```json\n{\n \"code\": -32004,\n \"message\": \"Invalid input\",\n \"data\": [\n { \"message\": \"required property is missing\", \"path\": [\"sku\"] },\n { \"message\": \"expected type \\\"integer\\\", got string\", \"path\": [\"quantity\"] }\n ]\n}\n```\n\n### The raw escape hatch\n\nFor a shape the builder cannot express, pass the JSON Schema document and the check together:\n\n```cpp\nbuilder.action(\"query\")\n .input_schema(load_schema_document(), [](const Json& input) {\n return your_validator.check(input);\n })\n .handler(run_query);\n```\n\nThe validator is required. A schema nothing enforces is a promise to the agent that the handler does not keep, and the failure surfaces inside the handler instead of as a `-32004` the agent can act on.\n\n## Failing\n\n`ActionError` has three factory methods. The difference between them is what reaches the agent.\n\nA missing todo id in the shipped example returns `-32005 HandlerError` with structured data:\n\n```cpp\nco_return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, \"Todo not found\",\n Json{{\"kind\", \"not_found\"}});\n```\n\nUse `ActionError::handler(message)` for the same `-32005` code without custom data. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and structured detail. Use `ActionError::internal(source)` when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives `-32603 Internal error`. A handler that throws is treated the same way.\n\n## Cancellation\n\nThe gateway sends `actions/cancel`, and the host answers `-32001` immediately: it does not wait for the handler to notice. The stop token gives the handler a chance to stop doing the work.\n\nThe canonical import handler checks its progress loop and reports each item:\n\n```cpp\n.handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n const Json& items = input.at(\"items\");\n Json identifiers = Json::array();\n for (std::size_t index = 0; index < items.size(); ++index) {\n Todo todo = state->create(items[index].get<std::string>(), tag.value());\n identifiers.push_back(todo.identifier);\n context.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n }\n state->publish();\n co_return Json{{\"added\", identifiers.size()}, {\"ids\", identifiers}};\n});\n```\n\n`stop_token()`, `cancelled()`, and `wait_for_cancellation()` expose the same cancellation signal to the handler. Settlement is first-wins between the handler returning, cancellation arriving, and the timeout firing, so one request cannot receive two answers.\n","bodyText":"An action is a name, a declared input shape, and a coroutine. The builder chains from the host builder and `handler` is the terminal step, which hands the host builder back so the next action follows.\n\nThe canonical todo example registers `addTodo` like this:\n\n```cpp\nbuilder.action(\"addTodo\")\n .description(\"Add one todo\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"text\", tesseron::schema::string().min_length(1)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema(todo_output_schema())\n .handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n Todo todo = state->create(input.at(\"text\").get<std::string>(), tag.value());\n state->publish();\n co_return todo_payload(todo);\n });\n```\n\n`description` is what the agent reads when it decides whether to call this at all, so write it for a reader who has never seen your application. `timeout` overrides the gateway's 60-second default for this action only.\n\n## The handler\n\nA handler is a C++20 coroutine with this shape:\n\n```cpp\nboost::asio::awaitable<Result<Json>> addTodo(Json input, ActionContext context);\n```\n\nThe shipped examples use the same return type for every action handler. The coroutine runs on the host's I/O thread, so a `co_await` on a sample or an elicitation yields instead of blocking the read loop. A handler that blocks that thread stalls the whole session. Push real work onto your own executor and `co_await` the result.\n\n## Declaring input\n\n`Schema` answers both questions with one object: it emits the JSON Schema that goes in the manifest, and it validates the input at dispatch. The contract the agent reads is the contract the handler is protected by, so the two cannot drift apart.\n\nThe canonical `importTodos` action uses the builder for its object, array, and length constraints:\n\n```cpp\nbuilder.action(\"importTodos\")\n .description(\"Import several todos\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"items\", tesseron::schema::array(tesseron::schema::string()).min_items(1).max_items(50)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema({\n {\"type\", \"object\"},\n {\"properties\", {{\"added\", {{\"type\", \"integer\"}}}, {\"ids\", {{\"type\", \"array\"}, {\"items\", {{\"type\", \"string\"}}}}}}},\n {\"required\", {\"added\", \"ids\"}},\n })\n .handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n const Json& items = input.at(\"items\");\n Json identifiers = Json::array();\n for (std::size_t index = 0; index < items.size(); ++index) {\n Todo todo = state->create(items[index].get<std::string>(), tag.value());\n identifiers.push_back(todo.identifier);\n context.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n }\n state->publish();\n co_return Json{{\"added\", identifiers.size()}, {\"ids\", identifiers}};\n });\n```\n\n`min_length` and `max_length` count UTF-8 code points, not bytes, so a schema written for a human-visible field means what it looks like it means.\n\nInput that fails the schema never reaches the handler. The agent gets `-32004` with every issue at once, each carrying the path into the input:\n\n```json\n{\n \"code\": -32004,\n \"message\": \"Invalid input\",\n \"data\": [\n { \"message\": \"required property is missing\", \"path\": [\"sku\"] },\n { \"message\": \"expected type \\\"integer\\\", got string\", \"path\": [\"quantity\"] }\n ]\n}\n```\n\n### The raw escape hatch\n\nFor a shape the builder cannot express, pass the JSON Schema document and the check together:\n\n```cpp\nbuilder.action(\"query\")\n .input_schema(load_schema_document(), [](const Json& input) {\n return your_validator.check(input);\n })\n .handler(run_query);\n```\n\nThe validator is required. A schema nothing enforces is a promise to the agent that the handler does not keep, and the failure surfaces inside the handler instead of as a `-32004` the agent can act on.\n\n## Failing\n\n`ActionError` has three factory methods. The difference between them is what reaches the agent.\n\nA missing todo id in the shipped example returns `-32005 HandlerError` with structured data:\n\n```cpp\nco_return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, \"Todo not found\",\n Json{{\"kind\", \"not_found\"}});\n```\n\nUse `ActionError::handler(message)` for the same `-32005` code without custom data. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and structured detail. Use `ActionError::internal(source)` when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives `-32603 Internal error`. A handler that throws is treated the same way.\n\n## Cancellation\n\nThe gateway sends `actions/cancel`, and the host answers `-32001` immediately: it does not wait for the handler to notice. The stop token gives the handler a chance to stop doing the work.\n\nThe canonical import handler checks its progress loop and reports each item:\n\n```cpp\n.handler([state](Json input, ActionContext context) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n const Json& items = input.at(\"items\");\n Json identifiers = Json::array();\n for (std::size_t index = 0; index < items.size(); ++index) {\n Todo todo = state->create(items[index].get<std::string>(), tag.value());\n identifiers.push_back(todo.identifier);\n context.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n }\n state->publish();\n co_return Json{{\"added\", identifiers.size()}, {\"ids\", identifiers}};\n});\n```\n\n`stop_token()`, `cancelled()`, and `wait_for_cancellation()` expose the same cancellation signal to the handler. Settlement is first-wins between the handler returning, cancellation arriving, and the timeout firing, so one request cannot receive two answers."},{"slug":"sdk/cpp/conformance","title":"Conformance (C++)","description":"What the C++ host passes against the language-neutral fixture corpus, and how to run it yourself.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/installation","sdk/cpp/errors","sdk/cpp/threading","sdk/porting","protocol/compatibility"],"bodyRaw":"\nThe [conformance corpus](https://github.com/Eigenwise/tesseron/tree/main/conformance) is language-neutral: a set of JSON fixtures and a Node runner that plays the gateway against whatever host you point it at. The C++ SDK ships a private adapter so it can be pointed at.\n\n## Where it stands\n\n**Every fixture passes except the ten it declares it cannot serve**, on Linux with clang and on Windows with MSVC. At the corpus this was last run against, that is 29 passed, 10 skipped, 0 failed.\n\nThose 10 are the nine `bind/*` fixtures, which need a host-minted claim code, plus `uds/file-mode`, which needs a unix domain socket. The C++ host speaks WebSocket only and takes gateway-minted claims only, and it says so:\n\n`TESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds`\n\nThat list is not cosmetic. The runner cross-checks it against the four capability flags in the host's `tesseron/hello`: for each known capability, the flag has to equal \"not in the unsupported list\". Declaring `streaming: true` while naming `streaming` unsupported fails the run, and so does the reverse. All four flags this SDK declares are `true`, and neither of the two things it leaves out is one of them.\n\n## Running it\n\nFrom the `tesseron-cpp` repository root:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_CONFORMANCE_HOST=ON -DTESSERON_BUILD_EXAMPLES=ON\ncmake --build build\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"./build/conformance-host/tesseron-conformance-host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. Both tags are required on Linux and Windows. The runner uses its bundled corpus; pass `--fixtures <path>` to test a hub checkout's current fixtures. The [example guide](https://github.com/Eigenwise/tesseron-cpp/tree/main/examples) covers running the todo and prompts executables with the gateway.\n\n## The adapter\n\n`TESSERON_BUILD_CONFORMANCE_HOST=ON` builds `tesseron-conformance-host`. It is deliberately never installed and never exported: it exists to be launched from the repository, and shipping it would put a test adapter in a consumer's package.\n\nThe runner starts one process per fixture with `TESSERON_CONFORMANCE_FIXTURE` pointing at the fixture document, waits for a single readiness line on stdout, then plays the gateway against the endpoint that line names.\n\n```\ntesseron-conformance-url=ws://127.0.0.1:52344/\n```\n\nOne line, flushed. Every diagnostic goes to stderr, because a second stdout line fails the fixture. Closing stdin is how the runner asks the process to shut down.\n\nThe adapter reads the fixture's `actions` and `resources` and registers them, applying each action's behaviours in the order the corpus fixes: refuse a call that was not meant to happen, wait to be cancelled, stream progress, confirm, elicit, then answer with the canned value.\n\nTwo things it refuses at launch rather than ignoring:\n\n- a fixture requiring `uds`, or declaring a `hostMintedClaim`, because this host has neither;\n- an `inputSchema` using a JSON Schema keyword the adapter cannot enforce.\n\nThat second one matters more than it looks. The adapter covers the keywords the corpus actually uses instead of pulling a full JSON Schema implementation into a test binary, and a fixture that would otherwise pass *because* a keyword was silently ignored fails the launch instead.\n\n## The unit suite\n\nSeparate from conformance, and cheaper to run while you work:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_TESTS=ON\ncmake --build build\nctest --test-dir build --output-on-failure\n```\n\nCatch2 v3, registered with CTest through `catch_discover_tests`. It covers JSON-RPC framing, the handshake state machine, the progress clamp, resource subscription teardown, and error mapping. The handshake and resource tests talk to a real host over a real loopback socket, because those behaviours only exist in terms of what crosses the wire.\n","bodyText":"The [conformance corpus](https://github.com/Eigenwise/tesseron/tree/main/conformance) is language-neutral: a set of JSON fixtures and a Node runner that plays the gateway against whatever host you point it at. The C++ SDK ships a private adapter so it can be pointed at.\n\n## Where it stands\n\n**Every fixture passes except the ten it declares it cannot serve**, on Linux with clang and on Windows with MSVC. At the corpus this was last run against, that is 29 passed, 10 skipped, 0 failed.\n\nThose 10 are the nine `bind/*` fixtures, which need a host-minted claim code, plus `uds/file-mode`, which needs a unix domain socket. The C++ host speaks WebSocket only and takes gateway-minted claims only, and it says so:\n\n`TESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds`\n\nThat list is not cosmetic. The runner cross-checks it against the four capability flags in the host's `tesseron/hello`: for each known capability, the flag has to equal \"not in the unsupported list\". Declaring `streaming: true` while naming `streaming` unsupported fails the run, and so does the reverse. All four flags this SDK declares are `true`, and neither of the two things it leaves out is one of them.\n\n## Running it\n\nFrom the `tesseron-cpp` repository root:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_CONFORMANCE_HOST=ON -DTESSERON_BUILD_EXAMPLES=ON\ncmake --build build\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"./build/conformance-host/tesseron-conformance-host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. Both tags are required on Linux and Windows. The runner uses its bundled corpus; pass `--fixtures <path>` to test a hub checkout's current fixtures. The [example guide](https://github.com/Eigenwise/tesseron-cpp/tree/main/examples) covers running the todo and prompts executables with the gateway.\n\n## The adapter\n\n`TESSERON_BUILD_CONFORMANCE_HOST=ON` builds `tesseron-conformance-host`. It is deliberately never installed and never exported: it exists to be launched from the repository, and shipping it would put a test adapter in a consumer's package.\n\nThe runner starts one process per fixture with `TESSERON_CONFORMANCE_FIXTURE` pointing at the fixture document, waits for a single readiness line on stdout, then plays the gateway against the endpoint that line names.\n\n```\ntesseron-conformance-url=ws://127.0.0.1:52344/\n```\n\nOne line, flushed. Every diagnostic goes to stderr, because a second stdout line fails the fixture. Closing stdin is how the runner asks the process to shut down.\n\nThe adapter reads the fixture's `actions` and `resources` and registers them, applying each action's behaviours in the order the corpus fixes: refuse a call that was not meant to happen, wait to be cancelled, stream progress, confirm, elicit, then answer with the canned value.\n\nTwo things it refuses at launch rather than ignoring:\n\n- a fixture requiring `uds`, or declaring a `hostMintedClaim`, because this host has neither;\n- an `inputSchema` using a JSON Schema keyword the adapter cannot enforce.\n\nThat second one matters more than it looks. The adapter covers the keywords the corpus actually uses instead of pulling a full JSON Schema implementation into a test binary, and a fixture that would otherwise pass *because* a keyword was silently ignored fails the launch instead.\n\n## The unit suite\n\nSeparate from conformance, and cheaper to run while you work:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_TESTS=ON\ncmake --build build\nctest --test-dir build --output-on-failure\n```\n\nCatch2 v3, registered with CTest through `catch_discover_tests`. It covers JSON-RPC framing, the handshake state machine, the progress clamp, resource subscription teardown, and error mapping. The handshake and resource tests talk to a real host over a real loopback socket, because those behaviours only exist in terms of what crosses the wire."},{"slug":"sdk/cpp/context","title":"ActionContext (C++)","description":"Progress, sampling, confirmation, elicitation, logging, and cancellation from a C++ handler.","section":"sdk","related":["sdk/cpp/actions","sdk/cpp/errors","sdk/cpp/threading","protocol/progress-cancellation","protocol/elicitation","protocol/sampling"],"bodyRaw":"\nEvery handler is called with `(Json input, ActionContext context)`. The context is cheap to copy and every copy talks to the same invocation, including the shared progress ceiling, so a handler can hand one to a helper without losing anything.\n\n## What it knows\n\n```cpp\ncontext.action_name(); // \"addTodo\"\ncontext.invocation_id(); // the gateway's id for this call\ncontext.agent(); // { id, name }; \"pending\" until the session is claimed\ncontext.origin(); // the application's origin\ncontext.route(); // where in the application the agent was, if the gateway said\ncontext.agent_capabilities(); // what the other end negotiated\n```\n\nCheck `agent_capabilities()` before `sample` or `elicit` whenever the handler has a useful non-interactive fallback. It saves a round trip that was always going to fail.\n\n## progress\n\nThe canonical C++ examples report one update for each imported item:\n\n```cpp\ncontext.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n```\n\nEvery field is optional; send whichever the handler actually knows. Fire-and-forget, like every notification.\n\nPercent is clamped into 0 to 100, and never allowed to fall below a value already sent for this invocation. An agent rendering a progress bar reads a backwards jump as a restart, so a lower value is raised to the ceiling rather than dropped. Two copies of the context handed to two helpers share one ceiling.\n\n## log\n\nThe prompts example logs before sampling:\n\n```cpp\ncontext.log(LogEntry::info(\"Testing prompt \" + identifier));\n```\n\nFour levels are available: `debug`, `info`, `warn`, and `error`. Logging is fire-and-forget.\n\n## sample\n\nAsks the agent's model to answer a prompt. The todo example supplies a schema and a token limit:\n\n```cpp\nSampleRequest request(\"Produce exactly \" + std::to_string(count) +\n \" concrete todo items for the theme \\\"\" + theme +\n \"\\\". Return JSON matching { items: string[] }. Items should be short, \"\n \"imperative, and user-friendly. No numbering.\");\nrequest.json_schema(suggested_todos_output_schema()).max_tokens(400);\nauto sampled = co_await context.sample(std::move(request));\nif (!sampled.ok()) co_return sampled.error();\n```\n\nSampling depth is not a field in any Tesseron frame. The gateway owns `maxSamplingDepth` and answers `-32008` itself, so the host forwards the request without counting.\n\n## confirm\n\nA yes-or-no gate in front of something destructive. The prompts example uses it before deleting a prompt:\n\n```cpp\nauto confirmation = co_await context.confirm(\"Delete prompt \\\"\" + prompt->second.name +\n \"\\\" (tested \" + std::to_string(prompt->second.times_tested) +\n \"x)? This cannot be undone.\");\nif (!confirmation.ok()) co_return confirmation.error();\nif (!confirmation.value()) {\n co_return Json{{\"id\", identifier}, {\"deleted\", false}, {\"cancelled\", true}};\n}\n```\n\n`true` only means explicit accept. A decline, a cancel, and an agent that never negotiated elicitation all answer `false`.\n\n## elicit\n\nAsks the user for structured content. The todo example asks for a replacement name:\n\n```cpp\nElicitRequest request(\"Rename \\\"\" + todo->text + \"\\\" to?\");\nrequest.json_schema({\n {\"type\", \"object\"},\n {\"properties\", {{\"newName\", {{\"type\", \"string\"}, {\"minLength\", 1}}}}},\n {\"required\", {\"newName\"}},\n});\nauto elicited = co_await context.elicit(std::move(request));\nif (!elicited.ok()) co_return elicited.error();\nif (!elicited.value().has_value()) {\n co_return Json{{\"id\", identifier}, {\"renamed\", false}, {\"cancelled\", true}};\n}\n```\n\nAn empty optional means a decline or a cancel. Unlike `confirm`, a missing capability is an error rather than a default, because structured content has no safe default and the handler has to branch on it.\n\nThe schema is checked against the [elicitation rules](/protocol/elicitation/) before the frame leaves. MCP renders an elicit prompt as a flat form, so the protocol constrains the schema to a single object of primitive leaves. A top-level `oneOf`, `anyOf`, `allOf`, or `not`, a top-level type other than `object`, or a property typed `object` or `array` all fail with `-32602` at the `elicit` call site.\n\n## Cancellation\n\n`stop_token()`, `cancelled()`, and `co_await wait_for_cancellation()` expose the same signal when the agent cancels, the invocation times out, or the transport closes. See [Actions](/sdk/cpp/actions/#cancellation).\n\nApplication-thread handoff is covered on the [Threading](/sdk/cpp/threading/) page.\n","bodyText":"Every handler is called with `(Json input, ActionContext context)`. The context is cheap to copy and every copy talks to the same invocation, including the shared progress ceiling, so a handler can hand one to a helper without losing anything.\n\n## What it knows\n\n```cpp\ncontext.action_name(); // \"addTodo\"\ncontext.invocation_id(); // the gateway's id for this call\ncontext.agent(); // { id, name }; \"pending\" until the session is claimed\ncontext.origin(); // the application's origin\ncontext.route(); // where in the application the agent was, if the gateway said\ncontext.agent_capabilities(); // what the other end negotiated\n```\n\nCheck `agent_capabilities()` before `sample` or `elicit` whenever the handler has a useful non-interactive fallback. It saves a round trip that was always going to fail.\n\n## progress\n\nThe canonical C++ examples report one update for each imported item:\n\n```cpp\ncontext.progress(ProgressUpdate()\n .message(std::to_string(index + 1) + \"/\" + std::to_string(items.size()) + \" imported\")\n .percent(static_cast<int>((index + 1) * 100 / items.size())));\n```\n\nEvery field is optional; send whichever the handler actually knows. Fire-and-forget, like every notification.\n\nPercent is clamped into 0 to 100, and never allowed to fall below a value already sent for this invocation. An agent rendering a progress bar reads a backwards jump as a restart, so a lower value is raised to the ceiling rather than dropped. Two copies of the context handed to two helpers share one ceiling.\n\n## log\n\nThe prompts example logs before sampling:\n\n```cpp\ncontext.log(LogEntry::info(\"Testing prompt \" + identifier));\n```\n\nFour levels are available: `debug`, `info`, `warn`, and `error`. Logging is fire-and-forget.\n\n## sample\n\nAsks the agent's model to answer a prompt. The todo example supplies a schema and a token limit:\n\n```cpp\nSampleRequest request(\"Produce exactly \" + std::to_string(count) +\n \" concrete todo items for the theme \\\"\" + theme +\n \"\\\". Return JSON matching { items: string[] }. Items should be short, \"\n \"imperative, and user-friendly. No numbering.\");\nrequest.json_schema(suggested_todos_output_schema()).max_tokens(400);\nauto sampled = co_await context.sample(std::move(request));\nif (!sampled.ok()) co_return sampled.error();\n```\n\nSampling depth is not a field in any Tesseron frame. The gateway owns `maxSamplingDepth` and answers `-32008` itself, so the host forwards the request without counting.\n\n## confirm\n\nA yes-or-no gate in front of something destructive. The prompts example uses it before deleting a prompt:\n\n```cpp\nauto confirmation = co_await context.confirm(\"Delete prompt \\\"\" + prompt->second.name +\n \"\\\" (tested \" + std::to_string(prompt->second.times_tested) +\n \"x)? This cannot be undone.\");\nif (!confirmation.ok()) co_return confirmation.error();\nif (!confirmation.value()) {\n co_return Json{{\"id\", identifier}, {\"deleted\", false}, {\"cancelled\", true}};\n}\n```\n\n`true` only means explicit accept. A decline, a cancel, and an agent that never negotiated elicitation all answer `false`.\n\n## elicit\n\nAsks the user for structured content. The todo example asks for a replacement name:\n\n```cpp\nElicitRequest request(\"Rename \\\"\" + todo->text + \"\\\" to?\");\nrequest.json_schema({\n {\"type\", \"object\"},\n {\"properties\", {{\"newName\", {{\"type\", \"string\"}, {\"minLength\", 1}}}}},\n {\"required\", {\"newName\"}},\n});\nauto elicited = co_await context.elicit(std::move(request));\nif (!elicited.ok()) co_return elicited.error();\nif (!elicited.value().has_value()) {\n co_return Json{{\"id\", identifier}, {\"renamed\", false}, {\"cancelled\", true}};\n}\n```\n\nAn empty optional means a decline or a cancel. Unlike `confirm`, a missing capability is an error rather than a default, because structured content has no safe default and the handler has to branch on it.\n\nThe schema is checked against the [elicitation rules](/protocol/elicitation/) before the frame leaves. MCP renders an elicit prompt as a flat form, so the protocol constrains the schema to a single object of primitive leaves. A top-level `oneOf`, `anyOf`, `allOf`, or `not`, a top-level type other than `object`, or a property typed `object` or `array` all fail with `-32602` at the `elicit` call site.\n\n## Cancellation\n\n`stop_token()`, `cancelled()`, and `co_await wait_for_cancellation()` expose the same signal when the agent cancels, the invocation times out, or the transport closes. See [Actions](/sdk/cpp/actions/#cancellation).\n\nApplication-thread handoff is covered on the [Threading](/sdk/cpp/threading/) page."},{"slug":"sdk/cpp/errors","title":"Errors (C++)","description":"The C++ error types, the complete protocol code set, and the envelope rules the host follows.","section":"sdk","related":["sdk/cpp/actions","sdk/cpp/context","sdk/cpp/index","protocol/errors","protocol/wire-format"],"bodyRaw":"\nThe C++ SDK keeps three error types separate. `HostError` means the application could not start or shut down. `ActionError` is what a handler returns when an invocation fails. `ProtocolError` is the JSON-RPC error object that crosses the connection.\n\n## Protocol error codes\n\n`TesseronErrorCode` is the closed set of protocol codes. `to_wire_code(...)` returns the JSON-RPC integer, and `from_wire_code(...)` returns `std::nullopt` for an integer this SDK does not define.\n\n| Code | Enumerator | When |\n| --- | --- | --- |\n| `-32700` | `ParseError` | The peer sent bytes that are not valid JSON. |\n| `-32600` | `InvalidRequest` | The envelope is not a valid JSON-RPC 2.0 message. |\n| `-32601` | `MethodNotFound` | The requested method is not part of the Tesseron protocol. |\n| `-32602` | `InvalidParams` | Method parameters do not match the documented shape, including an elicit schema MCP cannot render. |\n| `-32603` | `InternalError` | An unexpected failure occurred. Detail stays local. |\n| `-32000` | `ProtocolMismatch` | The host and gateway disagree on the protocol major version. |\n| `-32001` | `Cancelled` | The agent cancelled the invocation. |\n| `-32002` | `Timeout` | The invocation passed its action timeout. |\n| `-32003` | `ActionNotFound` | No action is registered under the requested name, or a resource is not readable or subscribable. |\n| `-32004` | `InputValidation` | The invocation input failed the action's declared schema. |\n| `-32005` | `HandlerError` | The handler reported a domain failure. |\n| `-32006` | `SamplingNotAvailable` | The agent did not negotiate sampling. |\n| `-32007` | `ElicitationNotAvailable` | The agent did not negotiate elicitation. |\n| `-32008` | `SamplingDepthExceeded` | The gateway's sampling-depth limit was exceeded. |\n| `-32009` | `Unauthorized` | The session is unclaimed or the operation is not permitted. |\n| `-32010` | `TransportClosed` | The transport closed while a request was in flight. |\n| `-32011` | `ResumeFailed` | The gateway refused the resume credentials. |\n\n## Result and ActionError\n\nHandlers return `boost::asio::awaitable<Result<Json>>`. Startup returns `Result<Host, HostError>`, and shutdown returns `Result<void, HostError>`. A handler can return a domain failure through `ActionError::handler(message)`, a chosen code through `ActionError::protocol(code, message, data)`, or a local cause through `ActionError::internal(source)`.\n\nThe todo example's helper returns `-32005 HandlerError` with data when an id is unknown:\n\n```cpp\nActionError todo_not_found() {\n return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, \"Todo not found\",\n Json{{\"kind\", \"not_found\"}});\n}\n```\n\nThe `toggleTodo` and `deleteTodo` handlers `co_return todo_not_found()` when their lookup reaches the end. Use `ActionError::handler(message)` for the same `-32005` code without custom data. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and structured detail. Use `ActionError::internal(source)` when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives `-32603 Internal error`. A handler that throws is treated the same way.\n\n## ProtocolError\n\n`ProtocolError` represents the JSON-RPC `error` member with an integer code, a message, and optional JSON data. The integer stays available even when it is outside `TesseronErrorCode`, so a newer gateway's code can round-trip. `named_code()` returns `std::nullopt` for that unknown integer.\n\nConstruct it with either a `TesseronErrorCode` or a raw integer. `with_data(...)` attaches structured detail, `to_json()` makes the wire payload, and `from_json(...)` reads one when the shape is valid.\n\n## Envelope errors\n\nThe host follows the [wire-format rules](/protocol/wire-format/) for request IDs. An `id: null` member still marks a request, and its response carries `id: null`. Only an absent `id` makes a notification, so a notification receives no response.\n\nA frame without `jsonrpc: \"2.0\"` receives `-32600 InvalidRequest`. The host carries through a usable string, number, or null id, and uses `null` when there is no usable id. The session stays up after this response and can process the next frame.\n\n## HostError\n\nThese errors happen before an invocation reaches a handler:\n\n| Kind | When |\n| --- | --- |\n| `MissingApplication` | No application was registered before `listen()`. |\n| `InvalidApplicationId` | The application id is reserved or fails `^[a-z][a-z0-9_]*$`. |\n| `DuplicateName` | Two actions or two resources use the same name. |\n| `NonLoopbackBindAddress` | `bind_address` was given a non-loopback address. |\n| `Listen` | The loopback listener could not bind. |\n| `Manifest` | The instance manifest could not be written or removed. |\n| `HomeDirectoryUnknown` | The home directory for `~/.tesseron` could not be resolved. |\n\n`listen()` refuses a non-loopback address before binding. `shutdown()` reports manifest removal failures through `Result<void, HostError>`.\n","bodyText":"The C++ SDK keeps three error types separate. `HostError` means the application could not start or shut down. `ActionError` is what a handler returns when an invocation fails. `ProtocolError` is the JSON-RPC error object that crosses the connection.\n\n## Protocol error codes\n\n`TesseronErrorCode` is the closed set of protocol codes. `to_wire_code(...)` returns the JSON-RPC integer, and `from_wire_code(...)` returns `std::nullopt` for an integer this SDK does not define.\n\n| Code | Enumerator | When |\n| --- | --- | --- |\n| `-32700` | `ParseError` | The peer sent bytes that are not valid JSON. |\n| `-32600` | `InvalidRequest` | The envelope is not a valid JSON-RPC 2.0 message. |\n| `-32601` | `MethodNotFound` | The requested method is not part of the Tesseron protocol. |\n| `-32602` | `InvalidParams` | Method parameters do not match the documented shape, including an elicit schema MCP cannot render. |\n| `-32603` | `InternalError` | An unexpected failure occurred. Detail stays local. |\n| `-32000` | `ProtocolMismatch` | The host and gateway disagree on the protocol major version. |\n| `-32001` | `Cancelled` | The agent cancelled the invocation. |\n| `-32002` | `Timeout` | The invocation passed its action timeout. |\n| `-32003` | `ActionNotFound` | No action is registered under the requested name, or a resource is not readable or subscribable. |\n| `-32004` | `InputValidation` | The invocation input failed the action's declared schema. |\n| `-32005` | `HandlerError` | The handler reported a domain failure. |\n| `-32006` | `SamplingNotAvailable` | The agent did not negotiate sampling. |\n| `-32007` | `ElicitationNotAvailable` | The agent did not negotiate elicitation. |\n| `-32008` | `SamplingDepthExceeded` | The gateway's sampling-depth limit was exceeded. |\n| `-32009` | `Unauthorized` | The session is unclaimed or the operation is not permitted. |\n| `-32010` | `TransportClosed` | The transport closed while a request was in flight. |\n| `-32011` | `ResumeFailed` | The gateway refused the resume credentials. |\n\n## Result and ActionError\n\nHandlers return `boost::asio::awaitable<Result<Json>>`. Startup returns `Result<Host, HostError>`, and shutdown returns `Result<void, HostError>`. A handler can return a domain failure through `ActionError::handler(message)`, a chosen code through `ActionError::protocol(code, message, data)`, or a local cause through `ActionError::internal(source)`.\n\nThe todo example's helper returns `-32005 HandlerError` with data when an id is unknown:\n\n```cpp\nActionError todo_not_found() {\n return ActionError::protocol(tesseron::TesseronErrorCode::HandlerError, \"Todo not found\",\n Json{{\"kind\", \"not_found\"}});\n}\n```\n\nThe `toggleTodo` and `deleteTodo` handlers `co_return todo_not_found()` when their lookup reaches the end. Use `ActionError::handler(message)` for the same `-32005` code without custom data. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and structured detail. Use `ActionError::internal(source)` when the failure is a bug rather than a domain outcome. Its cause stays local and the agent receives `-32603 Internal error`. A handler that throws is treated the same way.\n\n## ProtocolError\n\n`ProtocolError` represents the JSON-RPC `error` member with an integer code, a message, and optional JSON data. The integer stays available even when it is outside `TesseronErrorCode`, so a newer gateway's code can round-trip. `named_code()` returns `std::nullopt` for that unknown integer.\n\nConstruct it with either a `TesseronErrorCode` or a raw integer. `with_data(...)` attaches structured detail, `to_json()` makes the wire payload, and `from_json(...)` reads one when the shape is valid.\n\n## Envelope errors\n\nThe host follows the [wire-format rules](/protocol/wire-format/) for request IDs. An `id: null` member still marks a request, and its response carries `id: null`. Only an absent `id` makes a notification, so a notification receives no response.\n\nA frame without `jsonrpc: \"2.0\"` receives `-32600 InvalidRequest`. The host carries through a usable string, number, or null id, and uses `null` when there is no usable id. The session stays up after this response and can process the next frame.\n\n## HostError\n\nThese errors happen before an invocation reaches a handler:\n\n| Kind | When |\n| --- | --- |\n| `MissingApplication` | No application was registered before `listen()`. |\n| `InvalidApplicationId` | The application id is reserved or fails `^[a-z][a-z0-9_]*$`. |\n| `DuplicateName` | Two actions or two resources use the same name. |\n| `NonLoopbackBindAddress` | `bind_address` was given a non-loopback address. |\n| `Listen` | The loopback listener could not bind. |\n| `Manifest` | The instance manifest could not be written or removed. |\n| `HomeDirectoryUnknown` | The home directory for `~/.tesseron` could not be resolved. |\n\n`listen()` refuses a non-loopback address before binding. `shutdown()` reports manifest removal failures through `Result<void, HostError>`."},{"slug":"sdk/cpp/index","title":"C++ SDK","description":"What the C++ implementation of the Tesseron host covers, and the shape of a host built with it.","section":"sdk","related":["sdk/cpp/installation","sdk/cpp/actions","sdk/cpp/errors","sdk/cpp/threading","sdk/index","protocol/compatibility"],"bodyRaw":"\nSource: [github.com/Eigenwise/tesseron-cpp](https://github.com/Eigenwise/tesseron-cpp)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-cpp)\n\nThe C++ SDK lives in `tesseron-cpp` and builds one static library, `tesseron::tesseron`. Your application binds a loopback WebSocket, writes an instance manifest, and the MCP gateway dials *in*. There is no port to configure and no gateway address to point at.\n\nConsume it from source through CMake's `FetchContent`, linking `tesseron::tesseron`. See [Install & build](/sdk/cpp/installation/) for the declaration. The SDK also fetches its own dependencies.\n\n## What it covers\n\nThe whole host half of protocol 1.2.0:\n\n- The handshake, claiming, and session resume with token rotation.\n- Action invocation with input validation, cancellation, and a per-action timeout.\n- Streaming progress, clamped and monotonic.\n- Resource reads and subscriptions, with a teardown that runs on unsubscribe and on a closing transport.\n- `ActionContext` round trips back into the agent: `sample`, `confirm`, `elicit`, and `log`.\n- The v2 instance manifest, written after the URL is known and removed on shutdown, `0700` on its directory and `0600` on the file where the platform has them.\n\nAll four `Capabilities` flags are declared. [Host-minted claim codes](/protocol/handshake/) are the one thing left out: the gateway mints the code, and a restarted process is a new session.\n\n## A host, end to end\n\nThe shipped todo example registers the same camelCase action names used by the other SDK examples:\n\n```cpp\nvoid register_actions(tesseron::HostBuilder& builder, const std::shared_ptr<TodoState>& state) {\n builder.action(\"addTodo\")\n .description(\"Add one todo\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"text\", tesseron::schema::string().min_length(1)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema(todo_output_schema())\n .handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n Todo todo = state->create(input.at(\"text\").get<std::string>(), tag.value());\n state->publish();\n co_return todo_payload(todo);\n });\n}\n```\n\nA complete app creates a `HostBuilder`, registers the actions and resources, then calls `listen()`:\n\n```cpp\nauto state = std::make_shared<TodoState>();\nauto builder = tesseron::Host::builder();\nbuilder.application(\"cpp_todo\", \"C++ Todo\");\nbuilder.on_event([](const tesseron::HostEvent& event) {\n if (event.kind == tesseron::HostEvent::Kind::Welcome && event.welcome.has_value() &&\n event.welcome->claim_code.has_value()) {\n std::cout << \"Claim code: \" << *event.welcome->claim_code << std::endl;\n }\n});\nregister_actions(builder, state);\nregister_resource(builder, state);\n\nauto listening = builder.listen();\nif (!listening.ok()) {\n std::cerr << \"tesseron-example-todo: \" << listening.error().message() << \"\\n\";\n return 1;\n}\nauto host = std::move(listening).value();\n```\n\nRegister the event listener before `listen()`. The gateway can dial and finish the handshake before `listen()` returns, and a listener installed afterwards misses the welcome that carries the claim code.\n\n## Run the examples\n\nFrom the `tesseron-cpp` repository root, configure and build the two shipped apps:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_EXAMPLES=ON\ncmake --build build --target tesseron-example-todo tesseron-example-prompts\n```\n\nRun the built examples with the gateway installed, then claim the printed code from your MCP client. The [example guide](https://github.com/Eigenwise/tesseron-cpp/tree/main/examples) covers both apps.\n\n## Three things that will surprise you\n\n**Boost.Asio is a public dependency.** A handler is a C++20 coroutine returning `boost::asio::awaitable<tesseron::Result<tesseron::Json>>`, so anything that links this library sees Asio's headers. That is deliberate: a hand-rolled coroutine type would have to be re-taught every executor, timer, and cancellation trick Asio already knows, and would not compose with the Asio code a real application already has. Boost.Beast, which implements the WebSocket listener, stays private.\n\n**Nothing throws across a handler boundary.** Every fallible call answers `Result<T>` or `Result<T, HostError>`, so the error type is part of the signature instead of something a caller has to guess at. A handler that throws anyway is caught and answered as `-32603` with the cause logged locally, never sent.\n\n**The host binds loopback only.** `HostOptions::bind_address` set to anything outside `127.0.0.0/8` or `::1` is a `HostError` before a socket opens. The gateway runs on the same machine by design; there is no configuration that turns this into a network service.\n\n## Threading\n\nOne host owns one `boost::asio::io_context` and one thread to run it. Every handler, reader, and subscriber runs on that thread, so a `co_await` yields rather than blocking the read loop. See [Threading](/sdk/cpp/threading/) for the application dispatcher and UI handoff.\n\n`ResourceEmitter::emit` is safe from any thread and hops onto the host's thread before touching the subscription. `ActionContext::on_application_thread` hands work to the application dispatcher and resumes the handler on the host's thread.\n","bodyText":"Source: [github.com/Eigenwise/tesseron-cpp](https://github.com/Eigenwise/tesseron-cpp)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-cpp)\n\nThe C++ SDK lives in `tesseron-cpp` and builds one static library, `tesseron::tesseron`. Your application binds a loopback WebSocket, writes an instance manifest, and the MCP gateway dials *in*. There is no port to configure and no gateway address to point at.\n\nConsume it from source through CMake's `FetchContent`, linking `tesseron::tesseron`. See [Install & build](/sdk/cpp/installation/) for the declaration. The SDK also fetches its own dependencies.\n\n## What it covers\n\nThe whole host half of protocol 1.2.0:\n\n- The handshake, claiming, and session resume with token rotation.\n- Action invocation with input validation, cancellation, and a per-action timeout.\n- Streaming progress, clamped and monotonic.\n- Resource reads and subscriptions, with a teardown that runs on unsubscribe and on a closing transport.\n- `ActionContext` round trips back into the agent: `sample`, `confirm`, `elicit`, and `log`.\n- The v2 instance manifest, written after the URL is known and removed on shutdown, `0700` on its directory and `0600` on the file where the platform has them.\n\nAll four `Capabilities` flags are declared. [Host-minted claim codes](/protocol/handshake/) are the one thing left out: the gateway mints the code, and a restarted process is a new session.\n\n## A host, end to end\n\nThe shipped todo example registers the same camelCase action names used by the other SDK examples:\n\n```cpp\nvoid register_actions(tesseron::HostBuilder& builder, const std::shared_ptr<TodoState>& state) {\n builder.action(\"addTodo\")\n .description(\"Add one todo\")\n .input(tesseron::schema::object({\n tesseron::schema::required(\"text\", tesseron::schema::string().min_length(1)),\n tesseron::schema::optional(\"tag\", tesseron::schema::string()),\n }))\n .output_schema(todo_output_schema())\n .handler([state](Json input, ActionContext) -> boost::asio::awaitable<Result<Json>> {\n const auto tag = optional_string(input, \"tag\");\n if (!tag.ok()) co_return tag.error();\n Todo todo = state->create(input.at(\"text\").get<std::string>(), tag.value());\n state->publish();\n co_return todo_payload(todo);\n });\n}\n```\n\nA complete app creates a `HostBuilder`, registers the actions and resources, then calls `listen()`:\n\n```cpp\nauto state = std::make_shared<TodoState>();\nauto builder = tesseron::Host::builder();\nbuilder.application(\"cpp_todo\", \"C++ Todo\");\nbuilder.on_event([](const tesseron::HostEvent& event) {\n if (event.kind == tesseron::HostEvent::Kind::Welcome && event.welcome.has_value() &&\n event.welcome->claim_code.has_value()) {\n std::cout << \"Claim code: \" << *event.welcome->claim_code << std::endl;\n }\n});\nregister_actions(builder, state);\nregister_resource(builder, state);\n\nauto listening = builder.listen();\nif (!listening.ok()) {\n std::cerr << \"tesseron-example-todo: \" << listening.error().message() << \"\\n\";\n return 1;\n}\nauto host = std::move(listening).value();\n```\n\nRegister the event listener before `listen()`. The gateway can dial and finish the handshake before `listen()` returns, and a listener installed afterwards misses the welcome that carries the claim code.\n\n## Run the examples\n\nFrom the `tesseron-cpp` repository root, configure and build the two shipped apps:\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_EXAMPLES=ON\ncmake --build build --target tesseron-example-todo tesseron-example-prompts\n```\n\nRun the built examples with the gateway installed, then claim the printed code from your MCP client. The [example guide](https://github.com/Eigenwise/tesseron-cpp/tree/main/examples) covers both apps.\n\n## Three things that will surprise you\n\n**Boost.Asio is a public dependency.** A handler is a C++20 coroutine returning `boost::asio::awaitable<tesseron::Result<tesseron::Json>>`, so anything that links this library sees Asio's headers. That is deliberate: a hand-rolled coroutine type would have to be re-taught every executor, timer, and cancellation trick Asio already knows, and would not compose with the Asio code a real application already has. Boost.Beast, which implements the WebSocket listener, stays private.\n\n**Nothing throws across a handler boundary.** Every fallible call answers `Result<T>` or `Result<T, HostError>`, so the error type is part of the signature instead of something a caller has to guess at. A handler that throws anyway is caught and answered as `-32603` with the cause logged locally, never sent.\n\n**The host binds loopback only.** `HostOptions::bind_address` set to anything outside `127.0.0.0/8` or `::1` is a `HostError` before a socket opens. The gateway runs on the same machine by design; there is no configuration that turns this into a network service.\n\n## Threading\n\nOne host owns one `boost::asio::io_context` and one thread to run it. Every handler, reader, and subscriber runs on that thread, so a `co_await` yields rather than blocking the read loop. See [Threading](/sdk/cpp/threading/) for the application dispatcher and UI handoff.\n\n`ResourceEmitter::emit` is safe from any thread and hops onto the host's thread before touching the subscription. `ActionContext::on_application_thread` hands work to the application dispatcher and resumes the handler on the host's thread."},{"slug":"sdk/cpp/installation","title":"Install & build (C++)","description":"Consuming the C++ SDK through CMake FetchContent, the toolchains it is checked on, and the compiler flags it sets for you.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/actions","sdk/cpp/errors","sdk/cpp/threading","sdk/cpp/conformance"],"bodyRaw":"\nCMake 3.24 or newer, and a compiler with C++20 coroutines. Everything else the SDK needs it fetches itself.\n\n## Adding it to your project\n\n```cmake\ninclude(FetchContent)\nFetchContent_Declare(\n tesseron\n GIT_REPOSITORY https://github.com/Eigenwise/tesseron-cpp.git\n GIT_TAG v0.1.0)\nFetchContent_MakeAvailable(tesseron)\n\ntarget_link_libraries(your_app PRIVATE tesseron::tesseron)\n```\n\nThe CMake project is at the repository root. Pin `GIT_TAG` to a reviewed commit hash for a reproducible build.\n\n## What it pulls in\n\n| Dependency | Visibility | Why |\n|---|---|---|\n| Boost.Asio (>= 1.85) | public | handlers return `boost::asio::awaitable<...>` |\n| nlohmann/json | public | `tesseron::Json` is `nlohmann::json` |\n| Boost.Beast | private | the WebSocket listener |\n| Catch2 v3 | tests only | fetched only when `TESSERON_BUILD_TESTS=ON` |\n\nEvery one arrives through `FetchContent` at a pinned version *and* a pinned SHA-256. No vcpkg, no Conan, no system packages: a clean checkout builds with nothing but a compiler, CMake, and a network connection.\n\n`BOOST_INCLUDE_LIBRARIES` is limited to `asio` and `beast`, so this is not a full Boost build. The first configure still compiles Boost.Context, Boost.Container and Boost.Date_Time from source, which takes a couple of minutes; everything after that is incremental.\n\nIf your project already has its own Boost, declare it before `FetchContent_MakeAvailable(tesseron)` and the SDK will use yours.\n\n## Options\n\n| Option | Default | What it does |\n|---|---|---|\n| `TESSERON_BUILD_TESTS` | `OFF` | builds the Catch2 suite and registers it with CTest |\n| `TESSERON_BUILD_CONFORMANCE_HOST` | `OFF` | builds the fixture adapter the conformance runner drives |\n| `TESSERON_BUILD_EXAMPLES` | `OFF` | builds the canonical headless todo and prompts examples |\n| `TESSERON_INSTALL` | on when top-level | generates install and export rules |\n\n## Building the SDK itself\n\nRun these commands from the `tesseron-cpp` repository root.\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_TESTS=ON\ncmake --build build\nctest --test-dir build --output-on-failure\n```\n\n## Toolchains\n\nCI builds ubuntu-latest with clang and windows-latest with MSVC (through `ilammy/msvc-dev-cmd`), both on Ninja, and runs the full conformance suite on each. Local development of this SDK was done on Windows 11 with clang 22.1.0 targeting `x86_64-pc-windows-msvc`, CMake 4.2.1 and Ninja 1.13.2.\n\nThree compiler settings are attached to the library target rather than left to you, because they have to hold in every consumer too:\n\n- **`_WIN32_WINNT=0x0A00`** on Windows. Asio reads it to pick its I/O completion API, and it has to be defined before any Asio header is included, in your translation units as well as the SDK's.\n- **`/bigobj`** on MSVC. Beast's templates blow past the default object-section limit.\n- **`/Zc:__cplusplus`** on MSVC. Without it the compiler reports C++98 in `__cplusplus`, and header feature checks quietly fall back to pre-C++20 paths.\n\n## Installing it\n\n```bash\ncmake -S . -B build -DCMAKE_INSTALL_PREFIX=/your/prefix\ncmake --build build --target install\n```\n\nThat writes a `tesseron-config.cmake`, so a consumer can `find_package(tesseron)` instead of fetching sources. An installed `tesseron` does not carry Boost or nlohmann/json with it: the config file resolves those through `find_dependency`, the way it would for any other shared dependency. The conformance host is deliberately neither installed nor exported.\n","bodyText":"CMake 3.24 or newer, and a compiler with C++20 coroutines. Everything else the SDK needs it fetches itself.\n\n## Adding it to your project\n\n```cmake\ninclude(FetchContent)\nFetchContent_Declare(\n tesseron\n GIT_REPOSITORY https://github.com/Eigenwise/tesseron-cpp.git\n GIT_TAG v0.1.0)\nFetchContent_MakeAvailable(tesseron)\n\ntarget_link_libraries(your_app PRIVATE tesseron::tesseron)\n```\n\nThe CMake project is at the repository root. Pin `GIT_TAG` to a reviewed commit hash for a reproducible build.\n\n## What it pulls in\n\n| Dependency | Visibility | Why |\n|---|---|---|\n| Boost.Asio (>= 1.85) | public | handlers return `boost::asio::awaitable<...>` |\n| nlohmann/json | public | `tesseron::Json` is `nlohmann::json` |\n| Boost.Beast | private | the WebSocket listener |\n| Catch2 v3 | tests only | fetched only when `TESSERON_BUILD_TESTS=ON` |\n\nEvery one arrives through `FetchContent` at a pinned version *and* a pinned SHA-256. No vcpkg, no Conan, no system packages: a clean checkout builds with nothing but a compiler, CMake, and a network connection.\n\n`BOOST_INCLUDE_LIBRARIES` is limited to `asio` and `beast`, so this is not a full Boost build. The first configure still compiles Boost.Context, Boost.Container and Boost.Date_Time from source, which takes a couple of minutes; everything after that is incremental.\n\nIf your project already has its own Boost, declare it before `FetchContent_MakeAvailable(tesseron)` and the SDK will use yours.\n\n## Options\n\n| Option | Default | What it does |\n|---|---|---|\n| `TESSERON_BUILD_TESTS` | `OFF` | builds the Catch2 suite and registers it with CTest |\n| `TESSERON_BUILD_CONFORMANCE_HOST` | `OFF` | builds the fixture adapter the conformance runner drives |\n| `TESSERON_BUILD_EXAMPLES` | `OFF` | builds the canonical headless todo and prompts examples |\n| `TESSERON_INSTALL` | on when top-level | generates install and export rules |\n\n## Building the SDK itself\n\nRun these commands from the `tesseron-cpp` repository root.\n\n```bash\ncmake -S . -B build -G Ninja -DTESSERON_BUILD_TESTS=ON\ncmake --build build\nctest --test-dir build --output-on-failure\n```\n\n## Toolchains\n\nCI builds ubuntu-latest with clang and windows-latest with MSVC (through `ilammy/msvc-dev-cmd`), both on Ninja, and runs the full conformance suite on each. Local development of this SDK was done on Windows 11 with clang 22.1.0 targeting `x86_64-pc-windows-msvc`, CMake 4.2.1 and Ninja 1.13.2.\n\nThree compiler settings are attached to the library target rather than left to you, because they have to hold in every consumer too:\n\n- **`_WIN32_WINNT=0x0A00`** on Windows. Asio reads it to pick its I/O completion API, and it has to be defined before any Asio header is included, in your translation units as well as the SDK's.\n- **`/bigobj`** on MSVC. Beast's templates blow past the default object-section limit.\n- **`/Zc:__cplusplus`** on MSVC. Without it the compiler reports C++98 in `__cplusplus`, and header feature checks quietly fall back to pre-C++20 paths.\n\n## Installing it\n\n```bash\ncmake -S . -B build -DCMAKE_INSTALL_PREFIX=/your/prefix\ncmake --build build --target install\n```\n\nThat writes a `tesseron-config.cmake`, so a consumer can `find_package(tesseron)` instead of fetching sources. An installed `tesseron` does not carry Boost or nlohmann/json with it: the config file resolves those through `find_dependency`, the way it would for any other shared dependency. The conformance host is deliberately neither installed nor exported."},{"slug":"sdk/cpp/resources","title":"Resources (C++)","description":"Readers, subscribers, the emitter, and teardown for the C++ SDK.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/actions","sdk/cpp/context","sdk/cpp/errors","protocol/resources"],"bodyRaw":"\nA resource is a named value the agent can read, and optionally subscribe to. `reader` is the terminal step of the builder, so `description` and `subscribe` come before it.\n\nThe todo example publishes the resource URI `todos://all`:\n\n```cpp\nbuilder.resource(\"todos://all\")\n .description(\"The complete todo list. Pushed on every mutation.\")\n .subscribe([state](tesseron::ResourceEmitter emitter) {\n state->subscribers.push_back(std::move(emitter));\n return tesseron::Subscription::without_teardown();\n })\n .reader([state]() -> boost::asio::awaitable<Result<Json>> { co_return todo_list_payload(state->todos); });\n```\n\n## The reader\n\nA reader returns the current value for every `resources/read`:\n\n```cpp\n.reader([state]() -> boost::asio::awaitable<Result<Json>> {\n co_return todo_list_payload(state->todos);\n});\n```\n\nIt runs on the host's I/O thread. A reader that fails answers an `ActionError` the same way a handler does. A reader that throws is caught and answered as `-32603`.\n\n## Subscribing\n\nRegistering a subscriber declares the resource `subscribable: true` in the manifest. Leave it off and a `resources/subscribe` for that name is answered `-32003`, the same answer an undeclared resource gets.\n\nThe subscriber starts pushing and hands back the thing that stops it:\n\n```cpp\n.subscribe([state](tesseron::ResourceEmitter emitter) {\n state->subscribers.push_back(std::move(emitter));\n return tesseron::Subscription::without_teardown();\n})\n```\n\n`Subscription::with_teardown(...)` is for a subscriber that registered an application listener. `Subscription::without_teardown()` is for a subscriber that registered nothing needing cleanup.\n\nThe acknowledgement goes out before the subscriber runs, so a value the subscriber emits immediately cannot overtake the response the agent is still waiting on. The acknowledgement is `result: null`, both for subscribe and unsubscribe.\n\n## The emitter\n\nOne emitter belongs to one `resources/subscribe`, so the subscription id is already baked in. Copying is cheap and every copy pushes to the same subscriber, which lets a subscriber hand one to another thread.\n\n`emit` is safe from any thread. It hops onto the host's own thread before touching the subscription, which is also where subscription liveness can be read without racing an unsubscribe. It is fire-and-forget: a value emitted after the agent unsubscribed, or after the transport closed, is dropped rather than queued.\n\n## Teardown\n\nThe teardown runs on `resources/unsubscribe` and when the transport closes, whichever comes first. Write it so it is safe when nothing is listening any more, and let it own everything the subscriber registered.\n\n## Reading it back\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": { \"value\": [] }\n}\n```\n\n`resources/updated` is a notification with no `id`:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub-1\", \"value\": [] }\n}\n```\n","bodyText":"A resource is a named value the agent can read, and optionally subscribe to. `reader` is the terminal step of the builder, so `description` and `subscribe` come before it.\n\nThe todo example publishes the resource URI `todos://all`:\n\n```cpp\nbuilder.resource(\"todos://all\")\n .description(\"The complete todo list. Pushed on every mutation.\")\n .subscribe([state](tesseron::ResourceEmitter emitter) {\n state->subscribers.push_back(std::move(emitter));\n return tesseron::Subscription::without_teardown();\n })\n .reader([state]() -> boost::asio::awaitable<Result<Json>> { co_return todo_list_payload(state->todos); });\n```\n\n## The reader\n\nA reader returns the current value for every `resources/read`:\n\n```cpp\n.reader([state]() -> boost::asio::awaitable<Result<Json>> {\n co_return todo_list_payload(state->todos);\n});\n```\n\nIt runs on the host's I/O thread. A reader that fails answers an `ActionError` the same way a handler does. A reader that throws is caught and answered as `-32603`.\n\n## Subscribing\n\nRegistering a subscriber declares the resource `subscribable: true` in the manifest. Leave it off and a `resources/subscribe` for that name is answered `-32003`, the same answer an undeclared resource gets.\n\nThe subscriber starts pushing and hands back the thing that stops it:\n\n```cpp\n.subscribe([state](tesseron::ResourceEmitter emitter) {\n state->subscribers.push_back(std::move(emitter));\n return tesseron::Subscription::without_teardown();\n})\n```\n\n`Subscription::with_teardown(...)` is for a subscriber that registered an application listener. `Subscription::without_teardown()` is for a subscriber that registered nothing needing cleanup.\n\nThe acknowledgement goes out before the subscriber runs, so a value the subscriber emits immediately cannot overtake the response the agent is still waiting on. The acknowledgement is `result: null`, both for subscribe and unsubscribe.\n\n## The emitter\n\nOne emitter belongs to one `resources/subscribe`, so the subscription id is already baked in. Copying is cheap and every copy pushes to the same subscriber, which lets a subscriber hand one to another thread.\n\n`emit` is safe from any thread. It hops onto the host's own thread before touching the subscription, which is also where subscription liveness can be read without racing an unsubscribe. It is fire-and-forget: a value emitted after the agent unsubscribed, or after the transport closed, is dropped rather than queued.\n\n## Teardown\n\nThe teardown runs on `resources/unsubscribe` and when the transport closes, whichever comes first. Write it so it is safe when nothing is listening any more, and let it own everything the subscriber registered.\n\n## Reading it back\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": { \"value\": [] }\n}\n```\n\n`resources/updated` is a notification with no `id`:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub-1\", \"value\": [] }\n}\n```"},{"slug":"sdk/cpp/threading","title":"Threading (C++)","description":"The C++ host I/O thread, coroutine handlers, and handing UI work to an application's own thread.","section":"sdk","related":["sdk/cpp/index","sdk/cpp/actions","sdk/cpp/context","sdk/cpp/installation"],"bodyRaw":"\nA `Host` owns one `boost::asio::io_context` and one thread that runs it. `HostBuilder::listen()` starts accepting the gateway on that thread. The host's socket reads, JSON-RPC dispatch, `HostBuilder::on_event` callback, action handlers, resource readers, and subscription callbacks all run there.\n\nHandlers return `boost::asio::awaitable<Result<Json>>`. `co_await` yields the host thread while a sampling or elicitation request is in flight. Blocking that thread blocks the session and every other handler on the host.\n\n`ResourceEmitter::emit` is the exception to the caller's thread rule. It is safe to call from any thread and posts the update onto the host's `io_context`; values emitted after unsubscribe or transport close are dropped.\n\n## Application dispatcher\n\n`HostOptions::application_dispatcher` is optional and has one job: it receives a `std::function<void()>` that the SDK wants to run on the application's own thread.\n\n```cpp\ntesseron::HostOptions options;\noptions.application_dispatcher = [](std::function<void()> work) {\n your_toolkit::post_to_main_loop(std::move(work));\n};\nbuilder.options(std::move(options));\n```\n\nThe handler calls `co_await context.on_application_thread(...)`. The dispatcher runs the callback on the UI toolkit's thread, then the handler resumes on the host's I/O thread. Keep the callback small and copy the value the handler needs back into state it owns.\n\nWhen the dispatcher is unset, `on_application_thread(...)` runs the callback inline on the host's I/O thread. That is the right default for a headless app with no second thread.\n","bodyText":"A `Host` owns one `boost::asio::io_context` and one thread that runs it. `HostBuilder::listen()` starts accepting the gateway on that thread. The host's socket reads, JSON-RPC dispatch, `HostBuilder::on_event` callback, action handlers, resource readers, and subscription callbacks all run there.\n\nHandlers return `boost::asio::awaitable<Result<Json>>`. `co_await` yields the host thread while a sampling or elicitation request is in flight. Blocking that thread blocks the session and every other handler on the host.\n\n`ResourceEmitter::emit` is the exception to the caller's thread rule. It is safe to call from any thread and posts the update onto the host's `io_context`; values emitted after unsubscribe or transport close are dropped.\n\n## Application dispatcher\n\n`HostOptions::application_dispatcher` is optional and has one job: it receives a `std::function<void()>` that the SDK wants to run on the application's own thread.\n\n```cpp\ntesseron::HostOptions options;\noptions.application_dispatcher = [](std::function<void()> work) {\n your_toolkit::post_to_main_loop(std::move(work));\n};\nbuilder.options(std::move(options));\n```\n\nThe handler calls `co_await context.on_application_thread(...)`. The dispatcher runs the callback on the UI toolkit's thread, then the handler resumes on the host's I/O thread. Keep the callback small and copy the value the handler needs back into state it owns.\n\nWhen the dispatcher is unset, `on_application_thread(...)` runs the callback inline on the host's I/O thread. That is the right default for a headless app with no second thread."},{"slug":"sdk/index","title":"SDK overview","description":"What a Tesseron SDK has to expose across TypeScript, Rust, Python, and C++.","section":"sdk","related":["sdk/rust/index","sdk/python/index","sdk/cpp/index","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\nThe SDKs live in four repositories: [TypeScript](https://github.com/Eigenwise/tesseron-typescript), [Rust](https://github.com/Eigenwise/tesseron-rust), [Python](https://github.com/Eigenwise/tesseron-python), and [C++](https://github.com/Eigenwise/tesseron-cpp). TypeScript packages are on npm, Rust is on crates.io, Python is on PyPI, and C++ is consumed through CMake FetchContent. The surface they expose, the **SDK contract**, is the portable part. Docs, protocol fixtures, and issue tracking stay in this hub.\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 hub-owned MCP gateway CLI, launched by the 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\" href=\"/sdk/python/\"\n description=\"asyncio and Pydantic v2, the full host surface, passing conformance. Published on PyPI.\" />\n <LinkCard title=\"Rust SDK\" href=\"/sdk/rust/\"\n description=\"Tokio and WebSocket host with typed actions, resources, and the full context API. Published on crates.io.\" />\n <LinkCard title=\"C++ SDK\" href=\"/sdk/cpp/\"\n description=\"C++20 and Boost.Asio host with actions, resources, and the full context API. Consumed through CMake FetchContent.\" />\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\nThe SDKs live in four repositories: [TypeScript](https://github.com/Eigenwise/tesseron-typescript), [Rust](https://github.com/Eigenwise/tesseron-rust), [Python](https://github.com/Eigenwise/tesseron-python), and [C++](https://github.com/Eigenwise/tesseron-cpp). TypeScript packages are on npm, Rust is on crates.io, Python is on PyPI, and C++ is consumed through CMake FetchContent. The surface they expose, the **SDK contract**, is the portable part. Docs, protocol fixtures, and issue tracking stay in this hub.\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","sdk/python/index","sdk/rust/index","sdk/cpp/index"],"bodyRaw":"\nTesseron already has [Rust](/sdk/rust/), [Python](/sdk/python/), and [C++](/sdk/cpp/) SDKs in their own language repositories. All three are working references for a new port.\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: `gateway/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. TypeScript uses a fluent builder (`action(...).describe(...).input(...).handler(...)`), Python uses decorators, and Rust uses a method chain on `TesseronHostBuilder`. 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\nPart of this list is executable. Build a small host adapter that reads `TESSERON_CONFORMANCE_FIXTURE`, registers its canned actions and resources, and prints the readiness line described in the [fixture adapter contract](https://github.com/eigenwise/tesseron/blob/main/conformance/README.md). Then run the shipped protocol 1.2 suite:\n\n```bash\npnpm dlx @tesseron/conformance@1.2.1 --host \"./build/tesseron-conformance-host\"\n```\n\nUse `TESSERON_CONFORMANCE_UNSUPPORTED=uds` on platforms without POSIX Unix domain sockets. The package carries the fixture corpus, reports skips separately, and runs each fixture against a fresh host process. Every language repository uses this published runner with its own host adapter. Docs and fixtures stay in the hub; an SDK release PR is complete only after its corresponding hub docs PR has merged. The prose list below remains the wider implementation checklist.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after the binding's connection becomes ready.\n- [ ] Sends `protocolVersion = \"1.2.0\"`. The gateway compares `major.minor`: a major mismatch is rejected with `-32000`, a minor mismatch is accepted with a warning.\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**Claim minting** — pick one of the two flows. Gateway-minted is the simpler port and stays supported.\n\n*Gateway-minted (default).* Omit `helloHandledByHost` from the manifest. The gateway auto-dials, mints the code, and returns it in the welcome. Nothing extra to implement.\n\n- [ ] Manifest omits `helloHandledByHost` (or sets it `false`) and carries no `hostMintedClaim`.\n\n*Host-minted (opt-in, [tesseron#60](https://github.com/eigenwise/tesseron/issues/60)).* The host mints the code so the user's paste deterministically picks one agent session instead of racing. Adds the [bind handshake](/protocol/handshake/#host-minted-claims-and-the-bind-handshake) as a hard requirement.\n\n- [ ] Mints `code`, `sessionId`, and `resumeToken` at instance creation; writes them into `hostMintedClaim` and sets `helloHandledByHost: true`.\n- [ ] Answers the app's own `tesseron/hello` locally with a synthesized welcome; does not forward it until a gateway binds.\n- [ ] Sets `hostMintedClaim.expiresAt = mintedAt + 10 min` and refreshes both every 5 min by rewriting the manifest, stopping once `boundAgent` is non-null.\n- [ ] Validates the bind code in **constant time**. A short-circuiting string compare leaks the code one character at a time.\n- [ ] Rate-limits mismatches: 5 within a 60 s rolling window trips a 60 s lockout; a successful bind resets the window.\n- [ ] Accepts exactly one bind. A second attempt against a spent claim is rejected, never re-bound.\n- [ ] Rejects a dial that skips the bind step (a pre-1.2 gateway). Letting it through produces a second, conflicting welcome against an already-resolved hello.\n- [ ] Replays the cached hello to the gateway after a successful bind, and drops the gateway's id-matched reply so the app never sees two welcomes.\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- [ ] Forwards `sampling/request` without counting sampling depth. The gateway enforces the depth cap of 3; no Tesseron frame carries depth.\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>/` following the [Rust](/sdk/rust/), [Python](/sdk/python/), and [C++](/sdk/cpp/) section structures.\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 already has [Rust](/sdk/rust/), [Python](/sdk/python/), and [C++](/sdk/cpp/) SDKs in their own language repositories. All three are working references for a new port.\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: `gateway/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. TypeScript uses a fluent builder (`action(...).describe(...).input(...).handler(...)`), Python uses decorators, and Rust uses a method chain on `TesseronHostBuilder`. 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\nPart of this list is executable. Build a small host adapter that reads `TESSERON_CONFORMANCE_FIXTURE`, registers its canned actions and resources, and prints the readiness line described in the [fixture adapter contract](https://github.com/eigenwise/tesseron/blob/main/conformance/README.md). Then run the shipped protocol 1.2 suite:\n\n```bash\npnpm dlx @tesseron/conformance@1.2.1 --host \"./build/tesseron-conformance-host\"\n```\n\nUse `TESSERON_CONFORMANCE_UNSUPPORTED=uds` on platforms without POSIX Unix domain sockets. The package carries the fixture corpus, reports skips separately, and runs each fixture against a fresh host process. Every language repository uses this published runner with its own host adapter. Docs and fixtures stay in the hub; an SDK release PR is complete only after its corresponding hub docs PR has merged. The prose list below remains the wider implementation checklist.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after the binding's connection becomes ready.\n- [ ] Sends `protocolVersion = \"1.2.0\"`. The gateway compares `major.minor`: a major mismatch is rejected with `-32000`, a minor mismatch is accepted with a warning.\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**Claim minting** — pick one of the two flows. Gateway-minted is the simpler port and stays supported.\n\n*Gateway-minted (default).* Omit `helloHandledByHost` from the manifest. The gateway auto-dials, mints the code, and returns it in the welcome. Nothing extra to implement.\n\n- [ ] Manifest omits `helloHandledByHost` (or sets it `false`) and carries no `hostMintedClaim`.\n\n*Host-minted (opt-in, [tesseron#60](https://github.com/eigenwise/tesseron/issues/60)).* The host mints the code so the user's paste deterministically picks one agent session instead of racing. Adds the [bind handshake](/protocol/handshake/#host-minted-claims-and-the-bind-handshake) as a hard requirement.\n\n- [ ] Mints `code`, `sessionId`, and `resumeToken` at instance creation; writes them into `hostMintedClaim` and sets `helloHandledByHost: true`.\n- [ ] Answers the app's own `tesseron/hello` locally with a synthesized welcome; does not forward it until a gateway binds.\n- [ ] Sets `hostMintedClaim.expiresAt = mintedAt + 10 min` and refreshes both every 5 min by rewriting the manifest, stopping once `boundAgent` is non-null.\n- [ ] Validates the bind code in **constant time**. A short-circuiting string compare leaks the code one character at a time.\n- [ ] Rate-limits mismatches: 5 within a 60 s rolling window trips a 60 s lockout; a successful bind resets the window.\n- [ ] Accepts exactly one bind. A second attempt against a spent claim is rejected, never re-bound.\n- [ ] Rejects a dial that skips the bind step (a pre-1.2 gateway). Letting it through produces a second, conflicting welcome against an already-resolved hello.\n- [ ] Replays the cached hello to the gateway after a successful bind, and drops the gateway's id-matched reply so the app never sees two welcomes.\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- [ ] Forwards `sampling/request` without counting sampling depth. The gateway enforces the depth cap of 3; no Tesseron frame carries depth.\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>/` following the [Rust](/sdk/rust/), [Python](/sdk/python/), and [C++](/sdk/cpp/) section structures.\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/actions","title":"Actions (Python)","description":"The @app.action decorator, input inference from the handler annotation, and what a handler may return.","section":"sdk","related":["sdk/python/index","sdk/python/context","sdk/python/errors","protocol/actions"],"bodyRaw":"\nAn action is a named, typed, handler-backed operation the agent can invoke. The gateway projects each one into an MCP tool.\n\n## The decorator\n\n```python\nfrom pydantic import BaseModel, Field\nfrom tesseron import ActionContext, JsonObject, TesseronApp\n\n\nclass AddTodoInput(BaseModel):\n text: str = Field(min_length=1)\n tag: str | None = None\n\n\ndef create_app() -> TesseronApp:\n app = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n\n @app.action(\"addTodo\", description=\"Add one todo\")\n async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject:\n del context\n todo = store.create(input_data.text, input_data.tag)\n await publish_todos()\n return todo_payload(todo)\n\n return app\n```\n\nThe `store`, `publish_todos`, and `todo_payload` names above come directly from the canonical [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py) host.\n\nEvery handler is `async def` and takes `(input_data, context)`. A handler that is not a coroutine function, or that does not take two parameters, raises `HostError` at registration. Registering one name twice raises `DuplicateNameError`, because the manifest has to stay unambiguous for the gateway to project it.\n\n## Input comes from the annotation\n\nIf the first parameter is annotated with a Pydantic `BaseModel`, that model is the input contract. Two things follow from it:\n\n1. The manifest publishes `model_json_schema(mode=\"validation\")`, unchanged. Validation mode is the right one here: the agent is producing input, not reading output, so aliases and defaults have to be described the way the model will accept them.\n2. Dispatch runs `model_validate` before the handler body. Input that does not fit is refused with [`-32004 InputValidation`](/protocol/errors/), and the handler never runs.\n\nThe refusal carries every problem Pydantic found, not just the first:\n\n```json\n{\n \"code\": -32004,\n \"message\": \"Invalid input\",\n \"data\": [\n { \"message\": \"String should have at least 1 character\", \"path\": [\"text\"] },\n { \"message\": \"Input should be a valid string\", \"path\": [\"tag\"] }\n ]\n}\n```\n\n## Raw JSON input\n\nAnnotate the first parameter with anything else and the handler takes the invocation input as raw JSON. Then `input_schema` is what the manifest publishes and `validate` is what enforces it:\n\n```python\nfrom tesseron import ActionContext, JsonValue, ValidationIssue\n\n\ndef positive_amount(raw_input: JsonValue) -> list[ValidationIssue]:\n if isinstance(raw_input, dict) and isinstance(raw_input.get(\"amount\"), int | float):\n return []\n return [ValidationIssue(message=\"amount must be a number\", path=[\"amount\"])]\n\n\n@app.action(\n \"charge\",\n description=\"Charge the saved card\",\n input_schema={\"type\": \"object\", \"properties\": {\"amount\": {\"type\": \"number\"}}},\n validate=positive_amount,\n)\nasync def charge(raw_input: JsonValue, context: ActionContext) -> JsonValue:\n return {\"charged\": True}\n```\n\nA non-empty issue list becomes the same `-32004` failure, with the same `data` shape. Leave `validate` off and nothing is checked: the schema is then documentation for the agent and nothing more.\n\n## The other options\n\n| Argument | What it does |\n| --- | --- |\n| `description` | Published in the manifest. The gateway uses it as the MCP tool description, so write it for the agent. |\n| `input_schema` | Overrides the schema derived from the model, or supplies one for a raw handler. |\n| `output_schema` | Published when set. Nothing validates output against it; it tells the agent what to expect. |\n| `timeout_ms` | Per-action deadline. Past it the invocation answers [`-32002 Timeout`](/protocol/errors/) and the handler task is cancelled. The default is 60 seconds. |\n| `validate` | Extra input check for a raw handler. Ignored when the input type comes from a model. |\n\n## Registering after listen\n\n`await app.listen()` returns a `TesseronHost`. Its `action` decorator has the same arguments as `app.action` and can register an action after the host starts:\n\n```python\nhost = await app.listen()\n\n\n@host.action(\"refresh\", description=\"Refresh cached data\")\nasync def refresh(raw_input: JsonObject, context: ActionContext) -> JsonObject:\n del raw_input, context\n return {\"ok\": True}\n```\n\nHost registration upserts by name. Registering an existing name replaces its descriptor and handler while keeping its position in the manifest. `host.remove_action(name)` returns `True` when an action was removed and `False` when the name was unknown. The app-level decorator still raises `DuplicateNameError` for a duplicate.\n\nAfter the gateway welcomes the session, each registry change sends `actions/list_changed` with `{ \"actions\": [full manifest] }`. Changes before welcome, or with no gateway connected, are silent; the next hello or resume carries the updated manifest. Each change sends one notification, with no coalescing.\n\nThese calls are synchronous and must run on the event loop thread, like the rest of the SDK.\n\n## What a handler may return\n\nOutput is converted to JSON before it leaves. Pydantic models go through `model_dump(mode=\"json\")`, enums through their value, mappings and sequences recursively, and `None`, `bool`, `int`, `float`, `str` as they are.\n\nAnything else has no defined wire shape, so it fails as an internal error rather than reaching the agent as the string of a repr. Return a model or a dict.\n\n## Failing on purpose\n\nRaise `ActionError` when the handler cannot produce its output:\n\n```python\nfrom pydantic import BaseModel\nfrom tesseron import ActionContext, ActionError, JsonObject\n\n\nclass TodoIdentifierInput(BaseModel):\n id: str\n\n\n@app.action(\"deleteTodo\", description=\"Delete one todo\")\nasync def delete_todo(input_data: TodoIdentifierInput, context: ActionContext) -> JsonObject:\n del context\n original_length = len(store.todos)\n store.todos[:] = [todo for todo in store.todos if todo.id != input_data.id]\n if len(store.todos) == original_length:\n raise ActionError.handler(\"Todo not found\", {\"kind\": \"not_found\"})\n await publish_todos()\n return {\"id\": input_data.id, \"removed\": True}\n```\n\nThis is the canonical `deleteTodo` shape from [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py).\n\n`ActionError.handler` sends its message and data to the agent as `-32005`. `ActionError.protocol(code, message, data)` does the same under a code you pick. `ActionError.internal(cause)` keeps the cause on your side and answers with a bare `-32603 Internal error`, which is what an unhandled exception in a handler is turned into too. See [errors](/sdk/python/errors/).\n","bodyText":"An action is a named, typed, handler-backed operation the agent can invoke. The gateway projects each one into an MCP tool.\n\n## The decorator\n\n```python\nfrom pydantic import BaseModel, Field\nfrom tesseron import ActionContext, JsonObject, TesseronApp\n\nclass AddTodoInput(BaseModel):\n text: str = Field(min_length=1)\n tag: str | None = None\n\ndef create_app() -> TesseronApp:\n app = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n\n @app.action(\"addTodo\", description=\"Add one todo\")\n async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject:\n del context\n todo = store.create(input_data.text, input_data.tag)\n await publish_todos()\n return todo_payload(todo)\n\n return app\n```\n\nThe `store`, `publish_todos`, and `todo_payload` names above come directly from the canonical [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py) host.\n\nEvery handler is `async def` and takes `(input_data, context)`. A handler that is not a coroutine function, or that does not take two parameters, raises `HostError` at registration. Registering one name twice raises `DuplicateNameError`, because the manifest has to stay unambiguous for the gateway to project it.\n\n## Input comes from the annotation\n\nIf the first parameter is annotated with a Pydantic `BaseModel`, that model is the input contract. Two things follow from it:\n\n1. The manifest publishes `model_json_schema(mode=\"validation\")`, unchanged. Validation mode is the right one here: the agent is producing input, not reading output, so aliases and defaults have to be described the way the model will accept them.\n2. Dispatch runs `model_validate` before the handler body. Input that does not fit is refused with [`-32004 InputValidation`](/protocol/errors/), and the handler never runs.\n\nThe refusal carries every problem Pydantic found, not just the first:\n\n```json\n{\n \"code\": -32004,\n \"message\": \"Invalid input\",\n \"data\": [\n { \"message\": \"String should have at least 1 character\", \"path\": [\"text\"] },\n { \"message\": \"Input should be a valid string\", \"path\": [\"tag\"] }\n ]\n}\n```\n\n## Raw JSON input\n\nAnnotate the first parameter with anything else and the handler takes the invocation input as raw JSON. Then `input_schema` is what the manifest publishes and `validate` is what enforces it:\n\n```python\nfrom tesseron import ActionContext, JsonValue, ValidationIssue\n\ndef positive_amount(raw_input: JsonValue) -> list[ValidationIssue]:\n if isinstance(raw_input, dict) and isinstance(raw_input.get(\"amount\"), int | float):\n return []\n return [ValidationIssue(message=\"amount must be a number\", path=[\"amount\"])]\n\n@app.action(\n \"charge\",\n description=\"Charge the saved card\",\n input_schema={\"type\": \"object\", \"properties\": {\"amount\": {\"type\": \"number\"}}},\n validate=positive_amount,\n)\nasync def charge(raw_input: JsonValue, context: ActionContext) -> JsonValue:\n return {\"charged\": True}\n```\n\nA non-empty issue list becomes the same `-32004` failure, with the same `data` shape. Leave `validate` off and nothing is checked: the schema is then documentation for the agent and nothing more.\n\n## The other options\n\n| Argument | What it does |\n| --- | --- |\n| `description` | Published in the manifest. The gateway uses it as the MCP tool description, so write it for the agent. |\n| `input_schema` | Overrides the schema derived from the model, or supplies one for a raw handler. |\n| `output_schema` | Published when set. Nothing validates output against it; it tells the agent what to expect. |\n| `timeout_ms` | Per-action deadline. Past it the invocation answers [`-32002 Timeout`](/protocol/errors/) and the handler task is cancelled. The default is 60 seconds. |\n| `validate` | Extra input check for a raw handler. Ignored when the input type comes from a model. |\n\n## Registering after listen\n\n`await app.listen()` returns a `TesseronHost`. Its `action` decorator has the same arguments as `app.action` and can register an action after the host starts:\n\n```python\nhost = await app.listen()\n\n@host.action(\"refresh\", description=\"Refresh cached data\")\nasync def refresh(raw_input: JsonObject, context: ActionContext) -> JsonObject:\n del raw_input, context\n return {\"ok\": True}\n```\n\nHost registration upserts by name. Registering an existing name replaces its descriptor and handler while keeping its position in the manifest. `host.remove_action(name)` returns `True` when an action was removed and `False` when the name was unknown. The app-level decorator still raises `DuplicateNameError` for a duplicate.\n\nAfter the gateway welcomes the session, each registry change sends `actions/list_changed` with `{ \"actions\": [full manifest] }`. Changes before welcome, or with no gateway connected, are silent; the next hello or resume carries the updated manifest. Each change sends one notification, with no coalescing.\n\nThese calls are synchronous and must run on the event loop thread, like the rest of the SDK.\n\n## What a handler may return\n\nOutput is converted to JSON before it leaves. Pydantic models go through `model_dump(mode=\"json\")`, enums through their value, mappings and sequences recursively, and `None`, `bool`, `int`, `float`, `str` as they are.\n\nAnything else has no defined wire shape, so it fails as an internal error rather than reaching the agent as the string of a repr. Return a model or a dict.\n\n## Failing on purpose\n\nRaise `ActionError` when the handler cannot produce its output:\n\n```python\nfrom pydantic import BaseModel\nfrom tesseron import ActionContext, ActionError, JsonObject\n\nclass TodoIdentifierInput(BaseModel):\n id: str\n\n@app.action(\"deleteTodo\", description=\"Delete one todo\")\nasync def delete_todo(input_data: TodoIdentifierInput, context: ActionContext) -> JsonObject:\n del context\n original_length = len(store.todos)\n store.todos[:] = [todo for todo in store.todos if todo.id != input_data.id]\n if len(store.todos) == original_length:\n raise ActionError.handler(\"Todo not found\", {\"kind\": \"not_found\"})\n await publish_todos()\n return {\"id\": input_data.id, \"removed\": True}\n```\n\nThis is the canonical `deleteTodo` shape from [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py).\n\n`ActionError.handler` sends its message and data to the agent as `-32005`. `ActionError.protocol(code, message, data)` does the same under a code you pick. `ActionError.internal(cause)` keeps the cause on your side and answers with a bare `-32603 Internal error`, which is what an unhandled exception in a handler is turned into too. See [errors](/sdk/python/errors/)."},{"slug":"sdk/python/conformance","title":"Conformance (Python)","description":"How the language-neutral runner drives the Python host, what passes, and what it skips.","section":"sdk","related":["sdk/python/index","sdk/porting","protocol/handshake"],"bodyRaw":"\nThe [conformance corpus](https://github.com/eigenwise/tesseron/tree/main/conformance) is the executable half of the protocol spec. It is language-neutral: the runner plays the gateway, and any SDK that can stand up a host from a fixture document can be checked against it.\n\n## Running it\n\nFrom the `tesseron-python` repository root:\n\n```bash\nuv sync --locked\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"uv run --locked python -m conformance_host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. The runner uses its bundled corpus; pass `--fixtures <path>` to test a hub checkout's current fixtures.\n\nThe current result on both Linux and Windows is **29 passed, 10 skipped, 0 failed** across the 39-fixture corpus.\n\n## What it skips, and why\n\nThe runner cross-checks the unsupported list against the four capability flags the host declares in `tesseron/hello`. A capability declared `true` in the SDK and named as unsupported fails the run, so this list cannot be used to hide a gap in something the host claims to do.\n\n- `host-minted-claim` skips the nine `bind/*` fixtures. This host takes gateway-minted claims only.\n- `uds` skips `uds/file-mode`. This host speaks WebSocket only. Set this tag on both platforms.\n\nThose are the only ten skips. WebSocket-only is by design for this release. The [canonical examples](https://github.com/Eigenwise/tesseron-python/tree/main/examples) exercise the same actions through the real gateway.\n\nNeither transport is a negotiated capability, so neither is covered by the four flags. Everything the host declares, streaming, subscriptions, sampling, and elicitation, is exercised by the fixtures that run.\n\n## The host adapter\n\n`conformance_host/` reads a fixture document and registers what it declares. It sits **beside** `src/tesseron` rather than inside it and imports the published package like any other consumer, so `uv build` produces a wheel with the SDK and nothing else.\n\nThe runner starts one host process per fixture with `TESSERON_CONFORMANCE_FIXTURE` pointing at the document, waits for a single readiness line on stdout, then plays the gateway against the endpoint that line names:\n\n```text\ntesseron-conformance-url=ws://127.0.0.1:62454/\n```\n\nExactly one stdout line. Every diagnostic goes to stderr, because a second stdout line fails the fixture. The process ends when the runner closes its stdin.\n\nAnything in the fixture grammar the host cannot serve is refused at launch rather than ignored. A fixture requiring `uds`, a fixture that mints its own claim, a fixture member the adapter does not know, and an `inputSchema` using a JSON Schema keyword the adapter cannot enforce all fail the launch. A fixture that would otherwise pass because a keyword was silently dropped fails the run instead.\n\n## Writing a port of your own\n\nThe [porting guide](/sdk/porting/) covers the contract. The Python adapter is a reasonable second reference next to the Rust one. It uses no test framework and touches nothing private: the fixture document goes in, and the same public API an application would call comes out.\n","bodyText":"The [conformance corpus](https://github.com/eigenwise/tesseron/tree/main/conformance) is the executable half of the protocol spec. It is language-neutral: the runner plays the gateway, and any SDK that can stand up a host from a fixture document can be checked against it.\n\n## Running it\n\nFrom the `tesseron-python` repository root:\n\n```bash\nuv sync --locked\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"uv run --locked python -m conformance_host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. The runner uses its bundled corpus; pass `--fixtures <path>` to test a hub checkout's current fixtures.\n\nThe current result on both Linux and Windows is **29 passed, 10 skipped, 0 failed** across the 39-fixture corpus.\n\n## What it skips, and why\n\nThe runner cross-checks the unsupported list against the four capability flags the host declares in `tesseron/hello`. A capability declared `true` in the SDK and named as unsupported fails the run, so this list cannot be used to hide a gap in something the host claims to do.\n\n- `host-minted-claim` skips the nine `bind/*` fixtures. This host takes gateway-minted claims only.\n- `uds` skips `uds/file-mode`. This host speaks WebSocket only. Set this tag on both platforms.\n\nThose are the only ten skips. WebSocket-only is by design for this release. The [canonical examples](https://github.com/Eigenwise/tesseron-python/tree/main/examples) exercise the same actions through the real gateway.\n\nNeither transport is a negotiated capability, so neither is covered by the four flags. Everything the host declares, streaming, subscriptions, sampling, and elicitation, is exercised by the fixtures that run.\n\n## The host adapter\n\n`conformance_host/` reads a fixture document and registers what it declares. It sits **beside** `src/tesseron` rather than inside it and imports the published package like any other consumer, so `uv build` produces a wheel with the SDK and nothing else.\n\nThe runner starts one host process per fixture with `TESSERON_CONFORMANCE_FIXTURE` pointing at the document, waits for a single readiness line on stdout, then plays the gateway against the endpoint that line names:\n\n```text\ntesseron-conformance-url=ws://127.0.0.1:62454/\n```\n\nExactly one stdout line. Every diagnostic goes to stderr, because a second stdout line fails the fixture. The process ends when the runner closes its stdin.\n\nAnything in the fixture grammar the host cannot serve is refused at launch rather than ignored. A fixture requiring `uds`, a fixture that mints its own claim, a fixture member the adapter does not know, and an `inputSchema` using a JSON Schema keyword the adapter cannot enforce all fail the launch. A fixture that would otherwise pass because a keyword was silently dropped fails the run instead.\n\n## Writing a port of your own\n\nThe [porting guide](/sdk/porting/) covers the contract. The Python adapter is a reasonable second reference next to the Rust one. It uses no test framework and touches nothing private: the fixture document goes in, and the same public API an application would call comes out."},{"slug":"sdk/python/context","title":"Context (Python)","description":"What an ActionContext tells a handler, and everything it can send back while it runs.","section":"sdk","related":["sdk/python/actions","protocol/progress-cancellation","protocol/sampling","protocol/elicitation"],"bodyRaw":"\nEvery handler gets `(input, context)`. The context is what the invocation knows and what it can send while it runs.\n\n## What it knows\n\n| Member | What it is |\n| --- | --- |\n| `action_name` | The name this invocation was made under. |\n| `invocation_id` | The id the agent gave this invocation. Every frame the context sends carries it. |\n| `agent` | `AgentIdentity(id, name)`. `pending` / `Awaiting agent` until the session is claimed. |\n| `agent_capabilities` | The negotiated intersection: `streaming`, `subscriptions`, `sampling`, `elicitation`. |\n| `origin` | The origin the application declared at construction. |\n| `route` | Where the agent was when it invoked, when the gateway sent one. `None` otherwise. |\n| `cancellation` | The shared cancellation signal. |\n| `is_cancelled` | Whether cancellation has already been requested. |\n\nThe context is assembled after the handshake settles, so `agent_capabilities` is never a guess: an invocation the gateway wrote straight after the welcome waits for the welcome to be applied before the handler sees it.\n\n## Progress\n\n```python\nfor index, text in enumerate(input_data.items, start=1):\n todo = store.create(text, input_data.tag)\n identifiers.append(todo.id)\n await context.progress(\n message=f\"{index}/{item_count} imported\", percent=index * 100 // item_count\n )\n```\n\nPercent is an integer from 0 to 100. Out-of-range values are clamped into range, and a value below one already sent for this invocation is raised back up to the running ceiling. An agent rendering a progress bar treats a backwards jump as a restart, and the message is worth more than the regression. Message and data travel unchanged.\n\nEvery argument is optional. Progress with only a message is a perfectly good frame. It is a notification, so nothing answers it and it costs the handler nothing to send.\n\n## Cancellation\n\nThe agent cancels with a notification, so nothing answers `actions/cancel`. The invocation it names answers `-32001` instead, and its task is cancelled.\n\nA handler that ignores the signal still gets its answer replaced, so long handlers should watch for it:\n\n```python\n@app.action(\"importTodos\", description=\"Import several todos\")\nasync def import_todos(input_data: ImportTodosInput, context: ActionContext) -> JsonObject:\n identifiers: list[str] = []\n item_count = len(input_data.items)\n for index, text in enumerate(input_data.items, start=1):\n todo = store.create(text, input_data.tag)\n identifiers.append(todo.id)\n await context.progress(\n message=f\"{index}/{item_count} imported\", percent=index * 100 // item_count\n )\n await publish_todos()\n return {\"added\": len(identifiers), \"ids\": json_string_array(identifiers)}\n```\n\n`await context.cancellation.wait()` resolves as soon as cancellation is requested, immediately if it already was, which is what you race a long await against.\n\n## Sampling\n\n```python\nawait context.progress(message=\"asking LLM...\", percent=25)\nsuggested = await context.sample_as(\n SuggestedTodos,\n (\n f'Produce exactly {count} concrete todo items for the theme \"{input_data.theme}\". '\n \"Return JSON matching { items: string[] }. Items should be short, imperative, \"\n \"and user-friendly. No numbering.\"\n ),\n max_tokens=400,\n)\n```\n\n`sample_as` derives the output schema from a Pydantic model and decodes the structured response into it. The canonical todo host uses `SuggestedTodos` with an `items: list[str]` field.\n\nA model asked for structured output answers with the JSON as text, so a string result is parsed before it is decoded.\n\nAn agent that never negotiated sampling gets you `-32006 SamplingNotAvailable` before a frame goes out. Sampling depth is not a field in any Tesseron frame: the gateway owns `maxSamplingDepth` and answers `-32008` itself, so the host forwards the request without counting.\n\n## Confirmation\n\n```python\nconfirmed = await context.confirm(\n f'Delete prompt \"{prompt.name}\" (tested {prompt.times_tested}x)? This cannot be undone.'\n)\nif not confirmed:\n return {\"id\": input_data.id, \"deleted\": False, \"cancelled\": True}\n```\n\n`True` only on an explicit accept. A decline, a cancel, and an agent that never negotiated elicitation all answer `False`, which is the safe reading for the destructive-operation gates this exists for. It never raises on the user's answer.\n\n## Elicitation\n\n```python\nanswer = await context.elicit_as(RenameTodoAnswer, f'Rename \"{todo.text}\" to?')\nif answer is None:\n return {\"id\": input_data.id, \"renamed\": False, \"cancelled\": True}\ntodo.text = answer.new_name\n```\n\n`None` on a decline or a cancel. Unlike `confirm`, a missing capability is an error here: structured content has no safe default, so the handler has to branch on it explicitly.\n\nMCP renders an elicit prompt as a flat form, so the schema has to be one object of primitive leaves. The host checks that on the send path, before the frame leaves, so a bad schema fails at the `elicit` call site with `-32602 InvalidParams` instead of surfacing as a gateway rejection three hops later. Top-level `oneOf`, `anyOf`, `allOf`, `not`, and object- or array-typed properties are all refused. A property with no usable type is accepted unchanged, and a `type` array is checked on its first entry.\n\nLeave `json_schema` off and the host sends a one-text-field schema, which is the least a client can render.\n\n`elicit_as` derives the form schema from a Pydantic model and decodes the accepted answer into it. The todo example declares the answer this way:\n\n```python\nclass RenameTodoAnswer(BaseModel):\n new_name: str = Field(alias=\"newName\", min_length=1)\n```\n\n\n## Logs\n\n```python\nawait context.log(\"saved\", level=LogLevel.WARN, meta={\"todoId\": \"t-1\"})\n```\n\nFire and forget, forwarded to the agent. Levels are `debug`, `info`, `warn`, `error`, matching the MCP levels the gateway forwards to.\n\n## Testing a handler without a gateway\n\n`ActionContext.detached(action_name)` builds a context with no connection behind it. Notifications go nowhere, which is what a fire-and-forget frame does on a closed socket anyway, and every request answers `-32010 TransportClosed` rather than hanging.\n\n```python\noutput = await add_todo(\n AddTodoInput(text=\"buy milk\"), ActionContext.detached(\"addTodo\")\n)\n```\n\nA live invocation sees the same `-32010` if the transport drops underneath it: every request still waiting on an answer fails with it rather than hanging, and a request started after the socket is gone fails immediately. The invocation itself is cancelled at that point, so a handler that watches `cancellation` gets to unwind.\n","bodyText":"Every handler gets `(input, context)`. The context is what the invocation knows and what it can send while it runs.\n\n## What it knows\n\n| Member | What it is |\n| --- | --- |\n| `action_name` | The name this invocation was made under. |\n| `invocation_id` | The id the agent gave this invocation. Every frame the context sends carries it. |\n| `agent` | `AgentIdentity(id, name)`. `pending` / `Awaiting agent` until the session is claimed. |\n| `agent_capabilities` | The negotiated intersection: `streaming`, `subscriptions`, `sampling`, `elicitation`. |\n| `origin` | The origin the application declared at construction. |\n| `route` | Where the agent was when it invoked, when the gateway sent one. `None` otherwise. |\n| `cancellation` | The shared cancellation signal. |\n| `is_cancelled` | Whether cancellation has already been requested. |\n\nThe context is assembled after the handshake settles, so `agent_capabilities` is never a guess: an invocation the gateway wrote straight after the welcome waits for the welcome to be applied before the handler sees it.\n\n## Progress\n\n```python\nfor index, text in enumerate(input_data.items, start=1):\n todo = store.create(text, input_data.tag)\n identifiers.append(todo.id)\n await context.progress(\n message=f\"{index}/{item_count} imported\", percent=index * 100 // item_count\n )\n```\n\nPercent is an integer from 0 to 100. Out-of-range values are clamped into range, and a value below one already sent for this invocation is raised back up to the running ceiling. An agent rendering a progress bar treats a backwards jump as a restart, and the message is worth more than the regression. Message and data travel unchanged.\n\nEvery argument is optional. Progress with only a message is a perfectly good frame. It is a notification, so nothing answers it and it costs the handler nothing to send.\n\n## Cancellation\n\nThe agent cancels with a notification, so nothing answers `actions/cancel`. The invocation it names answers `-32001` instead, and its task is cancelled.\n\nA handler that ignores the signal still gets its answer replaced, so long handlers should watch for it:\n\n```python\n@app.action(\"importTodos\", description=\"Import several todos\")\nasync def import_todos(input_data: ImportTodosInput, context: ActionContext) -> JsonObject:\n identifiers: list[str] = []\n item_count = len(input_data.items)\n for index, text in enumerate(input_data.items, start=1):\n todo = store.create(text, input_data.tag)\n identifiers.append(todo.id)\n await context.progress(\n message=f\"{index}/{item_count} imported\", percent=index * 100 // item_count\n )\n await publish_todos()\n return {\"added\": len(identifiers), \"ids\": json_string_array(identifiers)}\n```\n\n`await context.cancellation.wait()` resolves as soon as cancellation is requested, immediately if it already was, which is what you race a long await against.\n\n## Sampling\n\n```python\nawait context.progress(message=\"asking LLM...\", percent=25)\nsuggested = await context.sample_as(\n SuggestedTodos,\n (\n f'Produce exactly {count} concrete todo items for the theme \"{input_data.theme}\". '\n \"Return JSON matching { items: string[] }. Items should be short, imperative, \"\n \"and user-friendly. No numbering.\"\n ),\n max_tokens=400,\n)\n```\n\n`sample_as` derives the output schema from a Pydantic model and decodes the structured response into it. The canonical todo host uses `SuggestedTodos` with an `items: list[str]` field.\n\nA model asked for structured output answers with the JSON as text, so a string result is parsed before it is decoded.\n\nAn agent that never negotiated sampling gets you `-32006 SamplingNotAvailable` before a frame goes out. Sampling depth is not a field in any Tesseron frame: the gateway owns `maxSamplingDepth` and answers `-32008` itself, so the host forwards the request without counting.\n\n## Confirmation\n\n```python\nconfirmed = await context.confirm(\n f'Delete prompt \"{prompt.name}\" (tested {prompt.times_tested}x)? This cannot be undone.'\n)\nif not confirmed:\n return {\"id\": input_data.id, \"deleted\": False, \"cancelled\": True}\n```\n\n`True` only on an explicit accept. A decline, a cancel, and an agent that never negotiated elicitation all answer `False`, which is the safe reading for the destructive-operation gates this exists for. It never raises on the user's answer.\n\n## Elicitation\n\n```python\nanswer = await context.elicit_as(RenameTodoAnswer, f'Rename \"{todo.text}\" to?')\nif answer is None:\n return {\"id\": input_data.id, \"renamed\": False, \"cancelled\": True}\ntodo.text = answer.new_name\n```\n\n`None` on a decline or a cancel. Unlike `confirm`, a missing capability is an error here: structured content has no safe default, so the handler has to branch on it explicitly.\n\nMCP renders an elicit prompt as a flat form, so the schema has to be one object of primitive leaves. The host checks that on the send path, before the frame leaves, so a bad schema fails at the `elicit` call site with `-32602 InvalidParams` instead of surfacing as a gateway rejection three hops later. Top-level `oneOf`, `anyOf`, `allOf`, `not`, and object- or array-typed properties are all refused. A property with no usable type is accepted unchanged, and a `type` array is checked on its first entry.\n\nLeave `json_schema` off and the host sends a one-text-field schema, which is the least a client can render.\n\n`elicit_as` derives the form schema from a Pydantic model and decodes the accepted answer into it. The todo example declares the answer this way:\n\n```python\nclass RenameTodoAnswer(BaseModel):\n new_name: str = Field(alias=\"newName\", min_length=1)\n```\n\n## Logs\n\n```python\nawait context.log(\"saved\", level=LogLevel.WARN, meta={\"todoId\": \"t-1\"})\n```\n\nFire and forget, forwarded to the agent. Levels are `debug`, `info`, `warn`, `error`, matching the MCP levels the gateway forwards to.\n\n## Testing a handler without a gateway\n\n`ActionContext.detached(action_name)` builds a context with no connection behind it. Notifications go nowhere, which is what a fire-and-forget frame does on a closed socket anyway, and every request answers `-32010 TransportClosed` rather than hanging.\n\n```python\noutput = await add_todo(\n AddTodoInput(text=\"buy milk\"), ActionContext.detached(\"addTodo\")\n)\n```\n\nA live invocation sees the same `-32010` if the transport drops underneath it: every request still waiting on an answer fails with it rather than hanging, and a request started after the socket is gone fails immediately. The invocation itself is cancelled at that point, so a handler that watches `cancellation` gets to unwind."},{"slug":"sdk/python/errors","title":"Errors (Python)","description":"The closed error-code set, the three ways a handler fails, and the host errors that never reach the wire.","section":"sdk","related":["sdk/python/actions","sdk/python/index","protocol/errors"],"bodyRaw":"\n## Envelope errors\n\nThe host follows the wire-format ID rules. A request with `id: null` is answered with `id: null`; only an absent `id` is a notification. A frame without `jsonrpc: \"2.0\"` is answered with `-32600 Invalid Request`, carrying the readable request id through or using `null` when there is no usable id.\n\n`TesseronErrorCode` is an `IntEnum` carrying every code the protocol defines. The set is closed: a gateway that sends an integer outside it speaks a protocol this package does not implement, so `ProtocolError` keeps the raw integer and `named_code` answers `None` rather than inventing a member.\n\n| Code | Member | When |\n| --- | --- | --- |\n| `-32700` | `PARSE_ERROR` | The peer sent something that is not JSON. |\n| `-32600` | `INVALID_REQUEST` | Not a JSON-RPC 2.0 envelope. |\n| `-32601` | `METHOD_NOT_FOUND` | A method this host does not answer. |\n| `-32602` | `INVALID_PARAMS` | Params the method cannot use, including an elicit schema MCP cannot render. |\n| `-32603` | `INTERNAL_ERROR` | Anything unexpected. Never carries detail. |\n| `-32000` | `PROTOCOL_MISMATCH` | The two sides speak different protocol majors. |\n| `-32001` | `CANCELLED` | The agent cancelled the invocation. |\n| `-32002` | `TIMEOUT` | The invocation passed its deadline. |\n| `-32003` | `ACTION_NOT_FOUND` | No such action, or no such readable or subscribable resource. |\n| `-32004` | `INPUT_VALIDATION` | Input did not satisfy the declared schema. |\n| `-32005` | `HANDLER_ERROR` | A domain failure the handler reported on purpose. |\n| `-32006` | `SAMPLING_NOT_AVAILABLE` | The agent never negotiated sampling. |\n| `-32007` | `ELICITATION_NOT_AVAILABLE` | The agent never negotiated elicitation. |\n| `-32008` | `SAMPLING_DEPTH_EXCEEDED` | The gateway's own sampling-depth guard. |\n| `-32009` | `UNAUTHORIZED` | The session is not claimed, or the claim does not cover this. |\n| `-32010` | `TRANSPORT_CLOSED` | The connection went away with a request still in flight. |\n| `-32011` | `RESUME_FAILED` | The gateway refused the resume credentials. |\n\n`TesseronErrorCode.from_wire_code(code)` names a wire integer, or answers `None` for one this version does not define.\n\n## The three ways a handler fails\n\n```python\nfrom tesseron import ActionError, TesseronErrorCode\n\n\nraise ActionError.handler(\"Todo not found\", {\"kind\": \"not_found\"})\nraise ActionError.protocol(\n TesseronErrorCode.UNAUTHORIZED, \"this agent cannot charge cards\"\n)\nraise ActionError.internal(RuntimeError(\"database unavailable\"))\n```\n\nThe distinction that matters is what crosses the socket. `handler` and `protocol` send their message and data to the agent. `internal` keeps the cause on your side, reachable through `internal_source`, and answers with a bare `-32603 Internal error`: a stack trace or a database URL in a handler error is a leak.\n\nAn exception that is not an `ActionError` is turned into `ActionError.internal` automatically, so an unhandled failure in a handler never spills detail either. `with_data(data)` attaches structured detail the agent can branch on.\n\n## ProtocolError\n\n`ProtocolError` is the `error` member of a JSON-RPC failure, exactly as it travels: `code`, `message`, `data`. It is what the SDK raises when the gateway refuses something the host asked for, and what `to_wire()` produces for a failure the host is sending.\n\n## Host errors\n\nThese never reach the wire. They are how the host tells you it cannot start.\n\n| Exception | When |\n| --- | --- |\n| `HostError` | The base. A handler that is not a coroutine function, or that does not take `(input_data, context)`. |\n| `InvalidApplicationIdError` | The application id is reserved or does not match `^[a-z][a-z0-9_]*$`. |\n| `DuplicateNameError` | Two actions, or two resources, under one name. |\n| `ManifestError` | The instance manifest could not be written or removed. |\n| `MissingApplicationError` | No application descriptor was registered before `listen`. |\n","bodyText":"## Envelope errors\n\nThe host follows the wire-format ID rules. A request with `id: null` is answered with `id: null`; only an absent `id` is a notification. A frame without `jsonrpc: \"2.0\"` is answered with `-32600 Invalid Request`, carrying the readable request id through or using `null` when there is no usable id.\n\n`TesseronErrorCode` is an `IntEnum` carrying every code the protocol defines. The set is closed: a gateway that sends an integer outside it speaks a protocol this package does not implement, so `ProtocolError` keeps the raw integer and `named_code` answers `None` rather than inventing a member.\n\n| Code | Member | When |\n| --- | --- | --- |\n| `-32700` | `PARSE_ERROR` | The peer sent something that is not JSON. |\n| `-32600` | `INVALID_REQUEST` | Not a JSON-RPC 2.0 envelope. |\n| `-32601` | `METHOD_NOT_FOUND` | A method this host does not answer. |\n| `-32602` | `INVALID_PARAMS` | Params the method cannot use, including an elicit schema MCP cannot render. |\n| `-32603` | `INTERNAL_ERROR` | Anything unexpected. Never carries detail. |\n| `-32000` | `PROTOCOL_MISMATCH` | The two sides speak different protocol majors. |\n| `-32001` | `CANCELLED` | The agent cancelled the invocation. |\n| `-32002` | `TIMEOUT` | The invocation passed its deadline. |\n| `-32003` | `ACTION_NOT_FOUND` | No such action, or no such readable or subscribable resource. |\n| `-32004` | `INPUT_VALIDATION` | Input did not satisfy the declared schema. |\n| `-32005` | `HANDLER_ERROR` | A domain failure the handler reported on purpose. |\n| `-32006` | `SAMPLING_NOT_AVAILABLE` | The agent never negotiated sampling. |\n| `-32007` | `ELICITATION_NOT_AVAILABLE` | The agent never negotiated elicitation. |\n| `-32008` | `SAMPLING_DEPTH_EXCEEDED` | The gateway's own sampling-depth guard. |\n| `-32009` | `UNAUTHORIZED` | The session is not claimed, or the claim does not cover this. |\n| `-32010` | `TRANSPORT_CLOSED` | The connection went away with a request still in flight. |\n| `-32011` | `RESUME_FAILED` | The gateway refused the resume credentials. |\n\n`TesseronErrorCode.from_wire_code(code)` names a wire integer, or answers `None` for one this version does not define.\n\n## The three ways a handler fails\n\n```python\nfrom tesseron import ActionError, TesseronErrorCode\n\nraise ActionError.handler(\"Todo not found\", {\"kind\": \"not_found\"})\nraise ActionError.protocol(\n TesseronErrorCode.UNAUTHORIZED, \"this agent cannot charge cards\"\n)\nraise ActionError.internal(RuntimeError(\"database unavailable\"))\n```\n\nThe distinction that matters is what crosses the socket. `handler` and `protocol` send their message and data to the agent. `internal` keeps the cause on your side, reachable through `internal_source`, and answers with a bare `-32603 Internal error`: a stack trace or a database URL in a handler error is a leak.\n\nAn exception that is not an `ActionError` is turned into `ActionError.internal` automatically, so an unhandled failure in a handler never spills detail either. `with_data(data)` attaches structured detail the agent can branch on.\n\n## ProtocolError\n\n`ProtocolError` is the `error` member of a JSON-RPC failure, exactly as it travels: `code`, `message`, `data`. It is what the SDK raises when the gateway refuses something the host asked for, and what `to_wire()` produces for a failure the host is sending.\n\n## Host errors\n\nThese never reach the wire. They are how the host tells you it cannot start.\n\n| Exception | When |\n| --- | --- |\n| `HostError` | The base. A handler that is not a coroutine function, or that does not take `(input_data, context)`. |\n| `InvalidApplicationIdError` | The application id is reserved or does not match `^[a-z][a-z0-9_]*$`. |\n| `DuplicateNameError` | Two actions, or two resources, under one name. |\n| `ManifestError` | The instance manifest could not be written or removed. |\n| `MissingApplicationError` | No application descriptor was registered before `listen`. |"},{"slug":"sdk/python/index","title":"Python SDK","description":"The Python implementation of the Tesseron host protocol, built on asyncio and Pydantic v2.","section":"sdk","related":["sdk/index","sdk/python/actions","sdk/python/conformance","protocol/compatibility"],"bodyRaw":"\nSource: [github.com/Eigenwise/tesseron-python](https://github.com/Eigenwise/tesseron-python)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-python)\n\n`tesseron` is the Python host SDK. Your application listens on loopback, the MCP gateway dials in, and the agent gets typed actions and readable resources.\n\nIt speaks protocol [**1.2.0**](/protocol/), the same version the TypeScript, Rust, and C++ SDKs speak. Compatibility is decided by protocol version, never by matching package numbers: see the [compatibility contract](/protocol/compatibility/).\n\nThe host follows the protocol's envelope rules. A request with `id: null` is still a request and gets an answer with `id: null`; only an absent `id` makes a notification. A frame without `jsonrpc: \"2.0\"` gets `-32600 Invalid Request`, with its readable request id carried through or `null` when there is no usable id.\n\nThe package is published on [PyPI](https://pypi.org/project/tesseron/) and versions independently of the TypeScript SDK. Install it with `uv add tesseron`. Source and examples live in the `tesseron-python` repository.\n\n## Requirements\n\nPython 3.11 or newer. Two runtime dependencies: Pydantic v2 and `websockets`. Everything else is stdlib asyncio.\n\n## A first host\n\n```python\nfrom __future__ import annotations\n\nimport asyncio\n\nfrom pydantic import BaseModel, Field\n\nfrom tesseron import ActionContext, JsonObject, JsonValue, TesseronApp\n\n\nclass AddTodoInput(BaseModel):\n text: str = Field(min_length=1)\n tag: str | None = None\n\n\ndef create_app() -> TesseronApp:\n app = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n store = TodoStore()\n\n async def read_todos() -> JsonValue:\n return [todo_payload(todo) for todo in store.todos]\n\n todos_resource = app.resource(\n \"todos://all\",\n description=\"The complete todo list. Pushed on every mutation.\",\n read=read_todos,\n subscribable=True,\n )\n\n async def publish_todos() -> None:\n await todos_resource.publish(await read_todos())\n\n @app.action(\"addTodo\", description=\"Add one todo\")\n async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject:\n del context\n todo = store.create(input_data.text, input_data.tag)\n await publish_todos()\n return todo_payload(todo)\n\n return app\n\n\nasync def main() -> None:\n app = create_app()\n host = await app.listen()\n try:\n await asyncio.Event().wait()\n finally:\n await host.shutdown()\n```\n\nThe `TodoStore` and `todo_payload` definitions in this excerpt are the ones in [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py). The complete example also registers the other canonical actions.\n\n`app.listen()` binds `127.0.0.1` on a port the OS picks, writes the instance manifest the gateway watches for, and answers with a `TesseronHost` carrying the URL and the manifest path. Nothing dials out. The `app.action` and `app.resource` registrations in this example are made before `listen()`; the returned host can register, replace, and remove actions and resources at runtime.\n\nThe application id has to match `^[a-z][a-z0-9_]*$` and cannot be `tesseron`, `mcp`, or `system`: the gateway uses it as an MCP tool prefix. An id that fails either rule raises `InvalidApplicationIdError` from `listen()` rather than binding a socket nobody can use.\n\n## What it covers\n\nHandshake and claiming, session resume with in-memory token rotation, action invocation with input validation, cancellation, per-action timeouts, streaming progress, sampling, confirmation, schema-checked elicitation, structured logs, and resources with reads, subscriptions, and pushes. All four capability flags are declared true.\n\nGateway-minted claims only, and WebSocket only. There is no Unix domain socket transport and no host-minted bind in this release, so the [conformance suite](/sdk/python/conformance/) skips those fixtures rather than pretending.\n\n## The manifest\n\n`listen()` publishes a v2 instance manifest into `~/.tesseron/instances/` once the URL is known: `0700` on the directory, `0600` on the file, removed again on `shutdown()`. POSIX modes are advisory on Windows, where the user account is the gate.\n\nPoint it somewhere else, or switch it off, with `ManifestPublication`:\n\n```python\nfrom pathlib import Path\n\nfrom tesseron import ManifestPublication, TesseronApp\n\nTesseronApp(id=\"python_todo\", name=\"Python Todo\", manifest=ManifestPublication.in_directory(Path(\"/tmp/x\")))\nTesseronApp(id=\"python_todo\", name=\"Python Todo\", manifest=ManifestPublication.disabled())\n```\n\nDisabling it is what a test harness wants. The conformance host does exactly that, because the runner dials an endpoint it was told about and should never touch a developer's `~/.tesseron`.\n\n## Session events\n\n`app.add_event_listener(listener)` takes a plain callable and gets `WelcomeEvent`, `ClaimedEvent`, `HandshakeFailedEvent`, and `DisconnectedEvent`. A listener that raises is logged and skipped: one bad listener must not break the session it was told about.\n\n```python\nfrom tesseron import ClaimedEvent, HostEvent\n\n\ndef watch(event: HostEvent) -> None:\n if isinstance(event, ClaimedEvent):\n print(\"claimed by\", event.claimed.agent.name)\n\n\napp.add_event_listener(watch)\n```\n\n## Development\n\nRun these checks from the `tesseron-python` repository root:\n\n```bash\nuv sync --locked\nuv run --locked ruff check .\nuv run --locked ruff format --check .\nuv run --locked mypy --strict src tests\nuv run --locked pytest\nuv build\n```\n\nRun the [conformance check](/sdk/python/conformance/) after the unit suite.\n\n## Next\n\n- [Actions](/sdk/python/actions/): the decorator, input inference, and what a handler may return.\n- [Resources](/sdk/python/resources/): reads, subscriptions, and pushes.\n- [Context](/sdk/python/context/): progress, sampling, confirmation, elicitation, logs, cancellation.\n- [Errors](/sdk/python/errors/): the code catalog and the three ways a handler fails.\n- [Conformance](/sdk/python/conformance/): how the runner drives the host, and what it skips.\n","bodyText":"Source: [github.com/Eigenwise/tesseron-python](https://github.com/Eigenwise/tesseron-python)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-python)\n\n`tesseron` is the Python host SDK. Your application listens on loopback, the MCP gateway dials in, and the agent gets typed actions and readable resources.\n\nIt speaks protocol [**1.2.0**](/protocol/), the same version the TypeScript, Rust, and C++ SDKs speak. Compatibility is decided by protocol version, never by matching package numbers: see the [compatibility contract](/protocol/compatibility/).\n\nThe host follows the protocol's envelope rules. A request with `id: null` is still a request and gets an answer with `id: null`; only an absent `id` makes a notification. A frame without `jsonrpc: \"2.0\"` gets `-32600 Invalid Request`, with its readable request id carried through or `null` when there is no usable id.\n\nThe package is published on [PyPI](https://pypi.org/project/tesseron/) and versions independently of the TypeScript SDK. Install it with `uv add tesseron`. Source and examples live in the `tesseron-python` repository.\n\n## Requirements\n\nPython 3.11 or newer. Two runtime dependencies: Pydantic v2 and `websockets`. Everything else is stdlib asyncio.\n\n## A first host\n\n```python\nfrom __future__ import annotations\n\nfrom pydantic import BaseModel, Field\n\nfrom tesseron import ActionContext, JsonObject, JsonValue, TesseronApp\n\nclass AddTodoInput(BaseModel):\n text: str = Field(min_length=1)\n tag: str | None = None\n\ndef create_app() -> TesseronApp:\n app = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n store = TodoStore()\n\n async def read_todos() -> JsonValue:\n return [todo_payload(todo) for todo in store.todos]\n\n todos_resource = app.resource(\n \"todos://all\",\n description=\"The complete todo list. Pushed on every mutation.\",\n read=read_todos,\n subscribable=True,\n )\n\n async def publish_todos() -> None:\n await todos_resource.publish(await read_todos())\n\n @app.action(\"addTodo\", description=\"Add one todo\")\n async def add_todo(input_data: AddTodoInput, context: ActionContext) -> JsonObject:\n del context\n todo = store.create(input_data.text, input_data.tag)\n await publish_todos()\n return todo_payload(todo)\n\n return app\n\nasync def main() -> None:\n app = create_app()\n host = await app.listen()\n try:\n await asyncio.Event().wait()\n finally:\n await host.shutdown()\n```\n\nThe `TodoStore` and `todo_payload` definitions in this excerpt are the ones in [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py). The complete example also registers the other canonical actions.\n\n`app.listen()` binds `127.0.0.1` on a port the OS picks, writes the instance manifest the gateway watches for, and answers with a `TesseronHost` carrying the URL and the manifest path. Nothing dials out. The `app.action` and `app.resource` registrations in this example are made before `listen()`; the returned host can register, replace, and remove actions and resources at runtime.\n\nThe application id has to match `^[a-z][a-z0-9_]*$` and cannot be `tesseron`, `mcp`, or `system`: the gateway uses it as an MCP tool prefix. An id that fails either rule raises `InvalidApplicationIdError` from `listen()` rather than binding a socket nobody can use.\n\n## What it covers\n\nHandshake and claiming, session resume with in-memory token rotation, action invocation with input validation, cancellation, per-action timeouts, streaming progress, sampling, confirmation, schema-checked elicitation, structured logs, and resources with reads, subscriptions, and pushes. All four capability flags are declared true.\n\nGateway-minted claims only, and WebSocket only. There is no Unix domain socket transport and no host-minted bind in this release, so the [conformance suite](/sdk/python/conformance/) skips those fixtures rather than pretending.\n\n## The manifest\n\n`listen()` publishes a v2 instance manifest into `~/.tesseron/instances/` once the URL is known: `0700` on the directory, `0600` on the file, removed again on `shutdown()`. POSIX modes are advisory on Windows, where the user account is the gate.\n\nPoint it somewhere else, or switch it off, with `ManifestPublication`:\n\n```python\nfrom pathlib import Path\n\nfrom tesseron import ManifestPublication, TesseronApp\n\nTesseronApp(id=\"python_todo\", name=\"Python Todo\", manifest=ManifestPublication.in_directory(Path(\"/tmp/x\")))\nTesseronApp(id=\"python_todo\", name=\"Python Todo\", manifest=ManifestPublication.disabled())\n```\n\nDisabling it is what a test harness wants. The conformance host does exactly that, because the runner dials an endpoint it was told about and should never touch a developer's `~/.tesseron`.\n\n## Session events\n\n`app.add_event_listener(listener)` takes a plain callable and gets `WelcomeEvent`, `ClaimedEvent`, `HandshakeFailedEvent`, and `DisconnectedEvent`. A listener that raises is logged and skipped: one bad listener must not break the session it was told about.\n\n```python\nfrom tesseron import ClaimedEvent, HostEvent\n\ndef watch(event: HostEvent) -> None:\n if isinstance(event, ClaimedEvent):\n print(\"claimed by\", event.claimed.agent.name)\n\napp.add_event_listener(watch)\n```\n\n## Development\n\nRun these checks from the `tesseron-python` repository root:\n\n```bash\nuv sync --locked\nuv run --locked ruff check .\nuv run --locked ruff format --check .\nuv run --locked mypy --strict src tests\nuv run --locked pytest\nuv build\n```\n\nRun the [conformance check](/sdk/python/conformance/) after the unit suite.\n\n## Next\n\n- [Actions](/sdk/python/actions/): the decorator, input inference, and what a handler may return.\n- [Resources](/sdk/python/resources/): reads, subscriptions, and pushes.\n- [Context](/sdk/python/context/): progress, sampling, confirmation, elicitation, logs, cancellation.\n- [Errors](/sdk/python/errors/): the code catalog and the three ways a handler fails.\n- [Conformance](/sdk/python/conformance/): how the runner drives the host, and what it skips."},{"slug":"sdk/python/resources","title":"Resources (Python)","description":"Readable and optionally subscribable application state, with pushes and cleanup.","section":"sdk","related":["sdk/python/index","sdk/python/actions","protocol/resources"],"bodyRaw":"\nA resource is named application state the agent can read, and optionally follow. Actions change things; resources report them.\n\n## Registering one\n\n```python\nfrom tesseron import JsonValue, TesseronApp\n\napp = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n\n\nasync def read_todos() -> JsonValue:\n return [todo_payload(todo) for todo in store.todos]\n\n\ntodos_resource = app.resource(\n \"todos://all\",\n description=\"The complete todo list. Pushed on every mutation.\",\n read=read_todos,\n subscribable=True,\n)\n```\n\nThis is the resource from the canonical [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py).\n\n`app.resource` answers with the `Resource` handle, which is what you push updates through. The reader is `async` and runs on every `resources/read`, so it always reports the current value rather than a snapshot taken at registration.\n\nRegistering one name twice raises `DuplicateNameError`.\n\n## Registering after listen\n\n`await app.listen()` returns a `TesseronHost`. Its `resource` method has the same arguments as `app.resource` and can register a resource after the host starts:\n\n```python\nhost = await app.listen()\n\nhost.resource(\n \"todos://all\",\n description=\"The complete todo list.\",\n read=read_todos,\n subscribable=True,\n)\n```\n\nHost registration upserts by name. Registering an existing name replaces its descriptor, reader, and subscription handler while keeping its position in the manifest. `host.remove_resource(name)` returns `True` when a resource was removed and `False` when the name was unknown. Replacing or removing a resource stops its live subscriptions, and the agent resubscribes as needed. The app-level method still raises `DuplicateNameError` for a duplicate.\n\nAfter the gateway welcomes the session, each registry change sends `resources/list_changed` with `{ \"resources\": [...] }`. Changes before welcome, or with no gateway connected, are silent; the next hello or resume carries the updated manifest. Each change sends one notification, with no coalescing.\n\nThese calls are synchronous and must run on the event loop thread, like the rest of the SDK.\n\n## Pushing updates\n\n```python\nawait todos_resource.publish(await read_todos())\n```\n\n`publish` goes to every agent currently subscribed to that resource, and does nothing when nobody is. Call it from wherever the state actually changes.\n\n## Subscribing with your own source\n\nSome state has a natural event source: a file watcher, a database listener, a queue. Hand `subscribe` a callback that starts it and answers with the cleanup that stops again:\n\n```python\nfrom tesseron import Emit, JsonValue, TesseronApp, Unsubscribe\n\napp = TesseronApp(id=\"python_prompts\", name=\"Python Prompts\")\n\n\nasync def read_library() -> JsonValue:\n return [prompt_payload(prompt) for prompt in store.library()]\n\n\ndef follow(emit: Emit) -> Unsubscribe:\n watcher = start_watching(lambda value: emit(value))\n return watcher.stop\n\n\nlibrary_resource = app.resource(\n \"library\",\n description=\"Live snapshot of every prompt in the library. Pushed on every change.\",\n read=read_library,\n subscribable=True,\n subscribe=follow,\n)\n```\n\n`follow` is the application-specific addition to the canonical prompts app when a separate event source owns the updates.\n\nThe callback is synchronous and runs inside the session's read loop, so start your work and return promptly rather than awaiting in it. The cleanup runs when the agent unsubscribes, and again when the connection drops: a subscriber still holding a listener would emit into a closed socket for as long as the application runs.\n\nPassing `subscribe` implies `subscribable=True`. A resource is also subscribable on `subscribable=True` alone, because `publish` is enough on its own to push updates.\n\n## What the wire does\n\n`resources/subscribe` and `resources/unsubscribe` both acknowledge with `result: null`. The acknowledgement goes out **before** the subscriber runs, so a value the subscriber emits immediately cannot overtake the response the agent is still waiting on.\n\nUnsubscribing an id nobody registered is not an error. The agent and the transport can race, and there is nothing left to tear down either way.\n\nReading a resource that was never declared answers `-32003` with `Resource not readable: <name>`. Subscribing to one that was never declared, or to one that is not subscribable, answers `-32003` with `Resource not subscribable: <name>`. That is the same answer `@tesseron/core` gives.\n\nA reader that raises `ActionError` sends that failure to the agent. A reader that raises anything else answers a bare `-32603`, with the cause kept on your side.\n","bodyText":"A resource is named application state the agent can read, and optionally follow. Actions change things; resources report them.\n\n## Registering one\n\n```python\nfrom tesseron import JsonValue, TesseronApp\n\napp = TesseronApp(id=\"python_todo\", name=\"Python Todo\")\n\nasync def read_todos() -> JsonValue:\n return [todo_payload(todo) for todo in store.todos]\n\ntodos_resource = app.resource(\n \"todos://all\",\n description=\"The complete todo list. Pushed on every mutation.\",\n read=read_todos,\n subscribable=True,\n)\n```\n\nThis is the resource from the canonical [`examples/todo/app.py`](https://github.com/Eigenwise/tesseron-python/blob/main/examples/todo/app.py).\n\n`app.resource` answers with the `Resource` handle, which is what you push updates through. The reader is `async` and runs on every `resources/read`, so it always reports the current value rather than a snapshot taken at registration.\n\nRegistering one name twice raises `DuplicateNameError`.\n\n## Registering after listen\n\n`await app.listen()` returns a `TesseronHost`. Its `resource` method has the same arguments as `app.resource` and can register a resource after the host starts:\n\n```python\nhost = await app.listen()\n\nhost.resource(\n \"todos://all\",\n description=\"The complete todo list.\",\n read=read_todos,\n subscribable=True,\n)\n```\n\nHost registration upserts by name. Registering an existing name replaces its descriptor, reader, and subscription handler while keeping its position in the manifest. `host.remove_resource(name)` returns `True` when a resource was removed and `False` when the name was unknown. Replacing or removing a resource stops its live subscriptions, and the agent resubscribes as needed. The app-level method still raises `DuplicateNameError` for a duplicate.\n\nAfter the gateway welcomes the session, each registry change sends `resources/list_changed` with `{ \"resources\": [...] }`. Changes before welcome, or with no gateway connected, are silent; the next hello or resume carries the updated manifest. Each change sends one notification, with no coalescing.\n\nThese calls are synchronous and must run on the event loop thread, like the rest of the SDK.\n\n## Pushing updates\n\n```python\nawait todos_resource.publish(await read_todos())\n```\n\n`publish` goes to every agent currently subscribed to that resource, and does nothing when nobody is. Call it from wherever the state actually changes.\n\n## Subscribing with your own source\n\nSome state has a natural event source: a file watcher, a database listener, a queue. Hand `subscribe` a callback that starts it and answers with the cleanup that stops again:\n\n```python\nfrom tesseron import Emit, JsonValue, TesseronApp, Unsubscribe\n\napp = TesseronApp(id=\"python_prompts\", name=\"Python Prompts\")\n\nasync def read_library() -> JsonValue:\n return [prompt_payload(prompt) for prompt in store.library()]\n\ndef follow(emit: Emit) -> Unsubscribe:\n watcher = start_watching(lambda value: emit(value))\n return watcher.stop\n\nlibrary_resource = app.resource(\n \"library\",\n description=\"Live snapshot of every prompt in the library. Pushed on every change.\",\n read=read_library,\n subscribable=True,\n subscribe=follow,\n)\n```\n\n`follow` is the application-specific addition to the canonical prompts app when a separate event source owns the updates.\n\nThe callback is synchronous and runs inside the session's read loop, so start your work and return promptly rather than awaiting in it. The cleanup runs when the agent unsubscribes, and again when the connection drops: a subscriber still holding a listener would emit into a closed socket for as long as the application runs.\n\nPassing `subscribe` implies `subscribable=True`. A resource is also subscribable on `subscribable=True` alone, because `publish` is enough on its own to push updates.\n\n## What the wire does\n\n`resources/subscribe` and `resources/unsubscribe` both acknowledge with `result: null`. The acknowledgement goes out **before** the subscriber runs, so a value the subscriber emits immediately cannot overtake the response the agent is still waiting on.\n\nUnsubscribing an id nobody registered is not an error. The agent and the transport can race, and there is nothing left to tear down either way.\n\nReading a resource that was never declared answers `-32003` with `Resource not readable: <name>`. Subscribing to one that was never declared, or to one that is not subscribable, answers `-32003` with `Resource not subscribable: <name>`. That is the same answer `@tesseron/core` gives.\n\nA reader that raises `ActionError` sends that failure to the agent. A reader that raises anything else answers a bare `-32603`, with the cause kept on your side."},{"slug":"sdk/rust/actions","title":"Actions (Rust)","description":"Typed and raw JSON actions, schema publication, validation, timeouts, and registration rules.","section":"sdk","related":["sdk/rust/index","sdk/rust/context","sdk/rust/errors","protocol/actions"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nAn action is a named handler the agent can invoke. The gateway projects each registration into an MCP tool.\n\n## Typed actions\n\n`Action::typed(name, handler)` takes an input type that implements `DeserializeOwned + JsonSchema` and an output type that implements `Serialize`. The derived Schemars document is JSON Schema 2020-12, and the same input type is deserialized before the handler runs.\n\nThis is the builder shape used by the crate README:\n\n```rust\nuse tesseron::{Action, ActionContext, ActionError, HostEvent, Tesseron};\nuse serde::{Deserialize, Serialize};\nuse schemars::JsonSchema;\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddTodo {\n title: String,\n}\n\n#[derive(Serialize, JsonSchema)]\nstruct Added {\n id: u64,\n}\n\nasync fn add_todo(input: AddTodo, _context: ActionContext) -> Result<Added, ActionError> {\n Ok(Added { id: store_todo(input.title) })\n}\n\n# fn store_todo(_title: String) -> u64 { 1 }\n# async fn example() -> Result<(), Box<dyn std::error::Error>> {\nlet builder = Tesseron::builder()\n .application(\"todo\", \"Todo\")\n .action(Action::typed(\"add_todo\", add_todo).output_schema_from_type::<Added>());\nlet mut events = builder.subscribe();\nlet host = builder.listen().await?;\n\nwhile let Ok(event) = events.recv().await {\n if let HostEvent::Welcome(welcome) = event {\n if let Some(code) = welcome.claim_code {\n println!(\"Claim this session with {code}\");\n }\n break;\n }\n}\nhost.shutdown().await?;\n# Ok(())\n# }\n```\n\nA typed input schema must have an object root. Use a struct for input, including an empty struct for an action with no input. Scalars, enums, and `Vec<T>` derive non-object roots and are refused when `listen()` runs with `HostError::InvalidTypedActionInputSchema`. The error names the action and the Rust input type. A typed action with `.input_schema(Value)` still has to publish an object-root schema.\n\nInput that cannot deserialize is rejected with `ActionError` carrying `TesseronErrorCode::InputValidation`, and the handler does not run.\n\n## Raw JSON actions\n\n`Action::json(name, handler)` passes a `serde_json::Value` to the handler. It starts with a permissive `{}` input schema. Set `.input_schema(Value)` for the manifest and add `.validate_with(..)` when the schema must be enforced:\n\n```rust\nlet mut action = Action::json(\n fixture.name,\n move |_input: Value, context: ActionContext| {\n let script = Arc::clone(&script);\n async move { run_action(&script, context).await }\n },\n)\n.description(fixture.description);\n\nif let Some(schema) = fixture.input_schema {\n schema_subset::assert_enforceable(&schema)\n .map_err(|problem| format!(\"action {:?}: {problem}\", action.name()))?;\n let enforced = schema.clone();\n action = action\n .input_schema(schema)\n .validate_with(move |input: &Value| schema_subset::check(&enforced, input));\n}\n```\n\nThe validator returns `Ok(())` for accepted input or `Err(Vec<ValidationIssue>)` for rejected input. A rejected value becomes `TesseronErrorCode::InputValidation`. With no validator, the declared schema documents the expected value and the raw JSON reaches the handler unchanged.\n\n## Builder options\n\n| Method | What it does |\n| --- | --- |\n| `.description(...)` | Publishes the text the agent reads for the tool. |\n| `.input_schema(Value)` | Replaces the input schema in the manifest. |\n| `.output_schema(Value)` | Publishes an informational output schema. |\n| `.output_schema_from_type::<Output>()` | Derives and publishes the output schema from `Output`. |\n| `.timeout(Duration)` | Sets the per-invocation deadline instead of the 60-second default. |\n| `.validate_with(..)` | Adds a runtime validator for a raw JSON action. |\n\nOutput schema publication is opt-in. Nothing is published unless you call `.output_schema_from_type::<Output>()` or `.output_schema(Value)`. The schema describes the result for the agent; the crate does not validate handler output against it.\n\n## Registering after listen\n\nClone the host handle into a spawned task when an action needs to be added or removed after `listen()`:\n\n```rust\nuse serde_json::json;\nuse tesseron::{Action, TesseronHost};\n\nlet host = builder.listen().await?;\nlet host_clone: TesseronHost = host.clone();\nlet action = Action::json(\"refresh\", |_input, _context| async {\n Ok(json!({ \"ok\": true }))\n});\n\ntokio::spawn(async move {\n host_clone.register_action(action);\n host_clone.remove_action(\"refresh\");\n});\n```\n\n`register_action(&self, action)` upserts by name, replacing the descriptor, validator, and handler while keeping the existing manifest slot. `remove_action(&self, name)` returns `true` when an action was removed and `false` for an unknown name.\n\n`register_action` checks a typed action's input schema the same way `listen()` does: the schema must describe an object, and `properties`, when present, must be an object. `listen()` reports a bad schema as a `HostError`. `register_action` returns nothing, so it panics instead, before the registry changes.\n\nAfter the session is welcomed, each call that changes the registry sends `actions/list_changed` with `{ \"actions\": [full manifest] }`. Before welcome, or without a connected gateway, changes are silent and the next `tesseron/hello` or resume carries the new manifest. Notifications are sent for each change without coalescing.\n\nA duplicate action name on the builder returns `HostError::DuplicateName`. The application id is checked before the listener starts, and `bind_address(SocketAddr)` accepts loopback addresses only. `listen()` returns `HostError::NonLoopbackBindAddress` for anything else.\n\n## Handler failures\n\nReturn `ActionError::handler(message)` for a domain failure that the agent should see. Use `ActionError::protocol(code, message, data)` when a specific Tesseron code and structured data are part of the contract. Use `ActionError::internal(source)` for an unexpected failure; the cause stays local and the agent receives `-32603 Internal error`. See [Errors](/sdk/rust/errors/).\n","bodyText":"<!-- snippets from examples/todo -->\n\nAn action is a named handler the agent can invoke. The gateway projects each registration into an MCP tool.\n\n## Typed actions\n\n`Action::typed(name, handler)` takes an input type that implements `DeserializeOwned + JsonSchema` and an output type that implements `Serialize`. The derived Schemars document is JSON Schema 2020-12, and the same input type is deserialized before the handler runs.\n\nThis is the builder shape used by the crate README:\n\n```rust\nuse tesseron::{Action, ActionContext, ActionError, HostEvent, Tesseron};\nuse serde::{Deserialize, Serialize};\nuse schemars::JsonSchema;\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddTodo {\n title: String,\n}\n\n#[derive(Serialize, JsonSchema)]\nstruct Added {\n id: u64,\n}\n\nasync fn add_todo(input: AddTodo, _context: ActionContext) -> Result<Added, ActionError> {\n Ok(Added { id: store_todo(input.title) })\n}\n\n# fn store_todo(_title: String) -> u64 { 1 }\n# async fn example() -> Result<(), Box<dyn std::error::Error>> {\nlet builder = Tesseron::builder()\n .application(\"todo\", \"Todo\")\n .action(Action::typed(\"add_todo\", add_todo).output_schema_from_type::<Added>());\nlet mut events = builder.subscribe();\nlet host = builder.listen().await?;\n\nwhile let Ok(event) = events.recv().await {\n if let HostEvent::Welcome(welcome) = event {\n if let Some(code) = welcome.claim_code {\n println!(\"Claim this session with {code}\");\n }\n break;\n }\n}\nhost.shutdown().await?;\n# Ok(())\n# }\n```\n\nA typed input schema must have an object root. Use a struct for input, including an empty struct for an action with no input. Scalars, enums, and `Vec<T>` derive non-object roots and are refused when `listen()` runs with `HostError::InvalidTypedActionInputSchema`. The error names the action and the Rust input type. A typed action with `.input_schema(Value)` still has to publish an object-root schema.\n\nInput that cannot deserialize is rejected with `ActionError` carrying `TesseronErrorCode::InputValidation`, and the handler does not run.\n\n## Raw JSON actions\n\n`Action::json(name, handler)` passes a `serde_json::Value` to the handler. It starts with a permissive `{}` input schema. Set `.input_schema(Value)` for the manifest and add `.validate_with(..)` when the schema must be enforced:\n\n```rust\nlet mut action = Action::json(\n fixture.name,\n move |_input: Value, context: ActionContext| {\n let script = Arc::clone(&script);\n async move { run_action(&script, context).await }\n },\n)\n.description(fixture.description);\n\nif let Some(schema) = fixture.input_schema {\n schema_subset::assert_enforceable(&schema)\n .map_err(|problem| format!(\"action {:?}: {problem}\", action.name()))?;\n let enforced = schema.clone();\n action = action\n .input_schema(schema)\n .validate_with(move |input: &Value| schema_subset::check(&enforced, input));\n}\n```\n\nThe validator returns `Ok(())` for accepted input or `Err(Vec<ValidationIssue>)` for rejected input. A rejected value becomes `TesseronErrorCode::InputValidation`. With no validator, the declared schema documents the expected value and the raw JSON reaches the handler unchanged.\n\n## Builder options\n\n| Method | What it does |\n| --- | --- |\n| `.description(...)` | Publishes the text the agent reads for the tool. |\n| `.input_schema(Value)` | Replaces the input schema in the manifest. |\n| `.output_schema(Value)` | Publishes an informational output schema. |\n| `.output_schema_from_type::<Output>()` | Derives and publishes the output schema from `Output`. |\n| `.timeout(Duration)` | Sets the per-invocation deadline instead of the 60-second default. |\n| `.validate_with(..)` | Adds a runtime validator for a raw JSON action. |\n\nOutput schema publication is opt-in. Nothing is published unless you call `.output_schema_from_type::<Output>()` or `.output_schema(Value)`. The schema describes the result for the agent; the crate does not validate handler output against it.\n\n## Registering after listen\n\nClone the host handle into a spawned task when an action needs to be added or removed after `listen()`:\n\n```rust\nuse serde_json::json;\nuse tesseron::{Action, TesseronHost};\n\nlet host = builder.listen().await?;\nlet host_clone: TesseronHost = host.clone();\nlet action = Action::json(\"refresh\", |_input, _context| async {\n Ok(json!({ \"ok\": true }))\n});\n\ntokio::spawn(async move {\n host_clone.register_action(action);\n host_clone.remove_action(\"refresh\");\n});\n```\n\n`register_action(&self, action)` upserts by name, replacing the descriptor, validator, and handler while keeping the existing manifest slot. `remove_action(&self, name)` returns `true` when an action was removed and `false` for an unknown name.\n\n`register_action` checks a typed action's input schema the same way `listen()` does: the schema must describe an object, and `properties`, when present, must be an object. `listen()` reports a bad schema as a `HostError`. `register_action` returns nothing, so it panics instead, before the registry changes.\n\nAfter the session is welcomed, each call that changes the registry sends `actions/list_changed` with `{ \"actions\": [full manifest] }`. Before welcome, or without a connected gateway, changes are silent and the next `tesseron/hello` or resume carries the new manifest. Notifications are sent for each change without coalescing.\n\nA duplicate action name on the builder returns `HostError::DuplicateName`. The application id is checked before the listener starts, and `bind_address(SocketAddr)` accepts loopback addresses only. `listen()` returns `HostError::NonLoopbackBindAddress` for anything else.\n\n## Handler failures\n\nReturn `ActionError::handler(message)` for a domain failure that the agent should see. Use `ActionError::protocol(code, message, data)` when a specific Tesseron code and structured data are part of the contract. Use `ActionError::internal(source)` for an unexpected failure; the cause stays local and the agent receives `-32603 Internal error`. See [Errors](/sdk/rust/errors/)."},{"slug":"sdk/rust/conformance","title":"Conformance (Rust)","description":"Build the private Rust conformance host and run it against the shared protocol corpus.","section":"sdk","related":["sdk/rust/index","sdk/porting","protocol/handshake"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nThe [conformance corpus](https://github.com/eigenwise/tesseron/tree/main/conformance) is the executable half of the protocol spec. It is language-neutral. The runner plays the gateway, and the Rust host adapts each fixture into actions and resources through the public SDK API.\n\n## Run it\n\nFrom the `tesseron-rust` repository root, build the private host, then run the published runner:\n\n```bash\ncargo build --locked -p tesseron-conformance-host\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"./target/debug/tesseron-conformance-host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. Both tags are required on Linux and Windows. The runner uses its bundled corpus and starts a fresh host for every fixture. Use `--fixtures <path>` to test a hub checkout's current corpus.\n\nThe conformance host is private. It lives at `conformance-host/` as a workspace member and is not part of the published `tesseron` crate. It reads `TESSERON_CONFORMANCE_FIXTURE`, registers the fixture's canned actions and resources, and prints one readiness line before the runner connects. Diagnostics go to stderr.\n\n## Expected result\n\nThe Rust host uses gateway-minted claims and WebSocket transport only. With `host-minted-claim,uds` unsupported, expect **29 passed, 10 skipped, 0 failed** on Linux and Windows.\n\n- The nine `bind/*` fixtures skip because they require a host-minted claim. The Rust host waits for the gateway to mint the claim in the welcome.\n- `uds/file-mode` skips because the Rust host currently speaks WebSocket only in the conformance path and Unix domain sockets are unavailable there.\n\nThe runner treats these as skips, not hidden failures. Every capability the Rust host declares, including streaming, subscriptions, sampling, and elicitation, must agree with the `tesseron/hello` fields and is exercised by the fixtures that run.\n\n## Host launch contract\n\nThe runner gives each fixture a fresh temporary directory and starts one host process with `TESSERON_CONFORMANCE_FIXTURE` set to the fixture path. The host must print exactly one line in this form before any other stdout:\n\n`tesseron-conformance-url=ws://127.0.0.1:<port>/`\n\nThe runner connects to that loopback URL, runs the fixture steps, closes the connection, and ends the child before moving to the next fixture. A crash, extra stdout line, timeout, or non-loopback URL is a fixture failure.\n\n## When a port changes\n\nRun the full corpus again after changing a protocol path, action registration, resource subscription, handshake, or context method. A fixture added after the last host run is the usual reason a port goes red. The runner's `--host` path handling resolves the Rust binary to an absolute native path, which keeps this command working on Windows.\n","bodyText":"<!-- snippets from examples/todo -->\n\nThe [conformance corpus](https://github.com/eigenwise/tesseron/tree/main/conformance) is the executable half of the protocol spec. It is language-neutral. The runner plays the gateway, and the Rust host adapts each fixture into actions and resources through the public SDK API.\n\n## Run it\n\nFrom the `tesseron-rust` repository root, build the private host, then run the published runner:\n\n```bash\ncargo build --locked -p tesseron-conformance-host\nTESSERON_CONFORMANCE_UNSUPPORTED=host-minted-claim,uds pnpm dlx @tesseron/conformance@1.2.1 --host \"./target/debug/tesseron-conformance-host\"\n```\n\nIn PowerShell, set `$env:TESSERON_CONFORMANCE_UNSUPPORTED = 'host-minted-claim,uds'` before the `pnpm dlx` command instead of using the Bash environment prefix. Both tags are required on Linux and Windows. The runner uses its bundled corpus and starts a fresh host for every fixture. Use `--fixtures <path>` to test a hub checkout's current corpus.\n\nThe conformance host is private. It lives at `conformance-host/` as a workspace member and is not part of the published `tesseron` crate. It reads `TESSERON_CONFORMANCE_FIXTURE`, registers the fixture's canned actions and resources, and prints one readiness line before the runner connects. Diagnostics go to stderr.\n\n## Expected result\n\nThe Rust host uses gateway-minted claims and WebSocket transport only. With `host-minted-claim,uds` unsupported, expect **29 passed, 10 skipped, 0 failed** on Linux and Windows.\n\n- The nine `bind/*` fixtures skip because they require a host-minted claim. The Rust host waits for the gateway to mint the claim in the welcome.\n- `uds/file-mode` skips because the Rust host currently speaks WebSocket only in the conformance path and Unix domain sockets are unavailable there.\n\nThe runner treats these as skips, not hidden failures. Every capability the Rust host declares, including streaming, subscriptions, sampling, and elicitation, must agree with the `tesseron/hello` fields and is exercised by the fixtures that run.\n\n## Host launch contract\n\nThe runner gives each fixture a fresh temporary directory and starts one host process with `TESSERON_CONFORMANCE_FIXTURE` set to the fixture path. The host must print exactly one line in this form before any other stdout:\n\n`tesseron-conformance-url=ws://127.0.0.1:<port>/`\n\nThe runner connects to that loopback URL, runs the fixture steps, closes the connection, and ends the child before moving to the next fixture. A crash, extra stdout line, timeout, or non-loopback URL is a fixture failure.\n\n## When a port changes\n\nRun the full corpus again after changing a protocol path, action registration, resource subscription, handshake, or context method. A fixture added after the last host run is the usual reason a port goes red. The runner's `--host` path handling resolves the Rust binary to an absolute native path, which keeps this command working on Windows."},{"slug":"sdk/rust/context","title":"Context (Rust)","description":"Progress, cancellation, confirmation, elicitation, sampling, and logs available to every Rust handler.","section":"sdk","related":["sdk/rust/actions","sdk/rust/errors","protocol/progress-cancellation","protocol/sampling","protocol/elicitation"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n<!-- snippets from examples/prompts -->\n\nEvery handler receives an `ActionContext` after the gateway handshake. It identifies the action and invocation, exposes the negotiated `Capabilities`, and carries the connection used for requests and notifications. It is cheap to clone, and clones share the progress ceiling.\n\n## Progress\n\n`ProgressUpdate::new()` builds an update. Add `.message(...)`, `.percent(...)`, and `.data(Value)` as needed, then call `context.progress(update)`.\n\nThe protocol progress value is an integer from `0` through `100`. Pass integer-valued percentages. Values below `0` clamp to `0`, values above `100` clamp to `100`, and a value below the highest value already sent for this invocation is raised to that value. The message and data still go out. Progress is fire-and-forget.\n\nThe todo example sends one update per imported item:\n\n```rust\ncontext.progress(\n ProgressUpdate::new()\n .message(format!(\"{}/{} imported\", index + 1, item_count))\n .percent(((index + 1) * 100 / item_count) as f64),\n);\n```\n\nThe shared ceiling applies to cloned contexts too. A handler can report `55`, then `10`, and the second frame carries `55`.\n\n## Cancellation\n\nThe gateway cancels with an `actions/cancel` notification. The invocation answers with `TesseronErrorCode::Cancelled`, and the running handler should unwind. Check `context.is_cancelled()` between units of work, or await `context.cancellation().cancelled()` beside a long operation. The cancellation future resolves immediately when cancellation was already requested.\n\nA handler that ignores cancellation may keep running after the invocation response has been replaced. The host does not turn a late handler result into another response.\n\n## Sampling\n\n`context.sample(SampleRequest::new(prompt))` asks the agent's model and returns a `Value`. `SampleRequest::for_type::<Output>(prompt)` derives a JSON Schema for structured output, and `context.sample_as::<Output>(request)` decodes the result. Add `.max_tokens(...)` to cap the request.\n\nThe todo example asks for structured suggestions:\n\n```rust\nlet suggested = context\n .sample_as::<SuggestedTodos>(\n SampleRequest::for_type::<SuggestedTodos>(format!(\n \"Produce exactly {count} concrete todo items for the theme \\\"{}\\\". Return JSON matching {{ items: string[] }}. Items should be short, imperative, and user-friendly. No numbering.\",\n input.theme\n ))\n .max_tokens(400),\n )\n .await?;\n```\n\nWhen sampling was not negotiated, the call returns `ActionError` with `TesseronErrorCode::SamplingNotAvailable` and sends nothing. Sampling depth is enforced by the gateway with `TesseronErrorCode::SamplingDepthExceeded`; the host does not count nested requests.\n\n## Confirmation\n\n`context.confirm(question)` asks a yes-or-no question through elicitation and returns `Result<bool, ActionError>`. It returns `true` only for an explicit accept. Decline, cancel, and missing elicitation capability return `false`.\n\nThe prompt example uses it before deleting a prompt:\n\n```rust\nlet confirmed = context\n .confirm(format!(\n \"Delete prompt \\\"{}\\\" (tested {}x)? This cannot be undone.\",\n prompt.name, prompt.times_tested\n ))\n .await?;\n```\n\n## Elicitation\n\n`ElicitRequest::new(question)` uses a permissive single-text-field schema. `ElicitRequest::for_type::<Answer>(question)` derives a form schema from a `JsonSchema` type. Pass either request to `context.elicit(...)`, or use `context.elicit_as::<Answer>(request)` to decode accepted content.\n\nThe todo example derives its answer schema:\n\n```rust\nlet answer = context\n .elicit_as::<RenameTodoAnswer>(ElicitRequest::for_type::<RenameTodoAnswer>(\n format!(\"Rename \\\"{previous_text}\\\" to?\"),\n ))\n .await?;\n```\n\nAn accepted answer is `Some(Value)`, or `Some(Answer)` with `elicit_as`. Decline and cancel return `None`. Missing elicitation capability returns `TesseronErrorCode::ElicitationNotAvailable`. The host validates the JSON Schema before sending; an unsupported schema returns `InvalidParams` (`-32602`) at the call site, and no request reaches the agent. Top-level `oneOf`, `anyOf`, `allOf`, `not`, and object- or array-typed properties are refused.\n\n## Logs\n\n`context.log(LogEntry::info(message))` forwards a fire-and-forget log entry. Use `LogEntry::debug`, `LogEntry::warn`, or `LogEntry::error` for the other levels, and `.meta(...)` for structured metadata.\n\nThe session tests exercise the ordinary info level:\n\n```rust\ncontext.log(LogEntry::info(\"halfway\"));\n```\n\n## Capability checks and dropped transports\n\n`context.agent_capabilities()` is the negotiated capability set. Check it when a handler has a useful fallback. `context.agent()` identifies the caller, while `context.action_name()`, `context.invocation_id()`, `context.origin()`, and `context.route()` identify the running invocation.\n\nProgress and logs after a transport drop are discarded. Request methods such as `sample`, `confirm`, and `elicit` return an `ActionError` carrying `TesseronErrorCode::TransportClosed`, including when a cloned context is used after the connection has gone away. They fail instead of hanging.\n","bodyText":"<!-- snippets from examples/todo -->\n<!-- snippets from examples/prompts -->\n\nEvery handler receives an `ActionContext` after the gateway handshake. It identifies the action and invocation, exposes the negotiated `Capabilities`, and carries the connection used for requests and notifications. It is cheap to clone, and clones share the progress ceiling.\n\n## Progress\n\n`ProgressUpdate::new()` builds an update. Add `.message(...)`, `.percent(...)`, and `.data(Value)` as needed, then call `context.progress(update)`.\n\nThe protocol progress value is an integer from `0` through `100`. Pass integer-valued percentages. Values below `0` clamp to `0`, values above `100` clamp to `100`, and a value below the highest value already sent for this invocation is raised to that value. The message and data still go out. Progress is fire-and-forget.\n\nThe todo example sends one update per imported item:\n\n```rust\ncontext.progress(\n ProgressUpdate::new()\n .message(format!(\"{}/{} imported\", index + 1, item_count))\n .percent(((index + 1) * 100 / item_count) as f64),\n);\n```\n\nThe shared ceiling applies to cloned contexts too. A handler can report `55`, then `10`, and the second frame carries `55`.\n\n## Cancellation\n\nThe gateway cancels with an `actions/cancel` notification. The invocation answers with `TesseronErrorCode::Cancelled`, and the running handler should unwind. Check `context.is_cancelled()` between units of work, or await `context.cancellation().cancelled()` beside a long operation. The cancellation future resolves immediately when cancellation was already requested.\n\nA handler that ignores cancellation may keep running after the invocation response has been replaced. The host does not turn a late handler result into another response.\n\n## Sampling\n\n`context.sample(SampleRequest::new(prompt))` asks the agent's model and returns a `Value`. `SampleRequest::for_type::<Output>(prompt)` derives a JSON Schema for structured output, and `context.sample_as::<Output>(request)` decodes the result. Add `.max_tokens(...)` to cap the request.\n\nThe todo example asks for structured suggestions:\n\n```rust\nlet suggested = context\n .sample_as::<SuggestedTodos>(\n SampleRequest::for_type::<SuggestedTodos>(format!(\n \"Produce exactly {count} concrete todo items for the theme \\\"{}\\\". Return JSON matching {{ items: string[] }}. Items should be short, imperative, and user-friendly. No numbering.\",\n input.theme\n ))\n .max_tokens(400),\n )\n .await?;\n```\n\nWhen sampling was not negotiated, the call returns `ActionError` with `TesseronErrorCode::SamplingNotAvailable` and sends nothing. Sampling depth is enforced by the gateway with `TesseronErrorCode::SamplingDepthExceeded`; the host does not count nested requests.\n\n## Confirmation\n\n`context.confirm(question)` asks a yes-or-no question through elicitation and returns `Result<bool, ActionError>`. It returns `true` only for an explicit accept. Decline, cancel, and missing elicitation capability return `false`.\n\nThe prompt example uses it before deleting a prompt:\n\n```rust\nlet confirmed = context\n .confirm(format!(\n \"Delete prompt \\\"{}\\\" (tested {}x)? This cannot be undone.\",\n prompt.name, prompt.times_tested\n ))\n .await?;\n```\n\n## Elicitation\n\n`ElicitRequest::new(question)` uses a permissive single-text-field schema. `ElicitRequest::for_type::<Answer>(question)` derives a form schema from a `JsonSchema` type. Pass either request to `context.elicit(...)`, or use `context.elicit_as::<Answer>(request)` to decode accepted content.\n\nThe todo example derives its answer schema:\n\n```rust\nlet answer = context\n .elicit_as::<RenameTodoAnswer>(ElicitRequest::for_type::<RenameTodoAnswer>(\n format!(\"Rename \\\"{previous_text}\\\" to?\"),\n ))\n .await?;\n```\n\nAn accepted answer is `Some(Value)`, or `Some(Answer)` with `elicit_as`. Decline and cancel return `None`. Missing elicitation capability returns `TesseronErrorCode::ElicitationNotAvailable`. The host validates the JSON Schema before sending; an unsupported schema returns `InvalidParams` (`-32602`) at the call site, and no request reaches the agent. Top-level `oneOf`, `anyOf`, `allOf`, `not`, and object- or array-typed properties are refused.\n\n## Logs\n\n`context.log(LogEntry::info(message))` forwards a fire-and-forget log entry. Use `LogEntry::debug`, `LogEntry::warn`, or `LogEntry::error` for the other levels, and `.meta(...)` for structured metadata.\n\nThe session tests exercise the ordinary info level:\n\n```rust\ncontext.log(LogEntry::info(\"halfway\"));\n```\n\n## Capability checks and dropped transports\n\n`context.agent_capabilities()` is the negotiated capability set. Check it when a handler has a useful fallback. `context.agent()` identifies the caller, while `context.action_name()`, `context.invocation_id()`, `context.origin()`, and `context.route()` identify the running invocation.\n\nProgress and logs after a transport drop are discarded. Request methods such as `sample`, `confirm`, and `elicit` return an `ActionError` carrying `TesseronErrorCode::TransportClosed`, including when a cloned context is used after the connection has gone away. They fail instead of hanging."},{"slug":"sdk/rust/errors","title":"Errors (Rust)","description":"Host startup errors, handler failures, protocol envelopes, and the complete 17-code catalog.","section":"sdk","related":["sdk/rust/actions","sdk/rust/context","sdk/rust/index","protocol/errors"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nThe Rust SDK keeps three error types separate. `HostError` means the application could not start or shut down. `ActionError` is what a handler returns when an invocation fails. `ProtocolError` is the JSON-RPC error object that crosses the connection.\n\n## The code catalog\n\n`TesseronErrorCode` is the closed set of protocol codes. `as_wire_code()` returns the JSON-RPC integer, and `from_wire_code(...)` returns `None` for an integer this SDK does not define.\n\n| Code | Variant | When |\n| --- | --- | --- |\n| `-32700` | `ParseError` | The peer sent bytes that are not valid JSON. |\n| `-32600` | `InvalidRequest` | The envelope is not a valid JSON-RPC 2.0 message. |\n| `-32601` | `MethodNotFound` | The requested method is not part of the Tesseron protocol. |\n| `-32602` | `InvalidParams` | Method parameters do not match the documented shape, including an elicit schema MCP cannot render. |\n| `-32603` | `InternalError` | An unexpected failure occurred. Detail stays local. |\n| `-32000` | `ProtocolMismatch` | The host and gateway disagree on the protocol major version. |\n| `-32001` | `Cancelled` | The agent cancelled the invocation. |\n| `-32002` | `Timeout` | The invocation passed its action timeout. |\n| `-32003` | `ActionNotFound` | No action is registered under the requested name, or a resource is not readable or subscribable. |\n| `-32004` | `InputValidation` | The invocation input failed the action's declared schema. |\n| `-32005` | `HandlerError` | The handler reported a domain failure. |\n| `-32006` | `SamplingNotAvailable` | The agent did not negotiate sampling. |\n| `-32007` | `ElicitationNotAvailable` | The agent did not negotiate elicitation. |\n| `-32008` | `SamplingDepthExceeded` | The gateway's sampling-depth limit was exceeded. |\n| `-32009` | `Unauthorized` | The session is unclaimed or the operation is not permitted. |\n| `-32010` | `TransportClosed` | The transport closed while a request was in flight. |\n| `-32011` | `ResumeFailed` | The gateway refused the resume credentials. |\n\n## ActionError\n\nHandlers return `Result<Output, ActionError>`. Use `ActionError::handler(message)` for a domain failure that should reach the agent as `HandlerError`. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and optional structured `Value`. `ActionError::with_data(data)` adds detail to an existing error.\n\n`ActionError::internal(source)` keeps the source error in `internal_source()` and sends only `-32603 Internal error`. An unexpected error from a handler follows the same redacted path. This keeps panic messages, database URLs, and other local details off the wire.\n\n## ProtocolError\n\n`ProtocolError` represents the JSON-RPC `error` member with public `code: i32`, `message: String`, and optional `data: Value`. It keeps the raw integer so a newer peer's unknown code can round-trip. Call `named_code()` when you want `Option<TesseronErrorCode>`.\n\n`ProtocolError::new(code, message)` builds a known-code payload, and `.with_data(data)` attaches structured detail. The SDK turns gateway responses into `ActionError` when a handler's `sample`, `confirm`, or `elicit` request fails.\n\n## HostError\n\nThese errors occur before an invocation reaches a handler:\n\n| Variant | When |\n| --- | --- |\n| `MissingApplication` | No application was registered before `listen()`. |\n| `InvalidApplicationId(String)` | The id fails `^[a-z][a-z0-9_]*$` or is reserved. |\n| `InvalidTypedActionInputSchema { action_name, input_type_name }` | A typed action's derived or overridden input schema is not an object root. The error names both the action and Rust input type. |\n| `DuplicateName(String)` | Two actions or two resources use the same name. |\n| `NonLoopbackBindAddress(SocketAddr)` | `bind_address(...)` was given a non-loopback address. |\n| `Listen(io::Error)` | The loopback listener could not bind. |\n| `Manifest(io::Error)` | The instance manifest could not be written or removed. |\n| `HomeDirectoryUnknown` | The home directory for `~/.tesseron` could not be resolved. |\n\n`listen()` refuses a non-loopback address before binding. `shutdown().await` reports a manifest removal failure through `HostError::Manifest`.\n","bodyText":"<!-- snippets from examples/todo -->\n\nThe Rust SDK keeps three error types separate. `HostError` means the application could not start or shut down. `ActionError` is what a handler returns when an invocation fails. `ProtocolError` is the JSON-RPC error object that crosses the connection.\n\n## The code catalog\n\n`TesseronErrorCode` is the closed set of protocol codes. `as_wire_code()` returns the JSON-RPC integer, and `from_wire_code(...)` returns `None` for an integer this SDK does not define.\n\n| Code | Variant | When |\n| --- | --- | --- |\n| `-32700` | `ParseError` | The peer sent bytes that are not valid JSON. |\n| `-32600` | `InvalidRequest` | The envelope is not a valid JSON-RPC 2.0 message. |\n| `-32601` | `MethodNotFound` | The requested method is not part of the Tesseron protocol. |\n| `-32602` | `InvalidParams` | Method parameters do not match the documented shape, including an elicit schema MCP cannot render. |\n| `-32603` | `InternalError` | An unexpected failure occurred. Detail stays local. |\n| `-32000` | `ProtocolMismatch` | The host and gateway disagree on the protocol major version. |\n| `-32001` | `Cancelled` | The agent cancelled the invocation. |\n| `-32002` | `Timeout` | The invocation passed its action timeout. |\n| `-32003` | `ActionNotFound` | No action is registered under the requested name, or a resource is not readable or subscribable. |\n| `-32004` | `InputValidation` | The invocation input failed the action's declared schema. |\n| `-32005` | `HandlerError` | The handler reported a domain failure. |\n| `-32006` | `SamplingNotAvailable` | The agent did not negotiate sampling. |\n| `-32007` | `ElicitationNotAvailable` | The agent did not negotiate elicitation. |\n| `-32008` | `SamplingDepthExceeded` | The gateway's sampling-depth limit was exceeded. |\n| `-32009` | `Unauthorized` | The session is unclaimed or the operation is not permitted. |\n| `-32010` | `TransportClosed` | The transport closed while a request was in flight. |\n| `-32011` | `ResumeFailed` | The gateway refused the resume credentials. |\n\n## ActionError\n\nHandlers return `Result<Output, ActionError>`. Use `ActionError::handler(message)` for a domain failure that should reach the agent as `HandlerError`. Use `ActionError::protocol(code, message, data)` when the agent needs a specific code and optional structured `Value`. `ActionError::with_data(data)` adds detail to an existing error.\n\n`ActionError::internal(source)` keeps the source error in `internal_source()` and sends only `-32603 Internal error`. An unexpected error from a handler follows the same redacted path. This keeps panic messages, database URLs, and other local details off the wire.\n\n## ProtocolError\n\n`ProtocolError` represents the JSON-RPC `error` member with public `code: i32`, `message: String`, and optional `data: Value`. It keeps the raw integer so a newer peer's unknown code can round-trip. Call `named_code()` when you want `Option<TesseronErrorCode>`.\n\n`ProtocolError::new(code, message)` builds a known-code payload, and `.with_data(data)` attaches structured detail. The SDK turns gateway responses into `ActionError` when a handler's `sample`, `confirm`, or `elicit` request fails.\n\n## HostError\n\nThese errors occur before an invocation reaches a handler:\n\n| Variant | When |\n| --- | --- |\n| `MissingApplication` | No application was registered before `listen()`. |\n| `InvalidApplicationId(String)` | The id fails `^[a-z][a-z0-9_]*$` or is reserved. |\n| `InvalidTypedActionInputSchema { action_name, input_type_name }` | A typed action's derived or overridden input schema is not an object root. The error names both the action and Rust input type. |\n| `DuplicateName(String)` | Two actions or two resources use the same name. |\n| `NonLoopbackBindAddress(SocketAddr)` | `bind_address(...)` was given a non-loopback address. |\n| `Listen(io::Error)` | The loopback listener could not bind. |\n| `Manifest(io::Error)` | The instance manifest could not be written or removed. |\n| `HomeDirectoryUnknown` | The home directory for `~/.tesseron` could not be resolved. |\n\n`listen()` refuses a non-loopback address before binding. `shutdown().await` reports a manifest removal failure through `HostError::Manifest`."},{"slug":"sdk/rust/index","title":"Rust SDK","description":"The Rust implementation of the Tesseron host protocol, with typed actions, resources, and the full context API.","section":"sdk","related":["sdk/index","sdk/rust/actions","sdk/rust/conformance","sdk/porting","protocol/compatibility"],"bodyRaw":"\nSource: [github.com/Eigenwise/tesseron-rust](https://github.com/Eigenwise/tesseron-rust)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-rust)\n\n<!-- snippets from examples/todo -->\n\n`tesseron` is the Rust host SDK. Your application binds a loopback WebSocket, writes an instance manifest, and the MCP gateway dials in. The agent gets typed actions and readable resources from the process that owns the state.\n\nIt speaks protocol [**1.2.0**](/protocol/), the same version as the TypeScript and Python SDKs. Compatibility follows protocol version, never package numbers. See the [compatibility contract](/protocol/compatibility/).\n\nThe crate is published on [crates.io](https://crates.io/crates/tesseron). Install it with `cargo add tesseron`. Source and examples live in the `tesseron-rust` repository.\n\n## Requirements\n\nRust 1.85 or newer, edition 2024. The crate uses Tokio, `tokio-tungstenite`, Serde, Serde JSON, and Schemars. `Action::typed` inputs derive `Deserialize` and `JsonSchema`; serializable outputs can opt into a published schema.\n\n## A first host\n\nThis is the small host from the crate README. The `#` lines are doctest helpers kept by the source crate.\n\n```rust\nuse tesseron::{Action, ActionContext, ActionError, HostEvent, Tesseron};\nuse serde::{Deserialize, Serialize};\nuse schemars::JsonSchema;\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddTodo {\n title: String,\n}\n\n#[derive(Serialize, JsonSchema)]\nstruct Added {\n id: u64,\n}\n\nasync fn add_todo(input: AddTodo, _context: ActionContext) -> Result<Added, ActionError> {\n Ok(Added { id: store_todo(input.title) })\n}\n\n# fn store_todo(_title: String) -> u64 { 1 }\n# async fn example() -> Result<(), Box<dyn std::error::Error>> {\nlet builder = Tesseron::builder()\n .application(\"todo\", \"Todo\")\n .action(Action::typed(\"add_todo\", add_todo).output_schema_from_type::<Added>());\nlet mut events = builder.subscribe();\nlet host = builder.listen().await?;\n\nwhile let Ok(event) = events.recv().await {\n if let HostEvent::Welcome(welcome) = event {\n if let Some(code) = welcome.claim_code {\n println!(\"Claim this session with {code}\");\n }\n break;\n }\n}\nhost.shutdown().await?;\n# Ok(())\n# }\n```\n\nSubscribe before `listen()`. The gateway can finish the handshake before `listen()` returns, and `Welcome` carries the claim code for a fresh session. `host.url()` returns the loopback WebSocket URL when you need to inspect it or connect a test gateway.\n\nRun the complete headless todo app from the `tesseron-rust` repository root with `cargo run --manifest-path examples/todo/Cargo.toml`. It prints a claim code after the gateway connects. With the Tesseron plugin loaded in Claude Code, tell Claude Code to claim that code, then call the actions.\n\n## What it covers\n\nHandshake and claiming, session resume with in-memory token rotation, typed and raw JSON actions, input validation, cancellation, per-action timeouts, streaming progress, sampling, confirmation, schema-checked elicitation, structured logs, and resources with reads, subscriptions, and pushes are included.\n\nClaims are gateway-minted and transport is WebSocket only in this release. Host-minted bind claims and Unix domain sockets are outside the crate's shipped surface, so the [conformance suite](/sdk/rust/conformance/) skips those fixtures.\n\n## Manifest and shutdown\n\n`listen()` binds `127.0.0.1` on an OS-selected port and writes a v2 instance manifest into `~/.tesseron/instances/` once the URL is known. The directory is `0700`, the file is `0600`, and `shutdown().await` removes the manifest. Modes are advisory on Windows, where the user account is the access boundary.\n\n`host.welcome()` returns the most recent `WelcomeResult`, with `claim_code` cleared after the agent claims the session. `host.subscribe()` can observe later events. To catch the first welcome, use `builder.subscribe()` before `listen()` as shown above.\n\n## Next\n\n- [Actions](/sdk/rust/actions/): typed and raw handlers, schemas, timeouts, and runtime registration.\n- [Resources](/sdk/rust/resources/): reads, subscriptions, emitters, cleanup, and runtime registration.\n- [Context](/sdk/rust/context/): progress, confirmation, elicitation, sampling, logs, and cancellation.\n- [Errors](/sdk/rust/errors/): `HostError`, `ActionError`, `ProtocolError`, and the 17 codes.\n- [Conformance](/sdk/rust/conformance/): build the private host and run the shared corpus.\n- [Tauri](/sdk/rust/tauri/): keep a host in `tauri::State` and update the window after agent mutations.\n","bodyText":"Source: [github.com/Eigenwise/tesseron-rust](https://github.com/Eigenwise/tesseron-rust)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-rust)\n\n<!-- snippets from examples/todo -->\n\n`tesseron` is the Rust host SDK. Your application binds a loopback WebSocket, writes an instance manifest, and the MCP gateway dials in. The agent gets typed actions and readable resources from the process that owns the state.\n\nIt speaks protocol [**1.2.0**](/protocol/), the same version as the TypeScript and Python SDKs. Compatibility follows protocol version, never package numbers. See the [compatibility contract](/protocol/compatibility/).\n\nThe crate is published on [crates.io](https://crates.io/crates/tesseron). Install it with `cargo add tesseron`. Source and examples live in the `tesseron-rust` repository.\n\n## Requirements\n\nRust 1.85 or newer, edition 2024. The crate uses Tokio, `tokio-tungstenite`, Serde, Serde JSON, and Schemars. `Action::typed` inputs derive `Deserialize` and `JsonSchema`; serializable outputs can opt into a published schema.\n\n## A first host\n\nThis is the small host from the crate README. The `#` lines are doctest helpers kept by the source crate.\n\n```rust\nuse tesseron::{Action, ActionContext, ActionError, HostEvent, Tesseron};\nuse serde::{Deserialize, Serialize};\nuse schemars::JsonSchema;\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddTodo {\n title: String,\n}\n\n#[derive(Serialize, JsonSchema)]\nstruct Added {\n id: u64,\n}\n\nasync fn add_todo(input: AddTodo, _context: ActionContext) -> Result<Added, ActionError> {\n Ok(Added { id: store_todo(input.title) })\n}\n\n# fn store_todo(_title: String) -> u64 { 1 }\n# async fn example() -> Result<(), Box<dyn std::error::Error>> {\nlet builder = Tesseron::builder()\n .application(\"todo\", \"Todo\")\n .action(Action::typed(\"add_todo\", add_todo).output_schema_from_type::<Added>());\nlet mut events = builder.subscribe();\nlet host = builder.listen().await?;\n\nwhile let Ok(event) = events.recv().await {\n if let HostEvent::Welcome(welcome) = event {\n if let Some(code) = welcome.claim_code {\n println!(\"Claim this session with {code}\");\n }\n break;\n }\n}\nhost.shutdown().await?;\n# Ok(())\n# }\n```\n\nSubscribe before `listen()`. The gateway can finish the handshake before `listen()` returns, and `Welcome` carries the claim code for a fresh session. `host.url()` returns the loopback WebSocket URL when you need to inspect it or connect a test gateway.\n\nRun the complete headless todo app from the `tesseron-rust` repository root with `cargo run --manifest-path examples/todo/Cargo.toml`. It prints a claim code after the gateway connects. With the Tesseron plugin loaded in Claude Code, tell Claude Code to claim that code, then call the actions.\n\n## What it covers\n\nHandshake and claiming, session resume with in-memory token rotation, typed and raw JSON actions, input validation, cancellation, per-action timeouts, streaming progress, sampling, confirmation, schema-checked elicitation, structured logs, and resources with reads, subscriptions, and pushes are included.\n\nClaims are gateway-minted and transport is WebSocket only in this release. Host-minted bind claims and Unix domain sockets are outside the crate's shipped surface, so the [conformance suite](/sdk/rust/conformance/) skips those fixtures.\n\n## Manifest and shutdown\n\n`listen()` binds `127.0.0.1` on an OS-selected port and writes a v2 instance manifest into `~/.tesseron/instances/` once the URL is known. The directory is `0700`, the file is `0600`, and `shutdown().await` removes the manifest. Modes are advisory on Windows, where the user account is the access boundary.\n\n`host.welcome()` returns the most recent `WelcomeResult`, with `claim_code` cleared after the agent claims the session. `host.subscribe()` can observe later events. To catch the first welcome, use `builder.subscribe()` before `listen()` as shown above.\n\n## Next\n\n- [Actions](/sdk/rust/actions/): typed and raw handlers, schemas, timeouts, and runtime registration.\n- [Resources](/sdk/rust/resources/): reads, subscriptions, emitters, cleanup, and runtime registration.\n- [Context](/sdk/rust/context/): progress, confirmation, elicitation, sampling, logs, and cancellation.\n- [Errors](/sdk/rust/errors/): `HostError`, `ActionError`, `ProtocolError`, and the 17 codes.\n- [Conformance](/sdk/rust/conformance/): build the private host and run the shared corpus.\n- [Tauri](/sdk/rust/tauri/): keep a host in `tauri::State` and update the window after agent mutations."},{"slug":"sdk/rust/resources","title":"Resources (Rust)","description":"Readable and subscribable application state with ResourceEmitter pushes and cleanup.","section":"sdk","related":["sdk/rust/index","sdk/rust/actions","protocol/resources"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nA resource is named application state the agent can read, and optionally follow. Actions change things; resources report the current value.\n\n## Registering a resource\n\n`Resource::new(name, read)` takes a synchronous callback that returns a future resolving to `Result<Value, ActionError>`. The callback runs on every `resources/read`, so it reads current state rather than a snapshot captured during registration.\n\nThe todo example registers a readable and subscribable `todos://all` resource like this:\n\n```rust\nfn todo_resource(todos: TodoList) -> Resource {\n let resource_todos = todos.clone();\n\n Resource::new(\"todos://all\", move || {\n let todos = resource_todos.clone();\n async move {\n let todos = todos.snapshot()?;\n serde_json::to_value(todos).map_err(ActionError::internal)\n }\n })\n .description(\"The complete todo list. Pushed on every mutation.\")\n .subscribe(move |emitter| {\n let mut updates = todos.subscribe();\n let task = tokio::spawn(async move {\n while let Ok(todos) = updates.recv().await {\n if let Ok(value) = serde_json::to_value(todos) {\n emitter.emit(value);\n }\n }\n });\n Subscription::new(move || task.abort())\n })\n}\n```\n\n`.description(...)` publishes the text the agent sees. Calling `.subscribe(..)` marks the resource as subscribable and gives the callback one `ResourceEmitter` for that subscription.\n\n## Pushing updates\n\n`ResourceEmitter::emit(Value)` sends a `resources/updated` notification to the agent subscribed through that emitter. It is fire-and-forget. Emitting after the transport closes or after unsubscribe is dropped. Cloning an emitter keeps the same subscription id, which lets a spawned task keep pushing until its `Subscription` cleanup runs.\n\n`Subscription::new(stop)` stores a `FnOnce` cleanup. The SDK runs it when the agent unsubscribes and when the transport closes. Use `Subscription::without_cleanup()` when the callback started nothing that needs teardown.\n\nThe subscribe callback is synchronous. Start the event source and return the subscription promptly. A spawned task, as in the example, can wait for updates without blocking the session loop.\n\n## Wire behavior\n\n`resources/subscribe` and `resources/unsubscribe` acknowledge with `result: null`. The acknowledgement is sent before the subscriber starts, so an immediate push cannot overtake it. Unsubscribing an unknown id is harmless.\n\nReading an undeclared resource returns `TesseronErrorCode::ActionNotFound` with `Resource not readable: <name>`. Subscribing to an undeclared or non-subscribable resource returns the same code with `Resource not subscribable: <name>`.\n\nA reader can return `ActionError` for a domain failure. An unexpected reader error is reported as `-32603 Internal error`.\n\n## Registering after listen\n\nClone the host handle into a spawned task when a resource needs to be added or removed after `listen()`:\n\n```rust\nuse serde_json::json;\nuse tesseron::{Resource, TesseronHost};\n\nlet host = builder.listen().await?;\nlet host_clone: TesseronHost = host.clone();\nlet resource = Resource::new(\"todos://all\", || async { Ok(json!([])) });\n\ntokio::spawn(async move {\n host_clone.register_resource(resource);\n host_clone.remove_resource(\"todos://all\");\n});\n```\n\n`register_resource(&self, resource)` upserts by name, replacing the descriptor, reader, and subscription handler while keeping the existing manifest slot. Replacing a resource stops its live subscriptions. `remove_resource(&self, name)` returns `true` when a resource was removed and `false` for an unknown name; removing a resource also stops its subscriptions.\n\nAfter the session is welcomed, each call that changes the registry sends `resources/list_changed` with `{ \"resources\": [full manifest] }`. Before welcome, or without a connected gateway, changes are silent and the next `tesseron/hello` or resume carries the new manifest. Notifications are sent for each change without coalescing.\n","bodyText":"<!-- snippets from examples/todo -->\n\nA resource is named application state the agent can read, and optionally follow. Actions change things; resources report the current value.\n\n## Registering a resource\n\n`Resource::new(name, read)` takes a synchronous callback that returns a future resolving to `Result<Value, ActionError>`. The callback runs on every `resources/read`, so it reads current state rather than a snapshot captured during registration.\n\nThe todo example registers a readable and subscribable `todos://all` resource like this:\n\n```rust\nfn todo_resource(todos: TodoList) -> Resource {\n let resource_todos = todos.clone();\n\n Resource::new(\"todos://all\", move || {\n let todos = resource_todos.clone();\n async move {\n let todos = todos.snapshot()?;\n serde_json::to_value(todos).map_err(ActionError::internal)\n }\n })\n .description(\"The complete todo list. Pushed on every mutation.\")\n .subscribe(move |emitter| {\n let mut updates = todos.subscribe();\n let task = tokio::spawn(async move {\n while let Ok(todos) = updates.recv().await {\n if let Ok(value) = serde_json::to_value(todos) {\n emitter.emit(value);\n }\n }\n });\n Subscription::new(move || task.abort())\n })\n}\n```\n\n`.description(...)` publishes the text the agent sees. Calling `.subscribe(..)` marks the resource as subscribable and gives the callback one `ResourceEmitter` for that subscription.\n\n## Pushing updates\n\n`ResourceEmitter::emit(Value)` sends a `resources/updated` notification to the agent subscribed through that emitter. It is fire-and-forget. Emitting after the transport closes or after unsubscribe is dropped. Cloning an emitter keeps the same subscription id, which lets a spawned task keep pushing until its `Subscription` cleanup runs.\n\n`Subscription::new(stop)` stores a `FnOnce` cleanup. The SDK runs it when the agent unsubscribes and when the transport closes. Use `Subscription::without_cleanup()` when the callback started nothing that needs teardown.\n\nThe subscribe callback is synchronous. Start the event source and return the subscription promptly. A spawned task, as in the example, can wait for updates without blocking the session loop.\n\n## Wire behavior\n\n`resources/subscribe` and `resources/unsubscribe` acknowledge with `result: null`. The acknowledgement is sent before the subscriber starts, so an immediate push cannot overtake it. Unsubscribing an unknown id is harmless.\n\nReading an undeclared resource returns `TesseronErrorCode::ActionNotFound` with `Resource not readable: <name>`. Subscribing to an undeclared or non-subscribable resource returns the same code with `Resource not subscribable: <name>`.\n\nA reader can return `ActionError` for a domain failure. An unexpected reader error is reported as `-32603 Internal error`.\n\n## Registering after listen\n\nClone the host handle into a spawned task when a resource needs to be added or removed after `listen()`:\n\n```rust\nuse serde_json::json;\nuse tesseron::{Resource, TesseronHost};\n\nlet host = builder.listen().await?;\nlet host_clone: TesseronHost = host.clone();\nlet resource = Resource::new(\"todos://all\", || async { Ok(json!([])) });\n\ntokio::spawn(async move {\n host_clone.register_resource(resource);\n host_clone.remove_resource(\"todos://all\");\n});\n```\n\n`register_resource(&self, resource)` upserts by name, replacing the descriptor, reader, and subscription handler while keeping the existing manifest slot. Replacing a resource stops its live subscriptions. `remove_resource(&self, name)` returns `true` when a resource was removed and `false` for an unknown name; removing a resource also stops its subscriptions.\n\nAfter the session is welcomed, each call that changes the registry sends `resources/list_changed` with `{ \"resources\": [full manifest] }`. Before welcome, or without a connected gateway, changes are silent and the next `tesseron/hello` or resume carries the new manifest. Notifications are sent for each change without coalescing."},{"slug":"sdk/rust/tauri","title":"Tauri (Rust)","description":"Put a Rust host in tauri::State and refresh the window when an agent mutates shared todo state.","section":"sdk","related":["sdk/rust/index","sdk/rust/resources","sdk/rust/context","examples/vanilla-todo"],"bodyRaw":"\n<!-- snippets from examples/todo -->\n\nThe `tauri-todo` example uses the same Rust host as a headless app. `setup()` creates the application from `examples/todo`, stores the host in Tauri state, and forwards updates to the window.\n\n## The setup pattern\n\nThis is the setup closure from `examples/tauri-todo/src/main.rs`:\n\n```rust\nfn main() {\n let application = tauri::Builder::default()\n .setup(|application| {\n let (builder, todos) = todo_application(\"rust_tauri_todo\", \"Rust Tauri Todo\");\n let events = builder.subscribe();\n let host = tauri::async_runtime::block_on(builder.listen())?;\n\n forward_todo_updates(application.handle().clone(), &todos);\n forward_connection_updates(application.handle().clone(), events);\n application.manage(todos);\n application.manage(TesseronState::new(Arc::new(host)));\n Ok(())\n })\n .invoke_handler(tauri::generate_handler![\n list_todos,\n add_todo,\n toggle_todo,\n delete_todo,\n connection_status\n ])\n .build(tauri::generate_context!())\n .expect(\"error while building Tesseron Todo\");\n```\n\nThe full example keeps `Arc<TesseronHost>` inside `TesseronState`, wrapped in a `Mutex<Option<...>>`. Tauri commands read and mutate the shared `TodoList`. On `RunEvent::Exit`, the app takes the host from state and calls `host.shutdown()` so the accept loop stops and the manifest is removed.\n\n`todo_application(...)` comes from `examples/todo/src/lib.rs`. It owns the action registrations, typed input and output shapes, and the `todos://all` resource. The headless and Tauri binaries import that same function, so their agent surface stays aligned.\n\n## Refreshing the window\n\nThe shared list publishes a new snapshot after every mutation. The Tauri side listens to that channel and emits `todos-updated`:\n\n```rust\nfn forward_todo_updates(application_handle: AppHandle, todos: &TodoList) {\n let mut updates = todos.subscribe();\n tauri::async_runtime::spawn(async move {\n while let Ok(todos) = updates.recv().await {\n if application_handle.emit(TODO_UPDATED_EVENT, todos).is_err() {\n break;\n }\n }\n });\n}\n```\n\nThe frontend listens for the `todos-updated` event and replaces its list. Agent mutations therefore update the open window without a refresh. Connection events follow the same pattern with `connection-updated`; `HostEvent::Welcome` exposes the claim code, `HostEvent::Claimed` identifies the agent, and `HostEvent::Disconnected` reports a dropped gateway connection.\n\n## Run the example\n\nThe exact Windows sequence is in the crate README: install the Tauri CLI, check the example, change into its directory, and run `cargo tauri dev`. The window shows the claim code from the gateway. Claim it in Claude Code, then call `rust_tauri_todo__addTodo`; the new item appears in the list.\n\nTauri is checked separately on Windows in CI. The main Rust workspace checks exclude `tauri-todo` on Linux because its GTK and WebKit development stack adds desktop dependencies without adding protocol coverage.\n","bodyText":"<!-- snippets from examples/todo -->\n\nThe `tauri-todo` example uses the same Rust host as a headless app. `setup()` creates the application from `examples/todo`, stores the host in Tauri state, and forwards updates to the window.\n\n## The setup pattern\n\nThis is the setup closure from `examples/tauri-todo/src/main.rs`:\n\n```rust\nfn main() {\n let application = tauri::Builder::default()\n .setup(|application| {\n let (builder, todos) = todo_application(\"rust_tauri_todo\", \"Rust Tauri Todo\");\n let events = builder.subscribe();\n let host = tauri::async_runtime::block_on(builder.listen())?;\n\n forward_todo_updates(application.handle().clone(), &todos);\n forward_connection_updates(application.handle().clone(), events);\n application.manage(todos);\n application.manage(TesseronState::new(Arc::new(host)));\n Ok(())\n })\n .invoke_handler(tauri::generate_handler![\n list_todos,\n add_todo,\n toggle_todo,\n delete_todo,\n connection_status\n ])\n .build(tauri::generate_context!())\n .expect(\"error while building Tesseron Todo\");\n```\n\nThe full example keeps `Arc<TesseronHost>` inside `TesseronState`, wrapped in a `Mutex<Option<...>>`. Tauri commands read and mutate the shared `TodoList`. On `RunEvent::Exit`, the app takes the host from state and calls `host.shutdown()` so the accept loop stops and the manifest is removed.\n\n`todo_application(...)` comes from `examples/todo/src/lib.rs`. It owns the action registrations, typed input and output shapes, and the `todos://all` resource. The headless and Tauri binaries import that same function, so their agent surface stays aligned.\n\n## Refreshing the window\n\nThe shared list publishes a new snapshot after every mutation. The Tauri side listens to that channel and emits `todos-updated`:\n\n```rust\nfn forward_todo_updates(application_handle: AppHandle, todos: &TodoList) {\n let mut updates = todos.subscribe();\n tauri::async_runtime::spawn(async move {\n while let Ok(todos) = updates.recv().await {\n if application_handle.emit(TODO_UPDATED_EVENT, todos).is_err() {\n break;\n }\n }\n });\n}\n```\n\nThe frontend listens for the `todos-updated` event and replaces its list. Agent mutations therefore update the open window without a refresh. Connection events follow the same pattern with `connection-updated`; `HostEvent::Welcome` exposes the claim code, `HostEvent::Claimed` identifies the agent, and `HostEvent::Disconnected` reports a dropped gateway connection.\n\n## Run the example\n\nThe exact Windows sequence is in the crate README: install the Tauri CLI, check the example, change into its directory, and run `cargo tauri dev`. The window shows the claim code from the gateway. Claim it in Claude Code, then call `rust_tauri_todo__addTodo`; the new item appears in the list.\n\nTauri is checked separately on Windows in CI. The main Rust workspace checks exclude `tauri-todo` on Linux because its GTK and WebKit development stack adds desktop dependencies without adding protocol coverage."},{"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":"\nSource: [github.com/Eigenwise/tesseron-typescript](https://github.com/Eigenwise/tesseron-typescript)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-typescript)\n\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":"Source: [github.com/Eigenwise/tesseron-typescript](https://github.com/Eigenwise/tesseron-typescript)\n\n[Report an issue](https://github.com/Eigenwise/tesseron/issues/new/choose?labels=area%3A%20sdk-typescript)\n\n## 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/eigenwise/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/eigenwise/tesseron/issues/69).\n- Full MCP logging (`sendLoggingMessage`), progress (`notifications/progress`), sampling (`createMessage`), and elicitation (`elicitInput`).\n\nWhenever a session connects, claims, or drops, or an app registers or removes an action or resource after hello, 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## How the plugin gets it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo ships no bundled gateway. `plugin/.mcp.json` fetches the published package instead, pinned to the plugin's own version:\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": { \"type\": \"stdio\", \"command\": \"npx\", \"args\": [\"-y\", \"@tesseron/mcp@2.10.4\"] }\n }\n}\n```\n\nThat pin, and the copy of it on this page, are two of nine surfaces carrying the plugin version, all owned by `scripts/sync-plugin-version.mjs`. Run `pnpm sync-plugin-version` to fix drift; CI runs `--check`.\n\nIf you're hacking on the gateway, point the plugin at your checkout rather than editing the pin.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `gateway/src/cli.ts` - entry point.\n- `gateway/src/gateway.ts` - session management, dialer dispatcher, instances-directory watcher.\n- `gateway/src/dialer.ts` - per-binding dialers (`WsDialer`, `UdsDialer`).\n- `gateway/src/session.ts` - a single session's state + claim code.\n- `gateway/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/eigenwise/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/eigenwise/tesseron/issues/69).\n- Full MCP logging (`sendLoggingMessage`), progress (`notifications/progress`), sampling (`createMessage`), and elicitation (`elicitInput`).\n\nWhenever a session connects, claims, or drops, or an app registers or removes an action or resource after hello, 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## How the plugin gets it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo ships no bundled gateway. `plugin/.mcp.json` fetches the published package instead, pinned to the plugin's own version:\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": { \"type\": \"stdio\", \"command\": \"npx\", \"args\": [\"-y\", \"@tesseron/mcp@2.10.4\"] }\n }\n}\n```\n\nThat pin, and the copy of it on this page, are two of nine surfaces carrying the plugin version, all owned by `scripts/sync-plugin-version.mjs`. Run `pnpm sync-plugin-version` to fix drift; CI runs `--check`.\n\nIf you're hacking on the gateway, point the plugin at your checkout rather than editing the pin.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `gateway/src/cli.ts` - entry point.\n- `gateway/src/gateway.ts` - session management, dialer dispatcher, instances-directory watcher.\n- `gateway/src/dialer.ts` - per-binding dialers (`WsDialer`, `UdsDialer`).\n- `gateway/src/session.ts` - a single session's state + claim code.\n- `gateway/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."}]}