@tesseron/docs-mcp 0.2.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docs-index.json +1 -1
- package/package.json +1 -1
package/dist/docs-index.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"095251e","generatedAt":"2026-04-24T08:58:28.783Z","count":37,"docs":[{"slug":"examples/express-todo","title":"express-todo","description":"REST API + Tesseron on the same Node process, backed by the same state.","section":"examples","related":["sdk/typescript/server","examples/node-todo"],"bodyRaw":"\n**What it teaches:** how to expose the same backend operations via two channels at once - HTTP for human / programmatic clients, Tesseron for the agent. Neither knows the other exists.\n\n**Source:** [`examples/express-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/express-todo)\n\n## Run it\n\n```bash\npnpm --filter express-todo dev\n# REST on http://localhost:3001\n# WS -> gateway on ws://127.0.0.1:7475\n```\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 todos = new Map<string, Todo>();\n\n// --- REST ---\nconst app = express();\napp.post('/todos', (req, res) => {\n const todo = { id: newId(), text: req.body.text, done: false };\n todos.set(todo.id, todo);\n res.json(todo);\n});\n// GET /todos, PATCH /todos/:id, DELETE /todos/:id ...\n\n// --- Tesseron ---\ntesseron.app({ id: 'express_todo', name: 'Express Todo' });\n\ntesseron.action('addTodo')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.set(todo.id, todo);\n return todo;\n });\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` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), coexistence with an HTTP server in one process**.\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","bodyText":"**What it teaches:** how to expose the same backend operations via two channels at once - HTTP for human / programmatic clients, Tesseron for the agent. Neither knows the other exists.\n\n**Source:** [`examples/express-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/express-todo)\n\n## Run it\n\n```bash\npnpm --filter express-todo dev\n# REST on http://localhost:3001\n# WS -> gateway on ws://127.0.0.1:7475\n```\n\n## Pattern: shared state, two interfaces\n\n```ts title=\"src/index.ts (excerpt)\"\n\nconst todos = new Map<string, Todo>();\n\n// --- REST ---\nconst app = express();\napp.post('/todos', (req, res) => {\n const todo = { id: newId(), text: req.body.text, done: false };\n todos.set(todo.id, todo);\n res.json(todo);\n});\n// GET /todos, PATCH /todos/:id, DELETE /todos/:id ...\n\n// --- Tesseron ---\ntesseron.app({ id: 'express_todo', name: 'Express Todo' });\n\ntesseron.action('addTodo')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.set(todo.id, todo);\n return todo;\n });\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` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), coexistence with an HTTP server in one process**.\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."},{"slug":"examples/index","title":"All examples","description":"Six runnable Todo apps that together cover every framework adapter and every major feature of the SDK.","section":"examples","related":["overview/quickstart","sdk/typescript/index"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\n\nAll six examples live in [`examples/`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples). Each is a complete, runnable Todo app, intentionally simple so the Tesseron-specific code is easy to read.\n\n<CardGrid>\n <LinkCard title=\"vanilla-todo\" href=\"./vanilla-todo/\"\n description=\"Zero-framework baseline. Start here.\" />\n <LinkCard title=\"node-todo\" href=\"./node-todo/\"\n description=\"Headless Node service. No browser.\" />\n <LinkCard title=\"express-todo\" href=\"./express-todo/\"\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` (in `clearCompleted`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.elicit` with schema (in `renameTodo`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.progress` (in `importTodos`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.sample` (in `suggestTodos`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\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-todo](/examples/node-todo/)** - the same action declarations on Node. Proves nothing's tied to the browser.\n3. **[express-todo](/examples/express-todo/)** - adds a REST API next to Tesseron. Shows the \"same state, two channels\" pattern.\n4. **[react-todo](/examples/react-todo/)** - hooks-based integration.\n5. **[svelte-todo](/examples/svelte-todo/)** - mutation via `$state` runes.\n6. **[vue-todo](/examples/vue-todo/)** - mutation via `ref().value` / `computed()`.\n\n## Running any of them\n\n```bash\ngit clone https://github.com/BrainBlend-AI/tesseron\ncd tesseron\npnpm install\npnpm --filter <example-name> dev\n```\n\nThen claim the session from your agent - see the [quickstart](/overview/quickstart/) if you haven't already.\n","bodyText":"All six examples live in [`examples/`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples). Each is a complete, runnable Todo app, intentionally simple so the Tesseron-specific code is easy to read.\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` (in `clearCompleted`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.elicit` with schema (in `renameTodo`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.progress` (in `importTodos`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.sample` (in `suggestTodos`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\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-todo](/examples/node-todo/)** - the same action declarations on Node. Proves nothing's tied to the browser.\n3. **[express-todo](/examples/express-todo/)** - adds a REST API next to Tesseron. Shows the \"same state, two channels\" pattern.\n4. **[react-todo](/examples/react-todo/)** - hooks-based integration.\n5. **[svelte-todo](/examples/svelte-todo/)** - mutation via `$state` runes.\n6. **[vue-todo](/examples/vue-todo/)** - mutation via `ref().value` / `computed()`.\n\n## Running any of them\n\n```bash\ngit clone https://github.com/BrainBlend-AI/tesseron\ncd tesseron\npnpm install\npnpm --filter <example-name> dev\n```\n\nThen claim the session from your agent - see the [quickstart](/overview/quickstart/) if you haven't already."},{"slug":"examples/node-todo","title":"node-todo","description":"Headless Node service - no HTTP, no browser. Proves the SDK isn't tied to DOM.","section":"examples","related":["sdk/typescript/server"],"bodyRaw":"\n**What it teaches:** a pure-Node Tesseron integration. No Express, no web 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.\n\n**Source:** [`examples/node-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/node-todo)\n\n## Run it\n\n```bash\npnpm --filter node-todo dev\n# prints the claim code to stdout; no browser\n```\n\n## What's inside\n\n```ts title=\"src/index.ts (excerpt)\"\nimport { tesseron } from '@tesseron/server';\nimport { z } from 'zod';\n\nconst todos = new Map<string, Todo>();\n\ntesseron.app({ id: 'node_todo', name: 'Node Todo' });\n\ntesseron.action('addTodo')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.set(todo.id, todo);\n log(`+ addTodo: \"${text}\" (id=${todo.id})`);\n return todo;\n });\n\ntesseron.resource('todoStats')\n .read(() => ({ total: todos.size, completed: [...todos.values()].filter(t => t.done).length }));\n\nconst welcome = await tesseron.connect();\nlog(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n\nprocess.on('SIGINT', async () => { await tesseron.disconnect(); process.exit(0); });\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), structured logging via `log()`, signal-aware shutdown**.\n\nThe same nine actions as `vanilla-todo`, but persistence is an in-memory `Map` and there's no UI - the agent is the only way to interact.\n","bodyText":"**What it teaches:** a pure-Node Tesseron integration. No Express, no web 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.\n\n**Source:** [`examples/node-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/node-todo)\n\n## Run it\n\n```bash\npnpm --filter node-todo dev\n# prints the claim code to stdout; no browser\n```\n\n## What's inside\n\n```ts title=\"src/index.ts (excerpt)\"\n\nconst todos = new Map<string, Todo>();\n\ntesseron.app({ id: 'node_todo', name: 'Node Todo' });\n\ntesseron.action('addTodo')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.set(todo.id, todo);\n log(`+ addTodo: \"${text}\" (id=${todo.id})`);\n return todo;\n });\n\ntesseron.resource('todoStats')\n .read(() => ({ total: todos.size, completed: [...todos.values()].filter(t => t.done).length }));\n\nconst welcome = await tesseron.connect();\nlog(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n\nprocess.on('SIGINT', async () => { await tesseron.disconnect(); process.exit(0); });\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), structured logging via `log()`, signal-aware shutdown**.\n\nThe same nine actions as `vanilla-todo`, but persistence is an in-memory `Map` and there's no UI - the agent is the only way to interact."},{"slug":"examples/react-todo","title":"react-todo","description":"React 18 + `@tesseron/react` hooks. Idiomatic integration with component lifecycle.","section":"examples","related":["sdk/typescript/react","sdk/typescript/web"],"bodyRaw":"\n**What it teaches:** declarative action registration in React. Mount = register; unmount = unregister. State is mutated through `setTodos` exactly like in a normal React app.\n\n**Source:** [`examples/react-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/react-todo)\n\n## Run it\n\n```bash\npnpm --filter react-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```tsx title=\"src/app.tsx (excerpt)\"\nimport { useTesseronAction, useTesseronResource, useTesseronConnection } from '@tesseron/react';\nimport { z } from 'zod';\nimport { useState } from 'react';\n\nexport function App() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.claimCode && <ClaimBanner code={conn.claimCode} />}\n <TodoList todos={todos} />\n </>\n );\n}\n```\n\nFeatures exercised: **all three React hooks (`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`), annotations, Zod input, setState-driven UI reactivity, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), subscribable resources**.\n\nSee the [React adapter docs](/sdk/typescript/react/) for the full hook API.\n","bodyText":"**What it teaches:** declarative action registration in React. Mount = register; unmount = unregister. State is mutated through `setTodos` exactly like in a normal React app.\n\n**Source:** [`examples/react-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/react-todo)\n\n## Run it\n\n```bash\npnpm --filter react-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```tsx title=\"src/app.tsx (excerpt)\"\n\nexport function App() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.claimCode && }\n \n </>\n );\n}\n```\n\nFeatures exercised: **all three React hooks (`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`), annotations, Zod input, setState-driven UI reactivity, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), subscribable resources**.\n\nSee the [React adapter docs](/sdk/typescript/react/) for the full hook API."},{"slug":"examples/svelte-todo","title":"svelte-todo","description":"Svelte 5 runes (`$state`, `$derived`). Mutation via direct reassignment.","section":"examples","related":["sdk/typescript/web"],"bodyRaw":"\n**What it teaches:** integrating Tesseron with Svelte 5's rune-based reactivity. Handlers reassign `let todos = $state(...)` and Svelte re-renders.\n\n**Source:** [`examples/svelte-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/svelte-todo)\n\n## Run it\n\n```bash\npnpm --filter svelte-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```svelte title=\"src/app.svelte (excerpt)\"\n<script lang=\"ts\">\n import { tesseron } from '@tesseron/web';\n import { z } from 'zod';\n import { onMount } from 'svelte';\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 tesseron.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 todos = [...todos, todo]; // reassign - Svelte observes $state\n return todo;\n });\n\n tesseron.resource('todoStats')\n .read(() => ({ total: todos.length, completed: todos.filter((t) => t.done).length }));\n\n onMount(async () => {\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n });\n</script>\n```\n\nFeatures exercised: **`$state` / `$derived` runes, actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`, with a graceful fallback when the client doesn't advertise sampling), connection inside `onMount`**.\n\nThere isn't a Svelte-specific package - `@tesseron/web` composes cleanly with runes. If you'd like a `useTesseron*` rune-style API, it's a small wrapper to build - open an issue if you'd use it.\n","bodyText":"**What it teaches:** integrating Tesseron with Svelte 5's rune-based reactivity. Handlers reassign `let todos = $state(...)` and Svelte re-renders.\n\n**Source:** [`examples/svelte-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/svelte-todo)\n\n## Run it\n\n```bash\npnpm --filter svelte-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```svelte title=\"src/app.svelte (excerpt)\"\n<script lang=\"ts\">\n import { tesseron } from '@tesseron/web';\n import { z } from 'zod';\n import { onMount } from 'svelte';\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 tesseron.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 todos = [...todos, todo]; // reassign - Svelte observes $state\n return todo;\n });\n\n tesseron.resource('todoStats')\n .read(() => ({ total: todos.length, completed: todos.filter((t) => t.done).length }));\n\n onMount(async () => {\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n });\n</script>\n```\n\nFeatures exercised: **`$state` / `$derived` runes, actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`, with a graceful fallback when the client doesn't advertise sampling), connection inside `onMount`**.\n\nThere isn't a Svelte-specific package - `@tesseron/web` composes cleanly with runes. If you'd like a `useTesseron*` rune-style API, it's a small wrapper to build - open an issue if you'd use it."},{"slug":"examples/vanilla-todo","title":"vanilla-todo","description":"Plain Vite + TypeScript. The minimum environment for exercising the SDK.","section":"examples","related":["sdk/typescript/web","sdk/typescript/index"],"bodyRaw":"\n**What it teaches:** the raw action / resource builder API with no framework in the way. Read this before any of the framework-specific examples.\n\n**Source:** [`examples/vanilla-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vanilla-todo)\n\n## Run it\n\n```bash\npnpm --filter vanilla-todo dev\n# opens http://localhost:5173\n```\n\n## What's inside\n\n```ts title=\"src/main.ts (excerpt)\"\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'vanilla_todo', name: 'Vanilla Todo' });\n\ntesseron\n .action('addTodo')\n .describe('Add a new todo item. Returns the created todo.')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n state.todos = [...state.todos, todo];\n render();\n return todo;\n });\n\ntesseron.action('toggleTodo')\n .input(z.object({ id: z.string() }))\n .annotate({ destructive: true })\n .handler(/* … */);\n\ntesseron.resource('todoStats')\n .read(() => ({ total: state.todos.length, completed: state.todos.filter(t => t.done).length }));\n\nawait tesseron.connect();\n```\n\nNine actions (`addTodo`, `toggleTodo`, `deleteTodo`, `listTodos`, `setFilter`, `clearCompleted`, `renameTodo`, `importTodos`, `suggestTodos`) and two subscribable resources (`currentFilter`, `todoStats`) - a realistic-but-contained surface for experimenting.\n\nFeatures exercised: **actions, annotations (`destructive`, `requiresConfirmation`, `readOnly`), subscribable resources, Zod input validation, `ctx.confirm` (in `clearCompleted`), `ctx.elicit` with schema (in `renameTodo`), `ctx.progress` (in `importTodos`), `ctx.sample` (in `suggestTodos`), connection lifecycle**.\n","bodyText":"**What it teaches:** the raw action / resource builder API with no framework in the way. Read this before any of the framework-specific examples.\n\n**Source:** [`examples/vanilla-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vanilla-todo)\n\n## Run it\n\n```bash\npnpm --filter vanilla-todo dev\n# opens http://localhost:5173\n```\n\n## What's inside\n\n```ts title=\"src/main.ts (excerpt)\"\n\ntesseron.app({ id: 'vanilla_todo', name: 'Vanilla Todo' });\n\ntesseron\n .action('addTodo')\n .describe('Add a new todo item. Returns the created todo.')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n state.todos = [...state.todos, todo];\n render();\n return todo;\n });\n\ntesseron.action('toggleTodo')\n .input(z.object({ id: z.string() }))\n .annotate({ destructive: true })\n .handler(/* … */);\n\ntesseron.resource('todoStats')\n .read(() => ({ total: state.todos.length, completed: state.todos.filter(t => t.done).length }));\n\nawait tesseron.connect();\n```\n\nNine actions (`addTodo`, `toggleTodo`, `deleteTodo`, `listTodos`, `setFilter`, `clearCompleted`, `renameTodo`, `importTodos`, `suggestTodos`) and two subscribable resources (`currentFilter`, `todoStats`) - a realistic-but-contained surface for experimenting.\n\nFeatures exercised: **actions, annotations (`destructive`, `requiresConfirmation`, `readOnly`), subscribable resources, Zod input validation, `ctx.confirm` (in `clearCompleted`), `ctx.elicit` with schema (in `renameTodo`), `ctx.progress` (in `importTodos`), `ctx.sample` (in `suggestTodos`), connection lifecycle**."},{"slug":"examples/vue-todo","title":"vue-todo","description":"Vue 3 composition API with `ref()` and `computed()`.","section":"examples","related":["sdk/typescript/web"],"bodyRaw":"\n**What it teaches:** integrating Tesseron with Vue 3's reactivity. Handlers mutate `todos.value` and `computed()` recomputes downstream derived state.\n\n**Source:** [`examples/vue-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vue-todo)\n\n## Run it\n\n```bash\npnpm --filter vue-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```vue title=\"src/app.vue (excerpt)\"\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue';\nimport { tesseron } from '@tesseron/web';\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\ntesseron.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 todos.value = [...todos.value, todo]; // .value mutation triggers reactivity\n return todo;\n });\n\ntesseron.resource('todoStats')\n .read(() => ({ total: todos.value.length, completed: todos.value.filter((t) => t.done).length }));\n\nonMounted(async () => {\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n});\n</script>\n```\n\nFeatures exercised: **`ref` + `computed`, actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), connection inside `onMounted`**.\n\nLike Svelte, Vue doesn't need a dedicated adapter package - `@tesseron/web` composes with the composition API directly.\n","bodyText":"**What it teaches:** integrating Tesseron with Vue 3's reactivity. Handlers mutate `todos.value` and `computed()` recomputes downstream derived state.\n\n**Source:** [`examples/vue-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vue-todo)\n\n## Run it\n\n```bash\npnpm --filter vue-todo dev\n# http://localhost:5173\n```\n\n## What's inside\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\ntesseron.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 todos.value = [...todos.value, todo]; // .value mutation triggers reactivity\n return todo;\n });\n\ntesseron.resource('todoStats')\n .read(() => ({ total: todos.value.length, completed: todos.value.filter((t) => t.done).length }));\n\nonMounted(async () => {\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n});\n</script>\n```\n\nFeatures exercised: **`ref` + `computed`, actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), connection inside `onMounted`**.\n\nLike Svelte, Vue doesn't need a dedicated adapter package - `@tesseron/web` composes with the composition API directly."},{"slug":"index","title":"Tesseron","description":"Expose typed web-app actions to MCP-compatible AI agents over WebSocket. No browser automation, no scraping.","section":"","related":["overview/quickstart","overview/why","overview/architecture"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Diagram from '../../components/Diagram.astro';\n\n<Diagram\n caption=\"Your web app declares actions. The MCP gateway bridges them to any MCP-capable agent (Claude Code, Cursor, Claude Desktop).\"\n nodeWidth={130}\n spacing={115}\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', code: '@tesseron/web', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'WebSocket + MCP', code: '@tesseron/mcp :7475', 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 { from: 'app', to: 'gw', label: 'WebSocket', bidirectional: true, accent: true },\n { from: 'gw', to: 'agent', label: 'MCP stdio', bidirectional: true, accent: true },\n ]}\n/>\n\n## What you get\n\n<CardGrid>\n <Card title=\"Typed actions\" icon=\"seti:typescript\">\n Declare actions with a fluent builder backed by any [Standard Schema](https://standardschema.dev) validator - Zod, Valibot, ArkType, Effect Schema. The MCP tool schema is derived automatically.\n </Card>\n <Card title=\"Real UI, not a shadow DOM\" icon=\"open-book\">\n The agent drives your actual running app. State, auth, feature flags - all intact. Nothing to scrape, nothing to re-implement.\n </Card>\n <Card title=\"Full MCP capability set\" icon=\"rocket\">\n Streaming progress, cancellation, resources (read + subscribe), sampling, and elicitation work out of the box over a single WebSocket.\n </Card>\n <Card title=\"Framework-agnostic\" icon=\"puzzle\">\n One-file integrations for vanilla TS, React, Svelte, Vue, Node, and Express. Same builder API everywhere.\n </Card>\n</CardGrid>\n\n## Read the docs in two halves\n\n<CardGrid>\n <LinkCard\n title=\"Protocol\"\n href=\"./protocol/\"\n description=\"The wire format, handshake, action model, and advanced MCP features - with a diagram for every flow.\"\n />\n <LinkCard\n title=\"SDK\"\n href=\"./sdk/\"\n description=\"Build with @tesseron/web, /server, /react, or port Tesseron to a new language.\"\n />\n</CardGrid>\n\n## 60-second taste\n\n```ts title=\"src/main.ts\"\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\n\n// 1. A plain action - input, handler, streaming progress, return value.\ntesseron\n .action('searchProducts')\n .describe('Search the product catalog')\n .input(z.object({ query: z.string().min(1), limit: z.number().default(10) }))\n .handler(async ({ query, limit }, ctx) => {\n ctx.progress({ message: 'searching...', percent: 20 });\n const items = await store.search(query, { limit });\n return { items }; // becomes the MCP tool result the agent sees\n });\n\n// 2. An action that pauses to ask the user through the agent's UI.\ntesseron\n .action('checkout')\n .describe('Place the pending order')\n .input(z.object({ cartId: z.string() }))\n .handler(async ({ cartId }, ctx) => {\n const ok = await ctx.confirm({\n question: `Place order for $${cart.total(cartId)}? This charges your card.`,\n });\n if (!ok) throw new Error('User cancelled');\n return await orders.place(cartId);\n });\n\n// 3. A resource - readable, subscribable app state. No polling needed.\ntesseron\n .resource('currentRoute')\n .describe('URL the user is viewing')\n .read(() => location.pathname)\n .subscribe((emit) => {\n const fn = () => emit(location.pathname);\n addEventListener('popstate', fn);\n return () => removeEventListener('popstate', fn);\n });\n\n// 4. Connect. `connect()` resolves with the claim code - surface it\n// in your UI so the human can paste it into their agent.\nconst { claimCode } = await tesseron.connect();\ndocument.querySelector('#connect-banner')!.textContent =\n `Paste \"${claimCode}\" into Claude to connect this tab.`;\n```\n\n**What the agent sees once connected:**\n\n- Two MCP tools: `shop__searchProducts` and `shop__checkout`. It can call either, pass typed input, and receive your typed output.\n- One resource: `tesseron://shop/currentRoute`. It can read once, or subscribe and get pushed updates every time the user navigates - no polling, no webhooks.\n\n**What you didn't have to do:**\n\n- No HTTP server. The WebSocket goes to the gateway that runs next to the agent.\n- No OpenAPI spec, no tool schemas. They're derived from your Zod validators.\n- No glue between tools. The agent reads `searchProducts`'s output, picks a product, calls `checkout` with it, and pauses on `ctx.confirm` until the user approves - all orchestrated by the agent loop.\n\nThat's the whole surface: `.action()`, `.resource()`, and `.connect()`. Everything else is detail.\n\n**The other half runs next to the agent.** The gateway is `@tesseron/mcp` - an MCP server that opens the WebSocket port, hands out claim codes, and translates MCP tool calls into `actions/invoke` frames on your app's socket. You don't write MCP code; the gateway *is* the MCP server.\n\nYou wire it into your agent's MCP config once. Claude Desktop example (`claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@tesseron/mcp\"]\n }\n }\n}\n```\n\nClaude Code / Cursor / any MCP-capable client: same pattern, their own config file.\n","bodyText":"## What you get\n\n## Read the docs in two halves\n\n## 60-second taste\n\n```ts title=\"src/main.ts\"\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\n\n// 1. A plain action - input, handler, streaming progress, return value.\ntesseron\n .action('searchProducts')\n .describe('Search the product catalog')\n .input(z.object({ query: z.string().min(1), limit: z.number().default(10) }))\n .handler(async ({ query, limit }, ctx) => {\n ctx.progress({ message: 'searching...', percent: 20 });\n const items = await store.search(query, { limit });\n return { items }; // becomes the MCP tool result the agent sees\n });\n\n// 2. An action that pauses to ask the user through the agent's UI.\ntesseron\n .action('checkout')\n .describe('Place the pending order')\n .input(z.object({ cartId: z.string() }))\n .handler(async ({ cartId }, ctx) => {\n const ok = await ctx.confirm({\n question: `Place order for $${cart.total(cartId)}? This charges your card.`,\n });\n if (!ok) throw new Error('User cancelled');\n return await orders.place(cartId);\n });\n\n// 3. A resource - readable, subscribable app state. No polling needed.\ntesseron\n .resource('currentRoute')\n .describe('URL the user is viewing')\n .read(() => location.pathname)\n .subscribe((emit) => {\n const fn = () => emit(location.pathname);\n addEventListener('popstate', fn);\n return () => removeEventListener('popstate', fn);\n });\n\n// 4. Connect. `connect()` resolves with the claim code - surface it\n// in your UI so the human can paste it into their agent.\nconst { claimCode } = await tesseron.connect();\ndocument.querySelector('#connect-banner')!.textContent =\n `Paste \"${claimCode}\" into Claude to connect this tab.`;\n```\n\n**What the agent sees once connected:**\n\n- Two MCP tools: `shop__searchProducts` and `shop__checkout`. It can call either, pass typed input, and receive your typed output.\n- One resource: `tesseron://shop/currentRoute`. It can read once, or subscribe and get pushed updates every time the user navigates - no polling, no webhooks.\n\n**What you didn't have to do:**\n\n- No HTTP server. The WebSocket goes to the gateway that runs next to the agent.\n- No OpenAPI spec, no tool schemas. They're derived from your Zod validators.\n- No glue between tools. The agent reads `searchProducts`'s output, picks a product, calls `checkout` with it, and pauses on `ctx.confirm` until the user approves - all orchestrated by the agent loop.\n\nThat's the whole surface: `.action()`, `.resource()`, and `.connect()`. Everything else is detail.\n\n**The other half runs next to the agent.** The gateway is `@tesseron/mcp` - an MCP server that opens the WebSocket port, hands out claim codes, and translates MCP tool calls into `actions/invoke` frames on your app's socket. You don't write MCP code; the gateway *is* the MCP server.\n\nYou wire it into your agent's MCP config once. Claude Desktop example (`claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@tesseron/mcp\"]\n }\n }\n}\n```\n\nClaude Code / Cursor / any MCP-capable client: same pattern, their own config file."},{"slug":"overview/architecture","title":"Architecture at a glance","description":"The three moving parts - your app, the MCP gateway, the agent - and how a single action flows between them.","section":"overview","related":["overview/quickstart","protocol/handshake","protocol/actions","sdk/typescript/mcp"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\n<Diagram\n caption=\"Three processes, two protocols. Your app speaks JSON-RPC over WebSocket to the MCP gateway; the gateway speaks MCP stdio to the agent.\"\n nodes={[\n { id: 'user', label: 'USER', sub: ['human at', 'the keyboard'], icon: 'user' },\n { id: 'app', label: 'YOUR APP', sub: 'browser or node', code: '@tesseron/web', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'WebSocket + MCP', code: '@tesseron/mcp :7475', 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 { 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 process. Hosts the action handlers and the real state they mutate. Uses `@tesseron/web`, `@tesseron/server`, or a framework adapter.\n- **The MCP gateway** - a small Node process (`@tesseron/mcp`) bundled into the Claude Code plugin. Listens on `ws://127.0.0.1:7475` for your app and on stdio for the agent. Translates between the two.\n- **The agent** - Claude Code, Claude Desktop, Cursor, or any other MCP client. Doesn't know or care about WebSockets - 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 |\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- **Origin allowlist.** Non-localhost origins are rejected at the upgrade handshake unless explicitly allowed.\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\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 process. Hosts the action handlers and the real state they mutate. Uses `@tesseron/web`, `@tesseron/server`, or a framework adapter.\n- **The MCP gateway** - a small Node process (`@tesseron/mcp`) bundled into the Claude Code plugin. Listens on `ws://127.0.0.1:7475` for your app and on stdio for the agent. Translates between the two.\n- **The agent** - Claude Code, Claude Desktop, Cursor, or any other MCP client. Doesn't know or care about WebSockets - 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 |\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- **Origin allowlist.** Non-localhost origins are rejected at the upgrade handshake unless explicitly allowed.\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\nNext: the [5-minute quickstart](/overview/quickstart/) walks through getting this running end-to-end."},{"slug":"overview/quickstart","title":"Quickstart (5 minutes)","description":"Install the plugin, drop the SDK into an app, declare one action, watch Claude call it.","section":"overview","related":["sdk/typescript/index","sdk/typescript/action-builder","overview/architecture","examples/index"],"bodyRaw":"\nimport { Steps, Tabs, TabItem } from '@astrojs/starlight/components';\n\n**Prereqs.** Node ≥ 20. Claude Code installed.\n\n<Steps>\n\n1. **Install the Claude Code plugin.** It bundles the MCP gateway and auto-registers it as an MCP server.\n\n ```text\n /plugin marketplace add BrainBlend-AI/tesseron\n /plugin install tesseron@tesseron\n ```\n\n Restart Claude Code after installation. The gateway now runs whenever the plugin is enabled; no separate process to manage.\n\n2. **Add the SDK to your app.**\n\n <Tabs>\n <TabItem label=\"Browser / Vite\">\n ```bash\n pnpm add @tesseron/web zod\n ```\n </TabItem>\n <TabItem label=\"React\">\n ```bash\n pnpm add @tesseron/react zod\n ```\n </TabItem>\n <TabItem label=\"Node / server\">\n ```bash\n pnpm add @tesseron/server zod\n ```\n </TabItem>\n </Tabs>\n\n3. **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 `tesseron.connect()` opens the WebSocket and resolves once the gateway returns a `welcome` with a `claimCode`.\n\n4. **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\n5. **Call your action.** The tool list now contains `notes__createNote`. Ask Claude:\n\n > \"Create a note titled 'Groceries' with body 'eggs, milk, bread'.\"\n\n The handler runs inside your tab. The new note appears in your UI, reactively. Claude sees the returned object as the tool result.\n\n</Steps>\n\n## Next steps\n\n- [Add progress + cancellation](/protocol/progress-cancellation/) - for actions that take more than a beat.\n- [Expose resources](/protocol/resources/) - let Claude read your UI state (current route, selected item, filter settings).\n- [Use sampling](/protocol/sampling/) - let your handler ask the agent's LLM mid-execution.\n- [Pick your framework adapter](/sdk/) - React, Svelte, Vue, Express patterns.\n","bodyText":"**Prereqs.** Node ≥ 20. Claude Code installed.\n\n## Next steps\n\n- [Add progress + cancellation](/protocol/progress-cancellation/) - for actions that take more than a beat.\n- [Expose resources](/protocol/resources/) - let Claude read your UI state (current route, selected item, filter settings).\n- [Use sampling](/protocol/sampling/) - let your handler ask the agent's LLM mid-execution.\n- [Pick your framework adapter](/sdk/) - React, Svelte, Vue, Express patterns."},{"slug":"overview/why","title":"Why Tesseron?","description":"The problem Tesseron solves, and where it fits relative to browser automation, chat widgets, and custom APIs.","section":"overview","related":["overview/architecture","protocol/index"],"bodyRaw":"\nAgents are great at reasoning about what to do. They're bad at reaching into your app to do it.\n\nThere are three common ways to close that gap. Tesseron is a fourth.\n\n## 1. Browser automation (Playwright, Selenium, Computer Use)\n\nThe agent drives a pixel-level browser. Conceptually powerful, practically fragile: every layout tweak breaks selectors, every modal needs bespoke handling, every authentication flow is re-solved from scratch. Token-heavy. Slow.\n\n## 2. Chat widget embedded in the app\n\nYou bolt an AI sidebar into your UI and wire up tool calls manually. The agent can talk to your backend, but it can't touch the running UI state the user is looking at. Two worlds that never meet.\n\n## 3. A bespoke MCP server for your backend\n\nGreat for headless automation. Useless for \"put this in the user's cart on the page they're already viewing.\" The user's session, their open tab, their in-memory draft - all invisible to a backend MCP server.\n\n## 4. Tesseron\n\nThe running app opens a WebSocket to a local MCP gateway and declares its actions:\n\n```ts\ntesseron.action('addToCart').input(...).handler(...);\n```\n\nThe gateway exposes those actions as MCP tools over stdio. Any MCP-capable agent - Claude Code, Cursor, Claude Desktop, any other - sees them and calls them. The handler runs inside the user's real tab, with their real state, their real auth.\n\n## Tradeoffs (be honest)\n\n- **Localhost by default.** Tesseron is a local-first developer tool. The default gateway binds to `127.0.0.1:7475` and rejects non-localhost origins. Remote agents require an allowlist.\n- **Requires the tab to be open.** If the page is closed, the session is gone. This is a feature - it keeps the agent bound to what the user can see.\n- **Not a replacement for a headless API.** If you need scheduled or unattended automation, you want a server-side MCP. Tesseron complements it - it doesn't replace it.\n\n## When Tesseron is the right fit\n\n- Internal tools where power users want to drive the UI via chat.\n- Complex workflows that already exist as UI actions - search, filter, create, approve - and shouldn't be duplicated on the backend.\n- Product demos and prototypes where \"the agent actually does what the user sees\" is the whole point.\n- Personal dashboards, admin panels, CMS editors, developer tooling.\n\nIf you're shipping one of those, keep reading.\n","bodyText":"Agents are great at reasoning about what to do. They're bad at reaching into your app to do it.\n\nThere are three common ways to close that gap. Tesseron is a fourth.\n\n## 1. Browser automation (Playwright, Selenium, Computer Use)\n\nThe agent drives a pixel-level browser. Conceptually powerful, practically fragile: every layout tweak breaks selectors, every modal needs bespoke handling, every authentication flow is re-solved from scratch. Token-heavy. Slow.\n\n## 2. Chat widget embedded in the app\n\nYou bolt an AI sidebar into your UI and wire up tool calls manually. The agent can talk to your backend, but it can't touch the running UI state the user is looking at. Two worlds that never meet.\n\n## 3. A bespoke MCP server for your backend\n\nGreat for headless automation. Useless for \"put this in the user's cart on the page they're already viewing.\" The user's session, their open tab, their in-memory draft - all invisible to a backend MCP server.\n\n## 4. Tesseron\n\nThe running app opens a WebSocket to a local MCP gateway and declares its actions:\n\n```ts\ntesseron.action('addToCart').input(...).handler(...);\n```\n\nThe gateway exposes those actions as MCP tools over stdio. Any MCP-capable agent - Claude Code, Cursor, Claude Desktop, any other - sees them and calls them. The handler runs inside the user's real tab, with their real state, their real auth.\n\n## Tradeoffs (be honest)\n\n- **Localhost by default.** Tesseron is a local-first developer tool. The default gateway binds to `127.0.0.1:7475` and rejects non-localhost origins. Remote agents require an allowlist.\n- **Requires the tab to be open.** If the page is closed, the session is gone. This is a feature - it keeps the agent bound to what the user can see.\n- **Not a replacement for a headless API.** If you need scheduled or unattended automation, you want a server-side MCP. Tesseron complements it - it doesn't replace it.\n\n## When Tesseron is the right fit\n\n- Internal tools where power users want to drive the UI via chat.\n- Complex workflows that already exist as UI actions - search, filter, create, approve - and shouldn't be duplicated on the backend.\n- Product demos and prototypes where \"the agent actually does what the user sees\" is the whole point.\n- Personal dashboards, admin panels, CMS editors, developer tooling.\n\nIf you're shipping one of those, keep reading."},{"slug":"protocol/actions","title":"Action model","description":"How actions are declared, namespaced, invoked, validated, and returned.","section":"protocol","related":["sdk/typescript/action-builder","protocol/wire-format","protocol/elicitation","protocol/sampling","protocol/progress-cancellation"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nAn **action** is a named, typed, handler-backed operation that the app exposes to the agent. On the MCP side it looks like a single tool. On the Tesseron side it has a schema, a handler, and a set of per-invocation context helpers.\n\n<Sequence\n caption=\"One invocation from tools/call to tool result - with input validation between.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: \"tools/call { name: 'shop__addItem', arguments }\" },\n { from: 'g', to: 's', label: 'actions/invoke { name, invocationId, input }' },\n { note: 's', label: 'validate input (Standard Schema)' },\n { note: 's', label: 'run handler(input, ctx)' },\n { from: 's', to: 'g', label: \"result { id: 'item_42', ... }\", style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## Declaration\n\n```ts\ntesseron\n .action('addItem') // action name\n .describe('Add an item to the cart')\n .input(z.object({ sku: z.string(), quantity: z.number().int().positive() }))\n .output(z.object({ cartId: z.string(), itemId: z.string() }))\n .annotate({ destructive: false })\n .timeout({ ms: 10_000 })\n .handler(async ({ sku, quantity }, ctx) => {\n const item = await cart.add(sku, quantity);\n return { cartId: cart.id, itemId: item.id };\n });\n```\n\nBuilder steps:\n\n| Step | Purpose | Required? |\n|---|---|---|\n| `.describe(string)` | Human-readable description shown to the agent's LLM. | Recommended |\n| `.input(schema)` | Standard Schema validator for arguments. Becomes JSON Schema on the wire. | Recommended |\n| `.output(schema)` | Validator for the return value. Not enforced by default. | Optional |\n| `.annotate({…})` | Metadata: `readOnly`, `destructive`, `requiresConfirmation`. | Optional |\n| `.timeout(ms)` | Abort the invocation after N ms. Default 60 000. | Optional |\n| `.strictOutput()` | Enforce the output schema. Default is passthrough. | Optional |\n| `.handler(fn)` | The function that runs. Receives `(input, ctx)`. Terminates the builder. | Required |\n\n## Naming and the MCP tool list\n\nThe MCP gateway registers every action as an MCP tool under the name `<app.id>__<action.name>`. For `app.id = \"shop\"` and `action = \"searchProducts\"` the agent sees `shop__searchProducts`. Multiple apps can coexist - see [multi-app coexistence](/protocol/security/#multi-app-coexistence).\n\n## Invocation wire format\n\nRequest from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"method\": \"actions/invoke\",\n \"params\": {\n \"name\": \"addItem\",\n \"invocationId\": \"inv_abc123\",\n \"input\": { \"sku\": \"SKU-1\", \"quantity\": 2 },\n \"client\": { \"route\": \"/cart\" }\n }\n}\n```\n\nThe SDK turns `params` into an `ActionContext` and calls your handler. `ctx.agent`, `ctx.agentCapabilities`, and the rest of `ctx.client` come from the `welcome` the SDK cached at handshake time - they don't ride on every `actions/invoke`. Response:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"result\": { \"cartId\": \"c_1\", \"itemId\": \"i_42\" }\n}\n```\n\nOr, on error:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"error\": { \"code\": -32005, \"message\": \"Cart is locked\", \"data\": { \"cartId\": \"c_1\" } }\n}\n```\n\n## Validation passes\n\n- **Input** is validated **before** the handler runs. Failure → error code `-32004 InputValidation`, handler never fires. Validation `issues` are returned in `error.data`.\n- **Output** is **not** validated by default. Call `.strictOutput()` to enforce - failure becomes `-32005 HandlerError` with issues in `data`. The permissive default is deliberate: output schemas are often loose, and most teams use `.output()` purely for documentation.\n\n## Annotations\n\n| Field | Meaning |\n|---|---|\n| `readOnly` | The action doesn't mutate state. The agent may parallelise or cache. |\n| `destructive` | The action mutates persistent state. Agents SHOULD surface a confirmation UI. |\n| `requiresConfirmation` | The action MUST NOT be called without explicit user confirmation. |\n\nAnnotations are **advisory**. They ride along with the MCP tool descriptor; honouring them is the agent's job.\n\n## What the handler receives\n\n```ts\nhandler: (input: I, ctx: ActionContext) => O | Promise<O>\n```\n\n`ctx` is the per-invocation context. Full reference in [SDK → Context](/sdk/typescript/context/):\n\n| Field | Purpose |\n|---|---|\n| `ctx.signal` | `AbortSignal` fired on timeout or cancel. |\n| `ctx.agent` | `{ id, name }` of the calling agent. |\n| `ctx.agentCapabilities` | What the agent can do. Gate your sampling / elicit calls on this. |\n| `ctx.client` | `{ origin, route?, userAgent? }`. |\n| `ctx.progress(update)` | Emit an `actions/progress` notification. |\n| `ctx.sample(req)` | Re-enter the agent LLM for a reasoning step. |\n| `ctx.confirm(req)` | Ask the user a yes/no question. Returns `false` when the client can't prompt. |\n| `ctx.elicit(req)` | Ask the user for structured content matching a schema. |\n| `ctx.log({ level, message, meta? })` | Structured log forwarded to MCP logging. |\n\nNext: [progress & cancellation](/protocol/progress-cancellation/).\n","bodyText":"An **action** is a named, typed, handler-backed operation that the app exposes to the agent. On the MCP side it looks like a single tool. On the Tesseron side it has a schema, a handler, and a set of per-invocation context helpers.\n\n## Declaration\n\n```ts\ntesseron\n .action('addItem') // action name\n .describe('Add an item to the cart')\n .input(z.object({ sku: z.string(), quantity: z.number().int().positive() }))\n .output(z.object({ cartId: z.string(), itemId: z.string() }))\n .annotate({ destructive: false })\n .timeout({ ms: 10_000 })\n .handler(async ({ sku, quantity }, ctx) => {\n const item = await cart.add(sku, quantity);\n return { cartId: cart.id, itemId: item.id };\n });\n```\n\nBuilder steps:\n\n| Step | Purpose | Required? |\n|---|---|---|\n| `.describe(string)` | Human-readable description shown to the agent's LLM. | Recommended |\n| `.input(schema)` | Standard Schema validator for arguments. Becomes JSON Schema on the wire. | Recommended |\n| `.output(schema)` | Validator for the return value. Not enforced by default. | Optional |\n| `.annotate({…})` | Metadata: `readOnly`, `destructive`, `requiresConfirmation`. | Optional |\n| `.timeout(ms)` | Abort the invocation after N ms. Default 60 000. | Optional |\n| `.strictOutput()` | Enforce the output schema. Default is passthrough. | Optional |\n| `.handler(fn)` | The function that runs. Receives `(input, ctx)`. Terminates the builder. | Required |\n\n## Naming and the MCP tool list\n\nThe MCP gateway registers every action as an MCP tool under the name `<app.id>__<action.name>`. For `app.id = \"shop\"` and `action = \"searchProducts\"` the agent sees `shop__searchProducts`. Multiple apps can coexist - see [multi-app coexistence](/protocol/security/#multi-app-coexistence).\n\n## Invocation wire format\n\nRequest from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"method\": \"actions/invoke\",\n \"params\": {\n \"name\": \"addItem\",\n \"invocationId\": \"inv_abc123\",\n \"input\": { \"sku\": \"SKU-1\", \"quantity\": 2 },\n \"client\": { \"route\": \"/cart\" }\n }\n}\n```\n\nThe SDK turns `params` into an `ActionContext` and calls your handler. `ctx.agent`, `ctx.agentCapabilities`, and the rest of `ctx.client` come from the `welcome` the SDK cached at handshake time - they don't ride on every `actions/invoke`. Response:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"result\": { \"cartId\": \"c_1\", \"itemId\": \"i_42\" }\n}\n```\n\nOr, on error:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"error\": { \"code\": -32005, \"message\": \"Cart is locked\", \"data\": { \"cartId\": \"c_1\" } }\n}\n```\n\n## Validation passes\n\n- **Input** is validated **before** the handler runs. Failure → error code `-32004 InputValidation`, handler never fires. Validation `issues` are returned in `error.data`.\n- **Output** is **not** validated by default. Call `.strictOutput()` to enforce - failure becomes `-32005 HandlerError` with issues in `data`. The permissive default is deliberate: output schemas are often loose, and most teams use `.output()` purely for documentation.\n\n## Annotations\n\n| Field | Meaning |\n|---|---|\n| `readOnly` | The action doesn't mutate state. The agent may parallelise or cache. |\n| `destructive` | The action mutates persistent state. Agents SHOULD surface a confirmation UI. |\n| `requiresConfirmation` | The action MUST NOT be called without explicit user confirmation. |\n\nAnnotations are **advisory**. They ride along with the MCP tool descriptor; honouring them is the agent's job.\n\n## What the handler receives\n\n```ts\nhandler: (input: I, ctx: ActionContext) => O | Promise<O>\n```\n\n`ctx` is the per-invocation context. Full reference in [SDK → Context](/sdk/typescript/context/):\n\n| Field | Purpose |\n|---|---|\n| `ctx.signal` | `AbortSignal` fired on timeout or cancel. |\n| `ctx.agent` | `{ id, name }` of the calling agent. |\n| `ctx.agentCapabilities` | What the agent can do. Gate your sampling / elicit calls on this. |\n| `ctx.client` | `{ origin, route?, userAgent? }`. |\n| `ctx.progress(update)` | Emit an `actions/progress` notification. |\n| `ctx.sample(req)` | Re-enter the agent LLM for a reasoning step. |\n| `ctx.confirm(req)` | Ask the user a yes/no question. Returns `false` when the client can't prompt. |\n| `ctx.elicit(req)` | Ask the user for structured content matching a schema. |\n| `ctx.log({ level, message, meta? })` | Structured log forwarded to MCP logging. |\n\nNext: [progress & cancellation](/protocol/progress-cancellation/)."},{"slug":"protocol/elicitation","title":"Elicitation","description":"Handlers pause to ask the user a question. Two verbs - ctx.confirm for yes/no, ctx.elicit for structured content.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\n**Elicitation** is sampling's human sibling. Instead of the LLM generating the next value, the user is prompted through the agent UI and submits the answer themselves.\n\nTesseron exposes two verbs on `ctx`, mapping onto MCP elicit's two orthogonal return fields (`action`, `content`):\n\n- **`ctx.confirm({ question })`** returns `Promise<boolean>`. For yes/no safety gates. No schema.\n- **`ctx.elicit({ question, schema, jsonSchema? })`** returns `Promise<T | null>`. For structured content.\n\nPick by intent: a destructive-op gate is a confirm; a \"which warehouse?\" is an elicit.\n\n## ctx.confirm - yes/no safety gates\n\n```ts\ntesseron.action('clearCompleted')\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (_input, ctx) => {\n const ok = await ctx.confirm({\n question: 'Remove 5 completed todos? This cannot be undone.',\n });\n if (!ok) return { removed: 0, cancelled: true };\n // ... proceed\n });\n```\n\nReturns `true` only on explicit accept. Decline, cancel, and absence of elicitation capability all collapse to `false` - the safe default for destructive ops. You don't need to guard with `ctx.agentCapabilities.elicitation`; `confirm` returns `false` when the client can't prompt.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties JSON Schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render a pure Accept/Decline prompt with no input field.\n\n## ctx.elicit - structured content\n\n```ts\nimport { z } from 'zod';\n\nconst warehouseSchema = z.object({ warehouseId: z.string() });\n\ntesseron.action('checkStock')\n .handler(async (_input, ctx) => {\n const answer = await ctx.elicit({\n question: 'Which warehouse should I check?',\n schema: warehouseSchema,\n jsonSchema: z.toJSONSchema(warehouseSchema),\n });\n if (answer === null) return { cancelled: true };\n return stock.lookup(answer.warehouseId);\n });\n```\n\nReturns the validated value on accept, `null` on decline or cancel. Throws `ElicitationNotAvailableError` (code `-32007`) when the client didn't advertise elicitation - structured data has no safe default, so the handler must branch explicitly.\n\n`jsonSchema` is technically optional; if you omit it, the SDK sends a permissive text-only fallback (`{ response: string }`), which Claude Code renders as a single text input. For good UX, always derive it from your validator - Zod 4 has `z.toJSONSchema(schema)` built in.\n\nMCP elicit constrains `requestedSchema`:\n\n- Top level must be `{ type: \"object\" }`.\n- Each property must be a primitive type (`string`, `number`, `integer`, `boolean`).\n- No `oneOf` / `anyOf` / `allOf` / `not` at the top level.\n\nThe SDK enforces this on send and surfaces an `InvalidParams` error (code `-32602`) at the `ctx.elicit` call site if you send something else.\n\n<Sequence\n caption=\"ctx.confirm and ctx.elicit share the same wire flow - the difference is the requestedSchema they send.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.confirm / ctx.elicit', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'u', label: 'USER', icon: 'user' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'elicitation/request { question, schema }', accent: true },\n { from: 'g', to: 'a', label: 'MCP elicitation/elicit', accent: true },\n { from: 'a', to: 'u', label: 'shows form or Accept/Decline', style: 'dashed' },\n { from: 'u', to: 'a', label: 'submits or declines', style: 'dashed' },\n { from: 'a', to: 'g', label: 'elicitation result', accent: true },\n { from: 'g', to: 's', label: '{ action, value? }', accent: true },\n ]}\n/>\n\n## Wire format\n\nRequest:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"method\": \"elicitation/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"question\": \"Which warehouse should I check?\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": { \"warehouseId\": { \"type\": \"string\" } },\n \"required\": [\"warehouseId\"]\n }\n }\n}\n```\n\nResponse (accept):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"result\": { \"action\": \"accept\", \"value\": { \"warehouseId\": \"WH-7\" } }\n}\n```\n\nResponse (decline / cancel):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 11, \"result\": { \"action\": \"decline\" } }\n```\n\nThe SDK maps `action: 'accept'` to the validated value, `decline` / `cancel` to `null` (for `ctx.elicit`) or `false` (for `ctx.confirm`).\n\n## Capability gate\n\n`ctx.agentCapabilities.elicitation` reflects what the connected MCP client advertised during `initialize`. Claude Code advertises elicitation; earlier clients may not.\n\n- `ctx.confirm` is safe in any handler: missing capability returns `false`, which destructive-op guards treat correctly.\n- `ctx.elicit` throws `ElicitationNotAvailableError` when capability is missing - catch it or pre-check the flag and provide a non-interactive fallback.\n\n## Design hints\n\n- **One question per call.** Don't pack a wizard into a schema - chain actions instead.\n- **Use annotations in tandem.** `{ destructive: true, requiresConfirmation: true }` tells the agent to warn upfront; `ctx.confirm` is what gates.\n- **Avoid chained elicitations** in one handler - latency accumulates. If you need multi-step input, build a dedicated action per step.\n\nNext: [resources](/protocol/resources/) - state the agent can read and subscribe to.\n","bodyText":"**Elicitation** is sampling's human sibling. Instead of the LLM generating the next value, the user is prompted through the agent UI and submits the answer themselves.\n\nTesseron exposes two verbs on `ctx`, mapping onto MCP elicit's two orthogonal return fields (`action`, `content`):\n\n- **`ctx.confirm({ question })`** returns `Promise<boolean>`. For yes/no safety gates. No schema.\n- **`ctx.elicit({ question, schema, jsonSchema? })`** returns `Promise<T | null>`. For structured content.\n\nPick by intent: a destructive-op gate is a confirm; a \"which warehouse?\" is an elicit.\n\n## ctx.confirm - yes/no safety gates\n\n```ts\ntesseron.action('clearCompleted')\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (_input, ctx) => {\n const ok = await ctx.confirm({\n question: 'Remove 5 completed todos? This cannot be undone.',\n });\n if (!ok) return { removed: 0, cancelled: true };\n // ... proceed\n });\n```\n\nReturns `true` only on explicit accept. Decline, cancel, and absence of elicitation capability all collapse to `false` - the safe default for destructive ops. You don't need to guard with `ctx.agentCapabilities.elicitation`; `confirm` returns `false` when the client can't prompt.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties JSON Schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render a pure Accept/Decline prompt with no input field.\n\n## ctx.elicit - structured content\n\n```ts\n\nconst warehouseSchema = z.object({ warehouseId: z.string() });\n\ntesseron.action('checkStock')\n .handler(async (_input, ctx) => {\n const answer = await ctx.elicit({\n question: 'Which warehouse should I check?',\n schema: warehouseSchema,\n jsonSchema: z.toJSONSchema(warehouseSchema),\n });\n if (answer === null) return { cancelled: true };\n return stock.lookup(answer.warehouseId);\n });\n```\n\nReturns the validated value on accept, `null` on decline or cancel. Throws `ElicitationNotAvailableError` (code `-32007`) when the client didn't advertise elicitation - structured data has no safe default, so the handler must branch explicitly.\n\n`jsonSchema` is technically optional; if you omit it, the SDK sends a permissive text-only fallback (`{ response: string }`), which Claude Code renders as a single text input. For good UX, always derive it from your validator - Zod 4 has `z.toJSONSchema(schema)` built in.\n\nMCP elicit constrains `requestedSchema`:\n\n- Top level must be `{ type: \"object\" }`.\n- Each property must be a primitive type (`string`, `number`, `integer`, `boolean`).\n- No `oneOf` / `anyOf` / `allOf` / `not` at the top level.\n\nThe SDK enforces this on send and surfaces an `InvalidParams` error (code `-32602`) at the `ctx.elicit` call site if you send something else.\n\n## Wire format\n\nRequest:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"method\": \"elicitation/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"question\": \"Which warehouse should I check?\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": { \"warehouseId\": { \"type\": \"string\" } },\n \"required\": [\"warehouseId\"]\n }\n }\n}\n```\n\nResponse (accept):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"result\": { \"action\": \"accept\", \"value\": { \"warehouseId\": \"WH-7\" } }\n}\n```\n\nResponse (decline / cancel):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 11, \"result\": { \"action\": \"decline\" } }\n```\n\nThe SDK maps `action: 'accept'` to the validated value, `decline` / `cancel` to `null` (for `ctx.elicit`) or `false` (for `ctx.confirm`).\n\n## Capability gate\n\n`ctx.agentCapabilities.elicitation` reflects what the connected MCP client advertised during `initialize`. Claude Code advertises elicitation; earlier clients may not.\n\n- `ctx.confirm` is safe in any handler: missing capability returns `false`, which destructive-op guards treat correctly.\n- `ctx.elicit` throws `ElicitationNotAvailableError` when capability is missing - catch it or pre-check the flag and provide a non-interactive fallback.\n\n## Design hints\n\n- **One question per call.** Don't pack a wizard into a schema - chain actions instead.\n- **Use annotations in tandem.** `{ destructive: true, requiresConfirmation: true }` tells the agent to warn upfront; `ctx.confirm` is what gates.\n- **Avoid chained elicitations** in one handler - latency accumulates. If you need multi-step input, build a dedicated action per step.\n\nNext: [resources](/protocol/resources/) - state the agent can read and subscribe to."},{"slug":"protocol/errors","title":"Errors & capabilities","description":"Every error code Tesseron defines, what raises each one, and how capability negotiation shapes handler behaviour.","section":"protocol","related":["protocol/wire-format","protocol/handshake"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nTesseron uses JSON-RPC error codes with a Tesseron-specific extension range.\n\n<Sequence\n caption=\"A validation error path. The handler never runs; the agent gets structured issues it can correct.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call arguments: { query: 42 }' },\n { from: 'g', to: 's', label: 'actions/invoke' },\n { note: 's', label: 'validate input (Standard Schema)', variant: 'danger' },\n { from: 's', to: 'g', label: 'error -32004 InputValidation data: [issues]', danger: true, style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call error (agent can retry with corrected args)', danger: true, style: 'dashed' },\n ]}\n/>\n\n## Error catalog\n\n| Code | Name | Raised when |\n|---:|---|---|\n| `-32700` | `ParseError` | JSON-RPC message failed to parse. Almost always a bug or a non-JSON frame. |\n| `-32600` | `InvalidRequest` | Envelope is well-formed JSON but not a valid JSON-RPC request. |\n| `-32601` | `MethodNotFound` | Method isn't registered. Tesseron's method surface is fixed - this is almost always a version mismatch. |\n| `-32602` | `InvalidParams` | Params don't match the method's expected shape. |\n| `-32603` | `InternalError` | Unhandled exception inside the SDK or gateway. Report it. |\n| `-32000` | `ProtocolMismatch` | `tesseron/hello` sent a `protocolVersion` the gateway doesn't accept. |\n| `-32001` | `Cancelled` | Invocation was cancelled by the agent. |\n| `-32002` | `Timeout` | Invocation exceeded its timeout. |\n| `-32003` | `ActionNotFound` | Agent called an action that isn't registered for this session. |\n| `-32004` | `InputValidation` | Input failed Standard Schema validation. Issues in `error.data`. |\n| `-32005` | `HandlerError` | Handler threw, or output failed strict validation. Message comes from the thrown error. |\n| `-32006` | `SamplingNotAvailable` | Handler called `ctx.sample` but agent didn't advertise sampling. |\n| `-32007` | `ElicitationNotAvailable` | Handler called `ctx.elicit` but agent didn't advertise elicitation. (`ctx.confirm` returns `false` instead of throwing — safe default for destructive gates.) |\n| `-32008` | `SamplingDepthExceeded` | Sampling chain exceeded `maxSamplingDepth` (3). |\n| `-32009` | `Unauthorized` | Wrong claim code, unclaimed session invoking action, or origin not allowlisted. |\n\nErrors carry an optional `data` field. Tesseron uses it to attach:\n\n- **For `-32004` InputValidation**: the `issues` array from Standard Schema.\n- **For `-32005` HandlerError** with strict output: the `issues` for the failed output check.\n- **For `-32008` SamplingDepthExceeded**: `{ depth, max }`.\n\n## Capability negotiation\n\nBoth sides declare capabilities during the handshake. The `welcome` response contains their intersection - that's what your handler should trust.\n\n| Capability | Meaning |\n|---|---|\n| `streaming` | `actions/progress` notifications are allowed. |\n| `subscriptions` | Agent will call `resources/subscribe`. |\n| `sampling` | `ctx.sample` is available. |\n| `elicitation` | `ctx.confirm` and `ctx.elicit` are available. |\n\nYour handler, in general:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n return fallbackResponse();\n}\nconst refined = await ctx.sample({ prompt, schema });\n```\n\nIf you'd rather error out than fall back, just call `ctx.sample` unconditionally - the SDK throws `SamplingNotAvailableError` (code `-32006`) which the agent sees as a structured tool failure.\n\n## Errors are data, not disasters\n\nAgents are good at recovering from structured errors. Prefer returning a well-typed error to throwing a vague one:\n\n- Bad input? Let Standard Schema reject it - the agent gets issues to correct.\n- Impossible state? `throw new Error(\"Cart is locked; ask the user to unlock it\")` surfaces as code `-32005` with a useful message.\n- Need clarification? Use [elicitation](/protocol/elicitation/) instead of failing.\n\nNext: [lifecycle & failure modes](/protocol/lifecycle/).\n","bodyText":"Tesseron uses JSON-RPC error codes with a Tesseron-specific extension range.\n\n## Error catalog\n\n| Code | Name | Raised when |\n|---:|---|---|\n| `-32700` | `ParseError` | JSON-RPC message failed to parse. Almost always a bug or a non-JSON frame. |\n| `-32600` | `InvalidRequest` | Envelope is well-formed JSON but not a valid JSON-RPC request. |\n| `-32601` | `MethodNotFound` | Method isn't registered. Tesseron's method surface is fixed - this is almost always a version mismatch. |\n| `-32602` | `InvalidParams` | Params don't match the method's expected shape. |\n| `-32603` | `InternalError` | Unhandled exception inside the SDK or gateway. Report it. |\n| `-32000` | `ProtocolMismatch` | `tesseron/hello` sent a `protocolVersion` the gateway doesn't accept. |\n| `-32001` | `Cancelled` | Invocation was cancelled by the agent. |\n| `-32002` | `Timeout` | Invocation exceeded its timeout. |\n| `-32003` | `ActionNotFound` | Agent called an action that isn't registered for this session. |\n| `-32004` | `InputValidation` | Input failed Standard Schema validation. Issues in `error.data`. |\n| `-32005` | `HandlerError` | Handler threw, or output failed strict validation. Message comes from the thrown error. |\n| `-32006` | `SamplingNotAvailable` | Handler called `ctx.sample` but agent didn't advertise sampling. |\n| `-32007` | `ElicitationNotAvailable` | Handler called `ctx.elicit` but agent didn't advertise elicitation. (`ctx.confirm` returns `false` instead of throwing — safe default for destructive gates.) |\n| `-32008` | `SamplingDepthExceeded` | Sampling chain exceeded `maxSamplingDepth` (3). |\n| `-32009` | `Unauthorized` | Wrong claim code, unclaimed session invoking action, or origin not allowlisted. |\n\nErrors carry an optional `data` field. Tesseron uses it to attach:\n\n- **For `-32004` InputValidation**: the `issues` array from Standard Schema.\n- **For `-32005` HandlerError** with strict output: the `issues` for the failed output check.\n- **For `-32008` SamplingDepthExceeded**: `{ depth, max }`.\n\n## Capability negotiation\n\nBoth sides declare capabilities during the handshake. The `welcome` response contains their intersection - that's what your handler should trust.\n\n| Capability | Meaning |\n|---|---|\n| `streaming` | `actions/progress` notifications are allowed. |\n| `subscriptions` | Agent will call `resources/subscribe`. |\n| `sampling` | `ctx.sample` is available. |\n| `elicitation` | `ctx.confirm` and `ctx.elicit` are available. |\n\nYour handler, in general:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n return fallbackResponse();\n}\nconst refined = await ctx.sample({ prompt, schema });\n```\n\nIf you'd rather error out than fall back, just call `ctx.sample` unconditionally - the SDK throws `SamplingNotAvailableError` (code `-32006`) which the agent sees as a structured tool failure.\n\n## Errors are data, not disasters\n\nAgents are good at recovering from structured errors. Prefer returning a well-typed error to throwing a vague one:\n\n- Bad input? Let Standard Schema reject it - the agent gets issues to correct.\n- Impossible state? `throw new Error(\"Cart is locked; ask the user to unlock it\")` surfaces as code `-32005` with a useful message.\n- Need clarification? Use [elicitation](/protocol/elicitation/) instead of failing.\n\nNext: [lifecycle & failure modes](/protocol/lifecycle/)."},{"slug":"protocol/handshake","title":"Handshake & claiming","description":"How a WebSocket becomes a bound session - tesseron/hello, welcome, claim code, and tools/list_changed.","section":"protocol","related":["protocol/wire-format","protocol/transport","protocol/security","protocol/lifecycle"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nA Tesseron session goes through three states: **connected**, **awaiting claim**, **claimed**. Only claimed sessions can have their actions invoked.\n\n<Sequence\n caption=\"From page load to first tool call.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 'app', label: 'WEB APP', sub: '@tesseron/web', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: '@tesseron/mcp', icon: 'bridge', variant: 'accent' },\n { id: 'u', label: 'USER', icon: 'user' },\n { id: 'a', label: 'AGENT', sub: 'Claude Code', icon: 'agent' },\n ]}\n steps={[\n { from: 'app', to: 'gw', label: 'tesseron/hello { app, actions, resources, caps }' },\n { from: 'gw', to: 'app', label: \"tesseron/welcome { sessionId, claimCode: 'AB3X-7K' }\", style: 'dashed' },\n { from: 'gw', to: 'u', label: 'claim code (web UI or stdout)', style: 'dashed' },\n { from: 'u', to: 'a', label: 'connect AB3X-7K' },\n { from: 'a', to: 'gw', label: 'tools/call tesseron__claim_session', accent: true },\n { from: 'gw', to: 'a', label: 'notifications/tools/list_changed', style: 'dashed' },\n { from: 'a', to: 'gw', label: 'tools/call shop__searchProducts' },\n { from: 'gw', to: 'app', label: 'actions/invoke { invocationId, input }' },\n { from: 'app', to: 'gw', label: 'result', style: 'dashed' },\n { from: 'gw', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## The `tesseron/hello` request\n\nSent by the app right after the WebSocket opens.\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/hello\",\n \"params\": {\n \"protocolVersion\": \"1.0.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.0.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- 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## Why out-of-band claim?\n\nBecause in-band claim is just security theatre over localhost. Anything running on the user's machine can open a WebSocket to `:7475`. The claim code is a **user-typed confirmation** - proof that a human authorised this specific browser tab to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).\n\n## 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.0.0; SDK sent 2.0.0. Major version mismatch - pin compatible package versions.\" } }\n```\n\nNext: the [action model](/protocol/actions/).\n","bodyText":"A Tesseron session goes through three states: **connected**, **awaiting claim**, **claimed**. Only claimed sessions can have their actions invoked.\n\n## The `tesseron/hello` request\n\nSent by the app right after the WebSocket opens.\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/hello\",\n \"params\": {\n \"protocolVersion\": \"1.0.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.0.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- 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## Why out-of-band claim?\n\nBecause in-band claim is just security theatre over localhost. Anything running on the user's machine can open a WebSocket to `:7475`. The claim code is a **user-typed confirmation** - proof that a human authorised this specific browser tab to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).\n\n## 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.0.0; SDK sent 2.0.0. Major version mismatch - pin compatible package versions.\" } }\n```\n\nNext: the [action model](/protocol/actions/)."},{"slug":"protocol/index","title":"Protocol overview","description":"The Tesseron protocol in one page - wire format, transport, handshake, action model, MCP capabilities, errors, lifecycle.","section":"protocol","related":["protocol/wire-format","protocol/transport","protocol/handshake","protocol/actions","protocol/errors","protocol/lifecycle"],"bodyRaw":"\nimport { Aside, Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Sequence from '../../../components/Sequence.astro';\n\n<Aside type=\"note\" title=\"Spec license\">\nThe Tesseron protocol specification (every page under `docs/protocol/`) is licensed **CC BY 4.0** — independent from the reference implementation. You are free to build a compatible implementation in any language for any purpose, including commercially, with attribution. See [`LICENSE`](https://github.com/BrainBlend-AI/tesseron/blob/main/docs/src/content/docs/protocol/LICENSE) in the protocol directory.\n</Aside>\n\nTesseron speaks **JSON-RPC 2.0 over WebSocket** 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 protocol is at **version `1.0.0`**.\n\n<Sequence\n caption=\"A first-use session, start to finish: from WebSocket open to the first tool-call result returned to the agent.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 'app', label: 'WEB APP', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'u', label: 'USER', icon: 'user' },\n { id: 'a', label: 'AGENT', icon: 'agent' },\n ]}\n steps={[\n { from: 'app', to: 'gw', label: 'tesseron/hello { app, actions, caps }' },\n { from: 'gw', to: 'app', label: 'tesseron/welcome { sessionId, claimCode }', style: 'dashed' },\n { from: 'gw', to: 'u', label: 'claim code (stdout / web UI)', style: 'dashed' },\n { from: 'u', to: 'a', label: 'connect AB3X-7K' },\n { from: 'a', to: 'gw', label: 'tools/call tesseron__claim_session', accent: true },\n { from: 'gw', to: 'a', label: 'notifications/tools/list_changed', style: 'dashed' },\n { from: 'a', to: 'gw', label: 'tools/call shop__searchProducts' },\n { from: 'gw', to: 'app', label: 'actions/invoke { invocationId, input }' },\n { from: 'app', to: 'gw', label: 'result', style: 'dashed' },\n { from: 'gw', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## Read the pages in order\n\n<CardGrid>\n <LinkCard title=\"Wire format (JSON-RPC)\" href=\"./wire-format/\"\n description=\"Envelope shapes, methods, notifications, ID correlation.\" />\n <LinkCard title=\"Transport (WebSocket)\" href=\"./transport/\"\n description=\"URL, framing, origin allowlist, reconnection rules.\" />\n <LinkCard title=\"Handshake & claiming\" href=\"./handshake/\"\n description=\"`tesseron/hello` → `welcome` → claim code → bound session.\" />\n <LinkCard title=\"Action model\" href=\"./actions/\"\n description=\"How actions are declared, invoked, validated, and namespaced.\" />\n <LinkCard title=\"Progress & cancellation\" href=\"./progress-cancellation/\"\n description=\"Streaming updates; `AbortSignal`-based cancellation.\" />\n <LinkCard title=\"Sampling\" href=\"./sampling/\"\n description=\"Handlers re-enter the agent LLM for a reasoning step.\" />\n <LinkCard title=\"Elicitation\" href=\"./elicitation/\"\n description=\"Handlers pause and ask the user a question via the agent UI.\" />\n <LinkCard title=\"Resources\" href=\"./resources/\"\n description=\"Typed, readable, optionally subscribable state projected to the agent.\" />\n <LinkCard title=\"Errors & capabilities\" href=\"./errors/\"\n description=\"Error codes, capability negotiation, not-available paths.\" />\n <LinkCard title=\"Lifecycle & failure modes\" href=\"./lifecycle/\"\n description=\"Disconnect, reconnect, timeout, MCP gateway restart, tab close.\" />\n <LinkCard title=\"Security model\" href=\"./security/\"\n description=\"Origin allowlist, claim codes, multi-app namespacing.\" />\n</CardGrid>\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.0.0` |\n| Default gateway URL | `ws://127.0.0.1:7475` |\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 WebSocket** 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 protocol is at **version `1.0.0`**.\n\n## Read the pages in order\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.0.0` |\n| Default gateway URL | `ws://127.0.0.1:7475` |\n| Default action timeout | `60_000` ms |\n| Max sampling depth | `3` |\n| Tool name pattern | `<app_id>__<action_name>` |\n| Resource URI pattern | `tesseron://<app_id>/<resource_name>` |\n| `app.id` validator | `/^[a-z][a-z0-9_]*$/` |\n\nAll of these are **observable from the wire** - they're not SDK-specific. A Python or Go SDK implementing Tesseron MUST match them exactly."},{"slug":"protocol/lifecycle","title":"Lifecycle & failure modes","description":"The session state machine, and what happens to pending work at every transition.","section":"protocol","related":["protocol/handshake","protocol/resume","protocol/transport"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nEvery WebSocket connection to the MCP gateway produces a session that walks this state machine:\n\n<Diagram\n caption=\"Five states. Only Claimed can run actions. Every terminal transition aborts in-flight work.\"\n nodeWidth={130}\n nodeHeight={100}\n spacing={50}\n pad={44}\n nodes={[\n { id: 'dc', label: 'DISCONNECTED', icon: 'x' },\n { id: 'hs', label: 'HANDSHAKING', icon: 'arrow' },\n { id: 'aw', label: 'AWAITING', sub: 'claim pending', icon: 'lock' },\n { id: 'cl', label: 'CLAIMED', sub: 'tools exposed', icon: 'check', variant: 'accent' },\n { id: 'end', label: 'CLOSED', icon: 'x', variant: 'danger' },\n ]}\n edges={[\n { from: 'dc', to: 'hs', label: 'ws open' },\n { from: 'hs', to: 'aw', label: 'welcome' },\n { from: 'aw', to: 'cl', label: 'claim ok', accent: true },\n { from: 'aw', to: 'end', label: 'timeout', style: 'dashed', danger: true },\n { from: 'cl', to: 'end', label: 'ws close', style: 'dashed', danger: true },\n ]}\n/>\n\n## States\n\n- **Disconnected** - no WebSocket yet. Tooling surface shows no tools for this app.\n- **Handshaking** - WebSocket open, `tesseron/hello` in flight.\n- **Awaiting claim** - `welcome` sent with a `claimCode`. Actions are registered in the gateway but *not* exposed as MCP tools until claim.\n- **Claimed** - agent has submitted a matching claim. Tool list is published. Actions can be invoked.\n- **Closed** - WebSocket closed. Session forgotten by the gateway.\n\n## Transitions\n\n| From → To | Trigger | Side effects |\n|---|---|---|\n| Disconnected → Handshaking | App opens WebSocket. | `tesseron/hello` sent. |\n| Handshaking → Awaiting claim | MCP gateway returns `welcome`. | Claim code generated + printed to gateway stderr. |\n| Awaiting claim → Claimed | Agent calls `tesseron__claim_session` with matching code. | `notifications/tools/list_changed` fires. |\n| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |\n| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; `tools/list_changed` fires so the agent drops stale tools. |\n\nThe TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.\n\n## What pending work does on close\n\nFrom inside your handler, on any Closed transition:\n\n- `ctx.signal.aborted` becomes `true`.\n- `ctx.progress(…)` after close is silently dropped.\n- `ctx.sample(…)` / `ctx.confirm(…)` / `ctx.elicit(…)` in flight reject with `TransportClosedError`.\n- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.\n\n**Handler best practices:**\n\n```ts\n.handler(async (input, ctx) => {\n const abortable = new AbortController();\n ctx.signal.addEventListener('abort', () => abortable.abort());\n try {\n return await longWork(input, { signal: abortable.signal });\n } finally {\n abortable.abort(); // release any resources even on normal exit\n }\n});\n```\n\n## Reconnection doesn't resume - it starts over\n\n`tesseron.connect()` after a disconnect yields a **new** `sessionId` and **new** `claimCode`. The agent must re-claim. In-flight work from the old session is gone.\n\nWhy not resumable? Two reasons:\n\n1. The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.\n2. Claim is meant to be a user-visible act. An invisible reconnection would bypass the \"human-in-the-loop authorisation\" the claim code represents.\n\n## MCP gateway restart\n\nIf the gateway process dies (plugin disabled, Claude Code restart, crash):\n\n- Every app's WebSocket closes with code 1001 (`Going Away`).\n- Every SDK instance aborts in-flight work and rejects pending requests.\n- Apps are free to `connect()` again when the gateway comes back.\n\nA small \"reconnect\" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.\n\nNext: the [security model](/protocol/security/).\n","bodyText":"Every WebSocket connection to the MCP gateway produces a session that walks this state machine:\n\n## States\n\n- **Disconnected** - no WebSocket yet. Tooling surface shows no tools for this app.\n- **Handshaking** - WebSocket open, `tesseron/hello` in flight.\n- **Awaiting claim** - `welcome` sent with a `claimCode`. Actions are registered in the gateway but *not* exposed as MCP tools until claim.\n- **Claimed** - agent has submitted a matching claim. Tool list is published. Actions can be invoked.\n- **Closed** - WebSocket closed. Session forgotten by the gateway.\n\n## Transitions\n\n| From → To | Trigger | Side effects |\n|---|---|---|\n| Disconnected → Handshaking | App opens WebSocket. | `tesseron/hello` sent. |\n| Handshaking → Awaiting claim | MCP gateway returns `welcome`. | Claim code generated + printed to gateway stderr. |\n| Awaiting claim → Claimed | Agent calls `tesseron__claim_session` with matching code. | `notifications/tools/list_changed` fires. |\n| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |\n| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; `tools/list_changed` fires so the agent drops stale tools. |\n\nThe TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.\n\n## What pending work does on close\n\nFrom inside your handler, on any Closed transition:\n\n- `ctx.signal.aborted` becomes `true`.\n- `ctx.progress(…)` after close is silently dropped.\n- `ctx.sample(…)` / `ctx.confirm(…)` / `ctx.elicit(…)` in flight reject with `TransportClosedError`.\n- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.\n\n**Handler best practices:**\n\n```ts\n.handler(async (input, ctx) => {\n const abortable = new AbortController();\n ctx.signal.addEventListener('abort', () => abortable.abort());\n try {\n return await longWork(input, { signal: abortable.signal });\n } finally {\n abortable.abort(); // release any resources even on normal exit\n }\n});\n```\n\n## Reconnection doesn't resume - it starts over\n\n`tesseron.connect()` after a disconnect yields a **new** `sessionId` and **new** `claimCode`. The agent must re-claim. In-flight work from the old session is gone.\n\nWhy not resumable? Two reasons:\n\n1. The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.\n2. Claim is meant to be a user-visible act. An invisible reconnection would bypass the \"human-in-the-loop authorisation\" the claim code represents.\n\n## MCP gateway restart\n\nIf the gateway process dies (plugin disabled, Claude Code restart, crash):\n\n- Every app's WebSocket closes with code 1001 (`Going Away`).\n- Every SDK instance aborts in-flight work and rejects pending requests.\n- Apps are free to `connect()` again when the gateway comes back.\n\nA small \"reconnect\" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.\n\nNext: the [security model](/protocol/security/)."},{"slug":"protocol/progress-cancellation","title":"Progress & cancellation","description":"Streaming updates via `actions/progress` and AbortSignal-based cancellation via `actions/cancel`.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nLong-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.\n\n## Streaming progress\n\n<Sequence\n caption=\"Progress notifications ride along while the handler runs. The agent's UI typically renders them as an animated status line.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call { ... }' },\n { from: 'g', to: 's', label: 'actions/invoke' },\n { from: 's', to: 'g', label: 'actions/progress { percent: 10 }', style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/progress', style: 'dashed', accent: true },\n { from: 's', to: 'g', label: 'actions/progress { percent: 60 }', style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/progress', style: 'dashed', accent: true },\n { from: 's', to: 'g', label: 'result { ... }', style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n```ts\ntesseron.action('importCsv')\n .input(z.object({ url: z.string().url() }))\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url);\n\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('Cancelled');\n ctx.progress({\n message: `${i}/${rows.length}`,\n percent: 5 + Math.floor((i / rows.length) * 90),\n });\n await importBatch(rows.slice(i, i + 100));\n }\n\n return { imported: rows.length };\n });\n```\n\nWire format - sent by the app as a notification (no response):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"message\": \"500/2000\",\n \"percent\": 27,\n \"data\": { \"etaMs\": 14000 }\n }\n}\n```\n\nAll three payload fields (`message`, `percent`, `data`) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP `notifications/progress`; MCP clients render them at their leisure.\n\n**Guideline:** cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.\n\n## Cancellation\n\n<Sequence\n caption=\"The agent cancels. The gateway translates to actions/cancel. The handler sees ctx.signal.aborted.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call { ... }' },\n { from: 'g', to: 's', label: \"actions/invoke { invocationId: 'inv_1' }\" },\n { note: 's', label: 'handler running (reads ctx.signal)' },\n { from: 'a', to: 'g', label: 'cancel invocation', style: 'dashed', danger: true },\n { from: 'g', to: 's', label: \"actions/cancel { invocationId: 'inv_1' }\", style: 'dashed', danger: true },\n { note: 's', label: 'ctx.signal.aborted = true', variant: 'danger' },\n { from: 's', to: 'g', label: 'error -32001 Cancelled', style: 'dashed', danger: true },\n { from: 'g', to: 'a', label: 'tools/call error', style: 'dashed', danger: true },\n ]}\n/>\n\n```ts\ntesseron.action('generateReport')\n .input(...)\n .handler(async (input, ctx) => {\n const rows = await slowQuery(ctx.signal); // pass signal down\n if (ctx.signal.aborted) throw new Cancelled();\n return formatReport(rows);\n });\n```\n\n- `ctx.signal` is a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Pass it to `fetch`, `setTimeout`, database drivers, or anything else that accepts one.\n- Cancellation fires for **two reasons**: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.\n- After abort, the SDK returns an error response with code `-32001 Cancelled` (explicit) or `-32002 Timeout` (timer).\n\nWire format - notification from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/cancel\",\n \"params\": { \"invocationId\": \"inv_abc\" }\n}\n```\n\nThe app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.\n\n## Reading the progress on the agent side\n\nWhen the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as \"best-effort hint\".\n\n## What NOT to use progress for\n\n- Final results. Use the response.\n- Data the next handler needs. Use a return value, a sampling round-trip, or a resource.\n- Error surfacing. Return an error response.\n\nNext: [sampling](/protocol/sampling/) - handlers that call back into the LLM.\n","bodyText":"Long-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.\n\n## Streaming progress\n\n```ts\ntesseron.action('importCsv')\n .input(z.object({ url: z.string().url() }))\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url);\n\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('Cancelled');\n ctx.progress({\n message: `${i}/${rows.length}`,\n percent: 5 + Math.floor((i / rows.length) * 90),\n });\n await importBatch(rows.slice(i, i + 100));\n }\n\n return { imported: rows.length };\n });\n```\n\nWire format - sent by the app as a notification (no response):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"message\": \"500/2000\",\n \"percent\": 27,\n \"data\": { \"etaMs\": 14000 }\n }\n}\n```\n\nAll three payload fields (`message`, `percent`, `data`) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP `notifications/progress`; MCP clients render them at their leisure.\n\n**Guideline:** cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.\n\n## Cancellation\n\n```ts\ntesseron.action('generateReport')\n .input(...)\n .handler(async (input, ctx) => {\n const rows = await slowQuery(ctx.signal); // pass signal down\n if (ctx.signal.aborted) throw new Cancelled();\n return formatReport(rows);\n });\n```\n\n- `ctx.signal` is a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Pass it to `fetch`, `setTimeout`, database drivers, or anything else that accepts one.\n- Cancellation fires for **two reasons**: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.\n- After abort, the SDK returns an error response with code `-32001 Cancelled` (explicit) or `-32002 Timeout` (timer).\n\nWire format - notification from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/cancel\",\n \"params\": { \"invocationId\": \"inv_abc\" }\n}\n```\n\nThe app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.\n\n## Reading the progress on the agent side\n\nWhen the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as \"best-effort hint\".\n\n## What NOT to use progress for\n\n- Final results. Use the response.\n- Data the next handler needs. Use a return value, a sampling round-trip, or a resource.\n- Error surfacing. Return an error response.\n\nNext: [sampling](/protocol/sampling/) - handlers that call back into the LLM."},{"slug":"protocol/resources","title":"Resources","description":"Typed, readable, optionally subscribable state your app projects to the agent.","section":"protocol","related":["sdk/typescript/resources","protocol/wire-format"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nA **resource** is a named piece of app state the agent can read - and optionally subscribe to for push updates. Resources complement actions: actions cause changes, resources expose what changed.\n\n<Sequence\n caption=\"Agent reads once, then subscribes. When the app's value changes, the SDK pushes a resources/updated notification.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'RESOURCE HANDLER', icon: 'database' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'resources/read tesseron://shop/route' },\n { from: 'g', to: 's', label: 'resources/read' },\n { from: 's', to: 'g', label: \"{ value: '/cart' }\", style: 'dashed' },\n { from: 'g', to: 'a', label: 'read result', style: 'dashed' },\n { from: 'a', to: 'g', label: 'resources/subscribe', accent: true },\n { from: 'g', to: 's', label: 'resources/subscribe', accent: true },\n { note: 's', label: 'register emit() callback' },\n { from: 's', to: 'g', label: \"resources/updated { value: '/checkout' }\", style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/resources/updated', style: 'dashed', accent: true },\n ]}\n/>\n\n## Declaration\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is currently viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `.read()` is a one-shot getter. Called on every `resources/read` the agent issues.\n- `.subscribe()` is optional. It registers an emitter; return an unsubscribe function so the SDK can clean up when the agent unsubscribes or the session closes.\n\n## URI convention\n\nResources are exposed to the agent with the URI `tesseron://<app_id>/<resource_name>`. For `app.id = \"shop\"` and `resource = \"currentRoute\"`, the agent sees `tesseron://shop/currentRoute`.\n\n## Reading from clients that don't speak MCP resources\n\nSome MCP clients don't surface `resources/read` to their model. The MCP gateway ships a meta-tool fallback:\n\n- **`tesseron__read_resource`** (`{ app_id, name }`) - returns the resource's current value as a tool-call result. Prefer this over the generic `ReadMcpResourceTool` because the agent doesn't have to know how the MCP server is namespaced on the client (e.g. `plugin:tesseron:tesseron` in Claude Code plugin installs vs. `tesseron` in a raw config).\n\n`tesseron__list_actions` enumerates every claimed session's resources and includes both the preferred `tesseron__read_resource` args and the `ReadMcpResourceTool` fallback.\n\n## Wire format\n\n### Read (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"method\": \"resources/read\", \"params\": { \"name\": \"currentRoute\" } }\n```\n\nResponse:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"result\": { \"value\": \"/checkout\" } }\n```\n\n### Subscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"method\": \"resources/subscribe\", \"params\": { \"name\": \"currentRoute\", \"subscriptionId\": \"sub_1\" } }\n```\n\nResponse is empty - the SDK just acknowledges and now holds the emitter callback.\n\n### Update (app → gateway, notification)\n\nEach time the emitter fires:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub_1\", \"value\": \"/cart\" }\n}\n```\n\nThe gateway forwards this as MCP `notifications/resources/updated` to the agent.\n\n### Unsubscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"method\": \"resources/unsubscribe\", \"params\": { \"subscriptionId\": \"sub_1\" } }\n```\n\nThe SDK calls the unsubscribe function returned by your `.subscribe()` handler.\n\n### List changed (app → gateway, notification)\n\nIf your app registers or removes resources after the initial `tesseron/hello`, the SDK emits `resources/list_changed` with the new manifest. The gateway forwards this as MCP `notifications/resources/list_changed` so agents can refetch the list. `actions/list_changed` follows the same pattern for dynamic action sets.\n\n## Patterns\n\n### Read-only projection\n\n```ts\ntesseron.resource('filterState').read(() => ({\n search: state.search,\n onlyDone: state.onlyDone,\n}));\n```\n\nPerfect for letting the agent reason about \"what's the user currently looking at\" before proposing actions.\n\n### Debounced subscription\n\nDon't emit on every keystroke - the agent can't meaningfully react at that rate.\n\n```ts\ntesseron.resource('search')\n .read(() => state.search)\n .subscribe((emit) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const onChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => emit(state.search), 250);\n };\n state.on('change', onChange);\n return () => { if (timer) clearTimeout(timer); state.off('change', onChange); };\n });\n```\n\n### Large or expensive resources\n\nIf the value is expensive to produce, remember that `.read()` runs every time the agent fetches. Cache inside the handler, or use `.subscribe()` as the source of truth and cache the latest emitted value in-memory.\n\n## Capability gate\n\nSubscriptions require `agentCapabilities.subscriptions`. Reads do not. If the agent can't subscribe, it will only call `resources/read` and your `.subscribe()` handler is never invoked.\n\nNext: the full [error catalog and capability negotiation](/protocol/errors/).\n","bodyText":"A **resource** is a named piece of app state the agent can read - and optionally subscribe to for push updates. Resources complement actions: actions cause changes, resources expose what changed.\n\n## Declaration\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is currently viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `.read()` is a one-shot getter. Called on every `resources/read` the agent issues.\n- `.subscribe()` is optional. It registers an emitter; return an unsubscribe function so the SDK can clean up when the agent unsubscribes or the session closes.\n\n## URI convention\n\nResources are exposed to the agent with the URI `tesseron://<app_id>/<resource_name>`. For `app.id = \"shop\"` and `resource = \"currentRoute\"`, the agent sees `tesseron://shop/currentRoute`.\n\n## Reading from clients that don't speak MCP resources\n\nSome MCP clients don't surface `resources/read` to their model. The MCP gateway ships a meta-tool fallback:\n\n- **`tesseron__read_resource`** (`{ app_id, name }`) - returns the resource's current value as a tool-call result. Prefer this over the generic `ReadMcpResourceTool` because the agent doesn't have to know how the MCP server is namespaced on the client (e.g. `plugin:tesseron:tesseron` in Claude Code plugin installs vs. `tesseron` in a raw config).\n\n`tesseron__list_actions` enumerates every claimed session's resources and includes both the preferred `tesseron__read_resource` args and the `ReadMcpResourceTool` fallback.\n\n## Wire format\n\n### Read (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"method\": \"resources/read\", \"params\": { \"name\": \"currentRoute\" } }\n```\n\nResponse:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"result\": { \"value\": \"/checkout\" } }\n```\n\n### Subscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"method\": \"resources/subscribe\", \"params\": { \"name\": \"currentRoute\", \"subscriptionId\": \"sub_1\" } }\n```\n\nResponse is empty - the SDK just acknowledges and now holds the emitter callback.\n\n### Update (app → gateway, notification)\n\nEach time the emitter fires:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub_1\", \"value\": \"/cart\" }\n}\n```\n\nThe gateway forwards this as MCP `notifications/resources/updated` to the agent.\n\n### Unsubscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"method\": \"resources/unsubscribe\", \"params\": { \"subscriptionId\": \"sub_1\" } }\n```\n\nThe SDK calls the unsubscribe function returned by your `.subscribe()` handler.\n\n### List changed (app → gateway, notification)\n\nIf your app registers or removes resources after the initial `tesseron/hello`, the SDK emits `resources/list_changed` with the new manifest. The gateway forwards this as MCP `notifications/resources/list_changed` so agents can refetch the list. `actions/list_changed` follows the same pattern for dynamic action sets.\n\n## Patterns\n\n### Read-only projection\n\n```ts\ntesseron.resource('filterState').read(() => ({\n search: state.search,\n onlyDone: state.onlyDone,\n}));\n```\n\nPerfect for letting the agent reason about \"what's the user currently looking at\" before proposing actions.\n\n### Debounced subscription\n\nDon't emit on every keystroke - the agent can't meaningfully react at that rate.\n\n```ts\ntesseron.resource('search')\n .read(() => state.search)\n .subscribe((emit) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const onChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => emit(state.search), 250);\n };\n state.on('change', onChange);\n return () => { if (timer) clearTimeout(timer); state.off('change', onChange); };\n });\n```\n\n### Large or expensive resources\n\nIf the value is expensive to produce, remember that `.read()` runs every time the agent fetches. Cache inside the handler, or use `.subscribe()` as the source of truth and cache the latest emitted value in-memory.\n\n## Capability gate\n\nSubscriptions require `agentCapabilities.subscriptions`. Reads do not. If the agent can't subscribe, it will only call `resources/read` and your `.subscribe()` handler is never invoked.\n\nNext: the full [error catalog and capability negotiation](/protocol/errors/)."},{"slug":"protocol/resume","title":"Session resume","description":"How a Tesseron app rejoins a previously-claimed session after a transport drop via tesseron/resume - protocol shape, gateway behaviour, and the 4-line localStorage recipe.","section":"protocol","related":["protocol/handshake","protocol/transport","protocol/wire-format","protocol/lifecycle"],"bodyRaw":"\nA Tesseron session lives in the gateway's memory. When the underlying WebSocket drops (tab refresh, window close, network blip, HMR reload), the session normally goes away and a reconnecting app would have to go through the full `tesseron/hello` + claim-code dance again - even if the user had already paired it.\n\nThe `tesseron/resume` method lets the app rejoin an existing session it paired earlier, skipping the re-claim. Storage of the resume credentials is deliberately **the implementer's responsibility**: different apps have different opinions about where session credentials can live (localStorage, a cookie, an Electron store, the OS keychain), so the SDK exposes the primitive and leaves the choice to you.\n\n## Flow\n\n1. On a fresh `tesseron/hello`, the gateway returns a `resumeToken` in the welcome. Stash it alongside the `sessionId` wherever fits your app.\n2. When the transport drops, the gateway keeps the session's metadata as a \"zombie\" for `resumeTtlMs` (default 90 seconds).\n3. On reconnect, the app sends `tesseron/resume` with `{ sessionId, resumeToken }`. If the token matches (constant-time compare) and the zombie is still within its TTL, the gateway reattaches the fresh socket to the existing session and rotates the token.\n4. The caller persists the **new** `resumeToken` from the resume response.\n\nResume tokens are **one-shot**: every successful resume rotates the token and the previous value stops working. This means the freshest welcome is always the one to persist.\n\n## The `tesseron/resume` request\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/resume\",\n \"params\": {\n \"protocolVersion\": \"1.0.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.0.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 port: 7475,\n resumeTtlMs: 300_000, // 5 minutes (default: 90_000)\n maxZombies: 500, // cap on zombies held simultaneously (default: 100)\n});\n```\n\n- `resumeTtlMs` — how long a closed session is retained as a resumable zombie. Set to `0` to disable resume entirely: closed sessions drop immediately and any reconnect must start fresh.\n- `maxZombies` — ceiling on the in-memory zombie map. When inserting a new zombie would exceed it, the oldest (longest-retained) zombie is evicted to make room. Keeps a connect/disconnect flood from piling up zombies faster than their TTLs expire. Set to `0` to disable resume entirely (same effect as `resumeTtlMs: 0`).\n\n## Idiomatic SDK usage\n\nThe SDK exposes a single `resume` field on `ConnectOptions`. You decide where to stash the token:\n\n```ts\nimport { tesseron } from '@tesseron/web';\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\ntesseron.action('searchProducts').handler(/* ... */);\n\nconst saved = localStorage.getItem('tesseron:shop');\nconst welcome = await tesseron.connect(\n 'ws://127.0.0.1:7475',\n saved ? { resume: JSON.parse(saved) } : undefined,\n);\n\nlocalStorage.setItem('tesseron:shop', JSON.stringify({\n sessionId: welcome.sessionId,\n resumeToken: welcome.resumeToken,\n}));\n```\n\nFour lines. The SDK does not do this for you; `localStorage` is one answer among many. A desktop app might stash the pair in the OS keychain. A server process might put it in a file next to its config. An iframe-embedded app might have CSP reasons not to persist at all.\n\n### Falling back when resume fails\n\n```ts\ntry {\n await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);\n} catch (err) {\n if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {\n localStorage.removeItem('tesseron:shop');\n await tesseron.connect(url); // fresh hello\n } else {\n throw err;\n }\n}\n```\n\n## What resume does **not** do\n\n- It does not replay in-flight actions. An action the agent invoked just before the socket dropped is cancelled on the gateway; the agent sees an error (see [lifecycle](/protocol/lifecycle/)) and can retry at its own layer.\n- It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.\n- It does not persist across a gateway restart. Zombies live in gateway process memory; stopping the gateway evicts them. A fresh `tesseron/hello` is required after any gateway restart.\n- It does not work for sessions that were never claimed. The gateway surfaces `ResumeFailed` with `never claimed` so the SDK can fall back to `tesseron/hello` without ambiguity.\n\n## See also\n\n- [Handshake & claiming](/protocol/handshake/) - the `tesseron/hello` flow resume complements.\n- [Lifecycle & failure modes](/protocol/lifecycle/) - how the gateway behaves during drops, retries, and gateway restarts.\n- [Errors & capabilities](/protocol/errors/) - the full `TesseronErrorCode` table including `ResumeFailed`.\n","bodyText":"A Tesseron session lives in the gateway's memory. When the underlying WebSocket drops (tab refresh, window close, network blip, HMR reload), the session normally goes away and a reconnecting app would have to go through the full `tesseron/hello` + claim-code dance again - even if the user had already paired it.\n\nThe `tesseron/resume` method lets the app rejoin an existing session it paired earlier, skipping the re-claim. Storage of the resume credentials is deliberately **the implementer's responsibility**: different apps have different opinions about where session credentials can live (localStorage, a cookie, an Electron store, the OS keychain), so the SDK exposes the primitive and leaves the choice to you.\n\n## Flow\n\n1. On a fresh `tesseron/hello`, the gateway returns a `resumeToken` in the welcome. Stash it alongside the `sessionId` wherever fits your app.\n2. When the transport drops, the gateway keeps the session's metadata as a \"zombie\" for `resumeTtlMs` (default 90 seconds).\n3. On reconnect, the app sends `tesseron/resume` with `{ sessionId, resumeToken }`. If the token matches (constant-time compare) and the zombie is still within its TTL, the gateway reattaches the fresh socket to the existing session and rotates the token.\n4. The caller persists the **new** `resumeToken` from the resume response.\n\nResume tokens are **one-shot**: every successful resume rotates the token and the previous value stops working. This means the freshest welcome is always the one to persist.\n\n## The `tesseron/resume` request\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/resume\",\n \"params\": {\n \"protocolVersion\": \"1.0.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.0.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 port: 7475,\n resumeTtlMs: 300_000, // 5 minutes (default: 90_000)\n maxZombies: 500, // cap on zombies held simultaneously (default: 100)\n});\n```\n\n- `resumeTtlMs` — how long a closed session is retained as a resumable zombie. Set to `0` to disable resume entirely: closed sessions drop immediately and any reconnect must start fresh.\n- `maxZombies` — ceiling on the in-memory zombie map. When inserting a new zombie would exceed it, the oldest (longest-retained) zombie is evicted to make room. Keeps a connect/disconnect flood from piling up zombies faster than their TTLs expire. Set to `0` to disable resume entirely (same effect as `resumeTtlMs: 0`).\n\n## Idiomatic SDK usage\n\nThe SDK exposes a single `resume` field on `ConnectOptions`. You decide where to stash the token:\n\n```ts\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\ntesseron.action('searchProducts').handler(/* ... */);\n\nconst saved = localStorage.getItem('tesseron:shop');\nconst welcome = await tesseron.connect(\n 'ws://127.0.0.1:7475',\n saved ? { resume: JSON.parse(saved) } : undefined,\n);\n\nlocalStorage.setItem('tesseron:shop', JSON.stringify({\n sessionId: welcome.sessionId,\n resumeToken: welcome.resumeToken,\n}));\n```\n\nFour lines. The SDK does not do this for you; `localStorage` is one answer among many. A desktop app might stash the pair in the OS keychain. A server process might put it in a file next to its config. An iframe-embedded app might have CSP reasons not to persist at all.\n\n### Falling back when resume fails\n\n```ts\ntry {\n await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);\n} catch (err) {\n if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {\n localStorage.removeItem('tesseron:shop');\n await tesseron.connect(url); // fresh hello\n } else {\n throw err;\n }\n}\n```\n\n## What resume does **not** do\n\n- It does not replay in-flight actions. An action the agent invoked just before the socket dropped is cancelled on the gateway; the agent sees an error (see [lifecycle](/protocol/lifecycle/)) and can retry at its own layer.\n- It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.\n- It does not persist across a gateway restart. Zombies live in gateway process memory; stopping the gateway evicts them. A fresh `tesseron/hello` is required after any gateway restart.\n- It does not work for sessions that were never claimed. The gateway surfaces `ResumeFailed` with `never claimed` so the SDK can fall back to `tesseron/hello` without ambiguity.\n\n## See also\n\n- [Handshake & claiming](/protocol/handshake/) - the `tesseron/hello` flow resume complements.\n- [Lifecycle & failure modes](/protocol/lifecycle/) - how the gateway behaves during drops, retries, and gateway restarts.\n- [Errors & capabilities](/protocol/errors/) - the full `TesseronErrorCode` table including `ResumeFailed`."},{"slug":"protocol/sampling","title":"Sampling","description":"How a handler re-enters the agent's LLM for a reasoning step, and what the schema contract looks like.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\n**Sampling** lets an action handler ask the agent's LLM to produce a response mid-handler. The LLM is the agent's - not your own - so sampling doesn't require an API key from your side, and it counts against the user's agent budget.\n\n<Sequence\n caption=\"The handler re-enters the agent loop. The agent's LLM replies. The handler then validates the result against your schema.\"\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.sample(...)', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT + LLM', icon: 'agent' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'sampling/request { prompt, schema, maxTokens }' },\n { from: 'g', to: 'a', label: 'MCP sampling/createMessage' },\n { note: 'a', label: 'LLM generates response' },\n { from: 'a', to: 'g', label: 'sampling result', style: 'dashed' },\n { from: 'g', to: 's', label: '{ content }', style: 'dashed' },\n { note: 's', label: 'validate against schema -> parsed value', variant: 'accent' },\n ]}\n/>\n\n## When to use it\n\n- **Natural-language reformatting** - turn a list of rows into a readable summary.\n- **Classification** - given a free-text comment, pick a category from an enum.\n- **Structured extraction** - pull the fields your action needs out of a fuzzy input.\n\nDon't use sampling for:\n\n- Raw chatbot replies. Your action should have a clear return type.\n- Very long generations. Sampling is subject to depth limits (max 3 by default) and counts against the agent budget - keep it targeted.\n\n## Calling sample\n\n```ts\nimport { z } from 'zod';\n\ntesseron.action('classifyComment')\n .input(z.object({ text: z.string() }))\n .output(z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), confidence: z.number() }))\n .handler(async ({ text }, ctx) => {\n const result = await ctx.sample({\n prompt: `Classify the sentiment of this comment: \"\"\"${text}\"\"\"`,\n schema: z.object({\n sentiment: z.enum(['positive', 'neutral', 'negative']),\n confidence: z.number().min(0).max(1),\n }),\n maxTokens: 80,\n });\n return result;\n });\n```\n\n## Wire format\n\nRequest, app → gateway:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"method\": \"sampling/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"prompt\": \"Classify the sentiment …\",\n \"schema\": { \"type\": \"object\", \"properties\": { \"sentiment\": { \"enum\": [\"positive\", \"neutral\", \"negative\"] }, \"confidence\": { \"type\": \"number\" } } },\n \"maxTokens\": 80\n }\n}\n```\n\nResponse, gateway → app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"result\": { \"content\": { \"sentiment\": \"positive\", \"confidence\": 0.82 } }\n}\n```\n\nIf you passed a `schema`, the SDK validates `result.content` against it before returning from `ctx.sample`. If the model's response doesn't parse, you get a validation error and can retry.\n\n## Depth limit\n\nSampling is recursive by construction: the agent is a Claude session that called your action, and you're asking that same Claude to think again. Without a cap, a malicious or buggy chain could spiral.\n\nThe MCP gateway enforces `maxSamplingDepth = 3`. Each request from a handler that was itself invoked via sampling increments the counter. Exceeded → error `-32008 SamplingDepthExceeded`.\n\n## Capability gate\n\nNot every MCP client supports sampling. Before calling `ctx.sample`, check the capability:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n // Fall back: return something useful without the LLM.\n}\nconst result = await ctx.sample({ /* ... */ });\n```\n\nOr let the SDK throw `SamplingNotAvailableError` (error code `-32006`) and catch it. Pick whichever fits your UX.\n\n### Client compatibility\n\nSampling only works when the connected MCP client advertises `capabilities.sampling` during the MCP `initialize` handshake. Tesseron captures the client's capabilities at that point and flows them to every SDK session as `ctx.agentCapabilities.sampling` — so a handler always sees the real answer, even when a particular client (for example, Claude Code as of this writing) has not yet implemented `sampling/createMessage`. If a handler calls `ctx.sample()` anyway on such a client, the SDK throws a structured `SamplingNotAvailableError` including the client name (when available) instead of a raw JSON-RPC `-32601 Method not found`, so callers can branch on `error instanceof SamplingNotAvailableError` and return a graceful fallback.\n\nNext: [elicitation](/protocol/elicitation/) - same shape, but with the user instead of the model.\n","bodyText":"**Sampling** lets an action handler ask the agent's LLM to produce a response mid-handler. The LLM is the agent's - not your own - so sampling doesn't require an API key from your side, and it counts against the user's agent budget.\n\n<Sequence\n caption=\"The handler re-enters the agent loop. The agent's LLM replies. The handler then validates the result against your schema.\"\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.sample(...)', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT + LLM', icon: 'agent' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'sampling/request { prompt, schema, maxTokens }' },\n { from: 'g', to: 'a', label: 'MCP sampling/createMessage' },\n { note: 'a', label: 'LLM generates response' },\n { from: 'a', to: 'g', label: 'sampling result', style: 'dashed' },\n { from: 'g', to: 's', label: '{ content }', style: 'dashed' },\n { note: 's', label: 'validate against schema -> parsed value', variant: 'accent' },\n ]}\n/>\n\n## When to use it\n\n- **Natural-language reformatting** - turn a list of rows into a readable summary.\n- **Classification** - given a free-text comment, pick a category from an enum.\n- **Structured extraction** - pull the fields your action needs out of a fuzzy input.\n\nDon't use sampling for:\n\n- Raw chatbot replies. Your action should have a clear return type.\n- Very long generations. Sampling is subject to depth limits (max 3 by default) and counts against the agent budget - keep it targeted.\n\n## Calling sample\n\n```ts\n\ntesseron.action('classifyComment')\n .input(z.object({ text: z.string() }))\n .output(z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), confidence: z.number() }))\n .handler(async ({ text }, ctx) => {\n const result = await ctx.sample({\n prompt: `Classify the sentiment of this comment: \"\"\"${text}\"\"\"`,\n schema: z.object({\n sentiment: z.enum(['positive', 'neutral', 'negative']),\n confidence: z.number().min(0).max(1),\n }),\n maxTokens: 80,\n });\n return result;\n });\n```\n\n## Wire format\n\nRequest, app → gateway:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"method\": \"sampling/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"prompt\": \"Classify the sentiment …\",\n \"schema\": { \"type\": \"object\", \"properties\": { \"sentiment\": { \"enum\": [\"positive\", \"neutral\", \"negative\"] }, \"confidence\": { \"type\": \"number\" } } },\n \"maxTokens\": 80\n }\n}\n```\n\nResponse, gateway → app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"result\": { \"content\": { \"sentiment\": \"positive\", \"confidence\": 0.82 } }\n}\n```\n\nIf you passed a `schema`, the SDK validates `result.content` against it before returning from `ctx.sample`. If the model's response doesn't parse, you get a validation error and can retry.\n\n## Depth limit\n\nSampling is recursive by construction: the agent is a Claude session that called your action, and you're asking that same Claude to think again. Without a cap, a malicious or buggy chain could spiral.\n\nThe MCP gateway enforces `maxSamplingDepth = 3`. Each request from a handler that was itself invoked via sampling increments the counter. Exceeded → error `-32008 SamplingDepthExceeded`.\n\n## Capability gate\n\nNot every MCP client supports sampling. Before calling `ctx.sample`, check the capability:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n // Fall back: return something useful without the LLM.\n}\nconst result = await ctx.sample({ /* ... */ });\n```\n\nOr let the SDK throw `SamplingNotAvailableError` (error code `-32006`) and catch it. Pick whichever fits your UX.\n\n### Client compatibility\n\nSampling only works when the connected MCP client advertises `capabilities.sampling` during the MCP `initialize` handshake. Tesseron captures the client's capabilities at that point and flows them to every SDK session as `ctx.agentCapabilities.sampling` — so a handler always sees the real answer, even when a particular client (for example, Claude Code as of this writing) has not yet implemented `sampling/createMessage`. If a handler calls `ctx.sample()` anyway on such a client, the SDK throws a structured `SamplingNotAvailableError` including the client name (when available) instead of a raw JSON-RPC `-32601 Method not found`, so callers can branch on `error instanceof SamplingNotAvailableError` and return a graceful fallback.\n\nNext: [elicitation](/protocol/elicitation/) - same shape, but with the user instead of the model."},{"slug":"protocol/security","title":"Security model","description":"Origin allowlist, claim codes, multi-app namespacing, and the threats Tesseron does and does not defend against.","section":"protocol","related":["protocol/handshake","protocol/transport"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nTesseron's security model is **local-first, user-authorised**. The MCP gateway binds to localhost and won't expose any action until a human types a short code out-of-band. These are the two gates.\n\n## Gate 1 - origin allowlist\n\n<Diagram\n caption=\"The MCP gateway inspects Origin at the upgrade handshake. Non-localhost origins are rejected unless explicitly allowed.\"\n nodeWidth={200}\n spacing={80}\n nodes={[\n { id: 'app', label: 'YOUR APP', sub: 'http://localhost:3000', icon: 'window' },\n { id: 'evil', label: 'ATTACKER', sub: 'https://evil.com', icon: 'x', variant: 'danger' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'verifyClient()', icon: 'shield', variant: 'accent' },\n { id: 'accept', label: 'ACCEPT', sub: '-> tesseron/hello', icon: 'check' },\n { id: 'reject', label: 'REJECT', sub: '403, close 1008', icon: 'lock', variant: 'danger' },\n ]}\n edges={[\n { from: 'app', to: 'gw', label: 'Origin: localhost' },\n { from: 'evil', to: 'gw', label: 'Origin: evil.com', danger: true },\n { from: 'gw', to: 'accept', label: 'allowlist match', accent: true },\n { from: 'gw', to: 'reject', label: 'otherwise', style: 'dashed', danger: true },\n ]}\n/>\n\nThe WebSocket upgrade is accepted when:\n\n- `Origin` starts with `http://localhost:` or `http://127.0.0.1:`, or\n- `Origin` appears verbatim in `TESSERON_ORIGIN_ALLOWLIST` (comma-separated).\n\nAnything else returns HTTP 403. This is defence-in-depth - it prevents a drive-by page on `evil.com` from spraying `tesseron/hello` messages at your gateway and enumerating your app's surface.\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). 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 can open a WebSocket to `ws://127.0.0.1:7475` and send `tesseron/hello`. The origin check only fires if the client sends an `Origin` header; non-browser clients may not. The claim code is the second gate, and it requires human cooperation.\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- **Never expand `TESSERON_ORIGIN_ALLOWLIST` by default.** Add origins only for specific agent integrations that need them.\n- **Treat the claim code as short-lived.** Don't render it persistently in the UI after the session is claimed.\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 - origin allowlist\n\n<Diagram\n caption=\"The MCP gateway inspects Origin at the upgrade handshake. Non-localhost origins are rejected unless explicitly allowed.\"\n nodeWidth={200}\n spacing={80}\n nodes={[\n { id: 'app', label: 'YOUR APP', sub: 'http://localhost:3000', icon: 'window' },\n { id: 'evil', label: 'ATTACKER', sub: 'https://evil.com', icon: 'x', variant: 'danger' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'verifyClient()', icon: 'shield', variant: 'accent' },\n { id: 'accept', label: 'ACCEPT', sub: '-> tesseron/hello', icon: 'check' },\n { id: 'reject', label: 'REJECT', sub: '403, close 1008', icon: 'lock', variant: 'danger' },\n ]}\n edges={[\n { from: 'app', to: 'gw', label: 'Origin: localhost' },\n { from: 'evil', to: 'gw', label: 'Origin: evil.com', danger: true },\n { from: 'gw', to: 'accept', label: 'allowlist match', accent: true },\n { from: 'gw', to: 'reject', label: 'otherwise', style: 'dashed', danger: true },\n ]}\n/>\n\nThe WebSocket upgrade is accepted when:\n\n- `Origin` starts with `http://localhost:` or `http://127.0.0.1:`, or\n- `Origin` appears verbatim in `TESSERON_ORIGIN_ALLOWLIST` (comma-separated).\n\nAnything else returns HTTP 403. This is defence-in-depth - it prevents a drive-by page on `evil.com` from spraying `tesseron/hello` messages at your gateway and enumerating your app's surface.\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). 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 can open a WebSocket to `ws://127.0.0.1:7475` and send `tesseron/hello`. The origin check only fires if the client sends an `Origin` header; non-browser clients may not. The claim code is the second gate, and it requires human cooperation.\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- **Never expand `TESSERON_ORIGIN_ALLOWLIST` by default.** Add origins only for specific agent integrations that need them.\n- **Treat the claim code as short-lived.** Don't render it persistently in the UI after the session is claimed.\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 (WebSocket)","description":"URL, framing, origin enforcement, reconnection, and what happens to pending work on disconnect.","section":"protocol","related":["protocol/handshake","protocol/wire-format"],"bodyRaw":"\n## Endpoint\n\nDefault gateway URL: `ws://127.0.0.1:7475`.\n\nOverridable via environment:\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `TESSERON_PORT` | `7475` | MCP gateway listen port. |\n| `TESSERON_HOST` | `127.0.0.1` | Listen host. |\n| `TESSERON_ORIGIN_ALLOWLIST` | *(empty)* | Comma-separated extra origins allowed beyond localhost. |\n\nNo subprotocol is negotiated. Standard RFC 6455 `Upgrade: websocket` handshake.\n\n## Origin allowlist\n\nThe MCP gateway verifies the `Origin` header during the upgrade handshake:\n\n- `http://localhost:*` and `http://127.0.0.1:*` - accepted unconditionally.\n- Any origin in `TESSERON_ORIGIN_ALLOWLIST` - accepted.\n- Everything else - `cb(false, 403)` rejects the upgrade.\n\nThis is a defence-in-depth measure, **not** a substitute for the claim code. Both layers must pass before an agent can invoke actions.\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.\n- No fragmentation, no batching, no compression.\n\n## Heartbeat\n\nThere is no application-level ping. The protocol relies on TCP keep-alive and per-action timeouts (60 s default) to detect dead peers.\n\nIf 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.\n\nTo recover: call `tesseron.connect()` again. You will get a **new** `sessionId` and a **new** `claimCode` - the previous claim does not carry over. If your agent is still alive on its side, it must re-claim.\n\nWhy no auto-reconnect? Because a reclaimed session invalidates cached tool lists on the agent. An app-level reconnect lets you coordinate with UI (e.g., surface the new claim code) instead of silently rebinding.\n\n## Failure matrix\n\n| Event | App sees | MCP gateway does | Agent sees |\n|---|---|---|---|\n| MCP gateway shuts down cleanly | `close(1001)` | - | `tools/list_changed` drops those tools. |\n| Tab closes | - | Session removed, in-flight invocations cancelled. | `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| Origin rejected | `close(1008)` before any app message. | Upgrade refused 403. | N/A - never connected. |\n\nNext: the [handshake and claim flow](/protocol/handshake/).\n","bodyText":"## Endpoint\n\nDefault gateway URL: `ws://127.0.0.1:7475`.\n\nOverridable via environment:\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `TESSERON_PORT` | `7475` | MCP gateway listen port. |\n| `TESSERON_HOST` | `127.0.0.1` | Listen host. |\n| `TESSERON_ORIGIN_ALLOWLIST` | *(empty)* | Comma-separated extra origins allowed beyond localhost. |\n\nNo subprotocol is negotiated. Standard RFC 6455 `Upgrade: websocket` handshake.\n\n## Origin allowlist\n\nThe MCP gateway verifies the `Origin` header during the upgrade handshake:\n\n- `http://localhost:*` and `http://127.0.0.1:*` - accepted unconditionally.\n- Any origin in `TESSERON_ORIGIN_ALLOWLIST` - accepted.\n- Everything else - `cb(false, 403)` rejects the upgrade.\n\nThis is a defence-in-depth measure, **not** a substitute for the claim code. Both layers must pass before an agent can invoke actions.\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.\n- No fragmentation, no batching, no compression.\n\n## Heartbeat\n\nThere is no application-level ping. The protocol relies on TCP keep-alive and per-action timeouts (60 s default) to detect dead peers.\n\nIf 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.\n\nTo recover: call `tesseron.connect()` again. You will get a **new** `sessionId` and a **new** `claimCode` - the previous claim does not carry over. If your agent is still alive on its side, it must re-claim.\n\nWhy no auto-reconnect? Because a reclaimed session invalidates cached tool lists on the agent. An app-level reconnect lets you coordinate with UI (e.g., surface the new claim code) instead of silently rebinding.\n\n## Failure matrix\n\n| Event | App sees | MCP gateway does | Agent sees |\n|---|---|---|---|\n| MCP gateway shuts down cleanly | `close(1001)` | - | `tools/list_changed` drops those tools. |\n| Tab closes | - | Session removed, in-flight invocations cancelled. | `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| Origin rejected | `close(1008)` before any app message. | Upgrade refused 403. | N/A - never connected. |\n\nNext: the [handshake and claim flow](/protocol/handshake/)."},{"slug":"protocol/wire-format","title":"Wire format (JSON-RPC)","description":"The envelope shapes Tesseron uses, the full method surface in both directions, and the ID-correlation rules.","section":"protocol","related":["protocol/transport","protocol/handshake","protocol/errors","protocol/actions"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nTesseron's app ↔ MCP gateway hop is **JSON-RPC 2.0**, one JSON object per WebSocket text frame. No batching, no binary, no compression - one message, one parse.\n\n<Diagram\n caption=\"Four envelope shapes. Every message on the wire is exactly one of these.\"\n nodeWidth={190}\n nodeHeight={150}\n spacing={50}\n nodes={[\n { id: 'req', label: 'REQUEST', sub: ['id + method', 'expects response'], code: 'jsonrpc: \"2.0\"', icon: 'arrow' },\n { id: 'ntf', label: 'NOTIFICATION', sub: ['method only', 'fire-and-forget'], code: 'no id', icon: 'arrow' },\n { id: 'ok', label: 'SUCCESS', sub: ['echoes request id', 'carries result'], code: 'result: R', icon: 'check', variant: 'accent' },\n { id: 'err', label: 'ERROR', sub: ['echoes request id', 'carries error object'], code: 'error: {...}', icon: 'x', variant: 'danger' },\n ]}\n edges={[]}\n/>\n\n## Envelope shapes\n\n### Request (expects a response)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"method\": \"actions/invoke\",\n \"params\": { /* method-specific */ }\n}\n```\n\n### Notification (fire-and-forget)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": { \"invocationId\": \"inv_1\", \"percent\": 40 }\n}\n```\n\n### Success response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"result\": { /* method-specific payload */ }\n}\n```\n\n### Error response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"error\": { \"code\": -32004, \"message\": \"Invalid input\", \"data\": [/* issues */] }\n}\n```\n\n`id` can be a string, number, or `null`. The SDK uses monotonically incrementing integers per connection; any JSON-RPC-compliant peer is welcome to do otherwise.\n\n## Method surface\n\n### App → Gateway (you send)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `tesseron/hello` | request | Register app, actions, resources, capabilities. First message. |\n| `actions/progress` | notification | Streaming update during an invocation. |\n| `actions/list_changed` | notification | App (re)registered / removed an action after hello. |\n| `resources/updated` | notification | Push a new value to a subscriber. |\n| `resources/list_changed` | notification | App (re)registered / removed a resource after hello. |\n| `sampling/request` | request | Ask the agent to run an LLM step. |\n| `elicitation/request` | request | Ask the user (confirm or elicit) via the agent UI. |\n| `log` | notification | Structured log forwarded to MCP logging. |\n\nPlus: the **response** for any `actions/invoke`, `resources/read`, `resources/subscribe`, `resources/unsubscribe` the gateway sent you.\n\n### Gateway → App (you handle)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `actions/invoke` | request | Agent called an action. Respond with `result` or `error`. |\n| `actions/cancel` | notification | Agent cancelled an in-flight invocation. |\n| `resources/read` | request | Agent requested current resource value. |\n| `resources/subscribe` | request | Agent subscribed to future updates. |\n| `resources/unsubscribe` | request | Agent unsubscribed. |\n\nAnd the **response** to the `tesseron/hello` you sent.\n\n## ID correlation\n\n- A peer that issues a request assigns the `id`. The other peer echoes the exact same `id` in the response.\n- The SDK keeps a `Map<id, { resolve, reject, timeoutHandle }>` of pending outbound requests. On response it looks up the id, clears the timer, and settles the promise.\n- On transport close, **every pending request is rejected** with `TransportClosedError`. There is no resumable queue; reconnect means re-send.\n- Notifications have no `id` - they never fail visibly and never get a response. Don't send data you care about as a notification.\n\n## Framing\n\n- Each JSON-RPC object is serialized with `JSON.stringify` and sent as **one text frame**.\n- Binary frames sent by the peer are coerced to text and parsed - tolerated but not idiomatic.\n- There is **no length prefix and no framing header**. WebSocket gives us message boundaries for free.\n- There is **no batching**. Every message is self-contained.\n\n## Versioning\n\n`tesseron/hello` includes `protocolVersion: \"1.0.0\"`. The gateway parses it as `major.minor`: a major mismatch is rejected with `-32000 ProtocolMismatch` and the WebSocket is closed, a minor mismatch is accepted with a stderr warning (newer fields may be silently dropped), an exact match is silent. See [Handshake](/protocol/handshake/#protocol-version-mismatch).\n\nNext: how that WebSocket gets established - [Transport](/protocol/transport/).\n","bodyText":"Tesseron's app ↔ MCP gateway hop is **JSON-RPC 2.0**, one JSON object per WebSocket text frame. No batching, no binary, no compression - one message, one parse.\n\n## Envelope shapes\n\n### Request (expects a response)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"method\": \"actions/invoke\",\n \"params\": { /* method-specific */ }\n}\n```\n\n### Notification (fire-and-forget)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": { \"invocationId\": \"inv_1\", \"percent\": 40 }\n}\n```\n\n### Success response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"result\": { /* method-specific payload */ }\n}\n```\n\n### Error response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"error\": { \"code\": -32004, \"message\": \"Invalid input\", \"data\": [/* issues */] }\n}\n```\n\n`id` can be a string, number, or `null`. The SDK uses monotonically incrementing integers per connection; any JSON-RPC-compliant peer is welcome to do otherwise.\n\n## Method surface\n\n### App → Gateway (you send)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `tesseron/hello` | request | Register app, actions, resources, capabilities. First message. |\n| `actions/progress` | notification | Streaming update during an invocation. |\n| `actions/list_changed` | notification | App (re)registered / removed an action after hello. |\n| `resources/updated` | notification | Push a new value to a subscriber. |\n| `resources/list_changed` | notification | App (re)registered / removed a resource after hello. |\n| `sampling/request` | request | Ask the agent to run an LLM step. |\n| `elicitation/request` | request | Ask the user (confirm or elicit) via the agent UI. |\n| `log` | notification | Structured log forwarded to MCP logging. |\n\nPlus: the **response** for any `actions/invoke`, `resources/read`, `resources/subscribe`, `resources/unsubscribe` the gateway sent you.\n\n### Gateway → App (you handle)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `actions/invoke` | request | Agent called an action. Respond with `result` or `error`. |\n| `actions/cancel` | notification | Agent cancelled an in-flight invocation. |\n| `resources/read` | request | Agent requested current resource value. |\n| `resources/subscribe` | request | Agent subscribed to future updates. |\n| `resources/unsubscribe` | request | Agent unsubscribed. |\n\nAnd the **response** to the `tesseron/hello` you sent.\n\n## ID correlation\n\n- A peer that issues a request assigns the `id`. The other peer echoes the exact same `id` in the response.\n- The SDK keeps a `Map<id, { resolve, reject, timeoutHandle }>` of pending outbound requests. On response it looks up the id, clears the timer, and settles the promise.\n- On transport close, **every pending request is rejected** with `TransportClosedError`. There is no resumable queue; reconnect means re-send.\n- Notifications have no `id` - they never fail visibly and never get a response. Don't send data you care about as a notification.\n\n## Framing\n\n- Each JSON-RPC object is serialized with `JSON.stringify` and sent as **one text frame**.\n- Binary frames sent by the peer are coerced to text and parsed - tolerated but not idiomatic.\n- There is **no length prefix and no framing header**. WebSocket gives us message boundaries for free.\n- There is **no batching**. Every message is self-contained.\n\n## Versioning\n\n`tesseron/hello` includes `protocolVersion: \"1.0.0\"`. The gateway parses it as `major.minor`: a major mismatch is rejected with `-32000 ProtocolMismatch` and the WebSocket is closed, a minor mismatch is accepted with a stderr warning (newer fields may be silently dropped), an exact match is silent. See [Handshake](/protocol/handshake/#protocol-version-mismatch).\n\nNext: how that WebSocket gets established - [Transport](/protocol/transport/)."},{"slug":"sdk/index","title":"SDK overview","description":"What a Tesseron SDK has to expose - in TypeScript today, in any other language tomorrow.","section":"sdk","related":["sdk/porting","sdk/typescript/index","protocol/index"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Mermaid from '../../../components/Mermaid.astro';\n\nAn SDK is the part of Tesseron that lives in **your** process. It serialises outgoing JSON-RPC, dispatches incoming method calls into your handlers, and wraps the per-invocation protocol glue (progress, cancel, sample, elicit) in a shape that feels natural in the host language.\n\nToday we ship five TypeScript packages. The surface they expose - the **SDK contract** - is the portable part. A Python or Go implementation reuses the same concepts.\n\n## The shipped TypeScript SDK\n\n<Mermaid\n caption=\"Five packages. core owns the protocol types and builder; the others wrap transport and framework integration.\"\n code={`\nflowchart LR\n core[\"@tesseron/core<br/>action & resource builders<br/>JSON-RPC dispatcher<br/>protocol types\"]\n web[\"@tesseron/web<br/>browser client<br/>WebSocket transport\"]\n server[\"@tesseron/server<br/>Node client<br/>ws transport\"]\n react[\"@tesseron/react<br/>useTesseronAction<br/>useTesseronResource<br/>useTesseronConnection\"]\n mcp[\"@tesseron/mcp<br/>MCP gateway<br/>MCP stdio bridge\"]\n web -- \"re-exports\" --> core\n server -- \"re-exports\" --> core\n react -- \"wraps\" --> web\n mcp -. \"shared types\" .-> core\n`}\n/>\n\n<CardGrid>\n <LinkCard title=\"Quickstart\" href=\"./typescript/\"\n description=\"Install one package, declare one action, connect.\" />\n <LinkCard title=\"@tesseron/core\" href=\"./typescript/core/\"\n description=\"Action & resource builders, JSON-RPC dispatcher, protocol types. Zero runtime deps beyond Standard Schema.\" />\n <LinkCard title=\"@tesseron/web\" href=\"./typescript/web/\"\n description=\"Browser WebSocket transport + singleton client.\" />\n <LinkCard title=\"@tesseron/server\" href=\"./typescript/server/\"\n description=\"Node `ws`-backed transport + singleton client.\" />\n <LinkCard title=\"@tesseron/react\" href=\"./typescript/react/\"\n description=\"`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`.\" />\n <LinkCard title=\"@tesseron/mcp\" href=\"./typescript/mcp/\"\n description=\"The MCP gateway itself. CLI, bundled into the Claude Code plugin.\" />\n</CardGrid>\n\n## The portable SDK contract\n\nWhatever language you implement Tesseron in, the SDK has to expose these primitives. They correspond 1:1 with the [protocol](/protocol/).\n\n| Primitive | In TypeScript | Covers |\n|---|---|---|\n| **Client lifecycle** | `tesseron.app({ id, name, … })` + `tesseron.connect()` | Handshake, session ID, claim code. |\n| **Action builder** | `.action(name).describe(…).input(…).output(…).handler(fn)` | Declaring a named, typed, handler-backed action. |\n| **Resource builder** | `.resource(name).read(fn).subscribe(emitter)` | Declaring readable + optionally subscribable state. |\n| **Standard Schema bridge** | Accepts any `StandardSchemaV1<T>` validator (Zod, Valibot, ArkType, …) | Input / output / sampling / elicitation validation. |\n| **Invocation context** | `(input, ctx)` passed to every handler | `ctx.signal`, `ctx.progress`, `ctx.sample`, `ctx.confirm`, `ctx.elicit`, `ctx.log`, `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`. |\n| **Transport abstraction** | `Transport { send, onMessage, onClose, close }` | WebSocket in practice, but the protocol is transport-agnostic. |\n| **JSON-RPC dispatcher** | `JsonRpcDispatcher` | Request/notification handling, ID correlation, timeout, error mapping. |\n| **Structured error model** | `TesseronError(code, message, data?)` | Mapping to / from JSON-RPC error objects with the error codes in the [catalog](/protocol/errors/). |\n\n## Other SDKs\n\n<CardGrid>\n <LinkCard title=\"Python SDK (planned)\" href=\"./python/\"\n description=\"Status, intended shape, timeline.\" />\n <LinkCard title=\"Port Tesseron to your language\" href=\"./porting/\"\n description=\"Step-by-step guide, protocol conformance checklist, test strategy.\" />\n</CardGrid>\n","bodyText":"An SDK is the part of Tesseron that lives in **your** process. It serialises outgoing JSON-RPC, dispatches incoming method calls into your handlers, and wraps the per-invocation protocol glue (progress, cancel, sample, elicit) in a shape that feels natural in the host language.\n\nToday we ship five TypeScript packages. The surface they expose - the **SDK contract** - is the portable part. A Python or Go implementation reuses the same concepts.\n\n## The shipped TypeScript SDK\n\naction & resource builders<br/>JSON-RPC dispatcher<br/>protocol types\"]\n web[\"@tesseron/web<br/>browser client<br/>WebSocket transport\"]\n server[\"@tesseron/server<br/>Node client<br/>ws transport\"]\n react[\"@tesseron/react<br/>useTesseronAction<br/>useTesseronResource<br/>useTesseronConnection\"]\n mcp[\"@tesseron/mcp<br/>MCP gateway<br/>MCP stdio bridge\"]\n web -- \"re-exports\" --> core\n server -- \"re-exports\" --> core\n react -- \"wraps\" --> web\n mcp -. \"shared types\" .-> core\n`}\n/>\n\n## The portable SDK contract\n\nWhatever language you implement Tesseron in, the SDK has to expose these primitives. They correspond 1:1 with the [protocol](/protocol/).\n\n| Primitive | In TypeScript | Covers |\n|---|---|---|\n| **Client lifecycle** | `tesseron.app({ id, name, … })` + `tesseron.connect()` | Handshake, session ID, claim code. |\n| **Action builder** | `.action(name).describe(…).input(…).output(…).handler(fn)` | Declaring a named, typed, handler-backed action. |\n| **Resource builder** | `.resource(name).read(fn).subscribe(emitter)` | Declaring readable + optionally subscribable state. |\n| **Standard Schema bridge** | Accepts any `StandardSchemaV1<T>` validator (Zod, Valibot, ArkType, …) | Input / output / sampling / elicitation validation. |\n| **Invocation context** | `(input, ctx)` passed to every handler | `ctx.signal`, `ctx.progress`, `ctx.sample`, `ctx.confirm`, `ctx.elicit`, `ctx.log`, `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`. |\n| **Transport abstraction** | `Transport { send, onMessage, onClose, close }` | WebSocket in practice, but the protocol is transport-agnostic. |\n| **JSON-RPC dispatcher** | `JsonRpcDispatcher` | Request/notification handling, ID correlation, timeout, error mapping. |\n| **Structured error model** | `TesseronError(code, message, data?)` | Mapping to / from JSON-RPC error objects with the error codes in the [catalog](/protocol/errors/). |\n\n## Other SDKs"},{"slug":"sdk/porting","title":"Port Tesseron to your language","description":"Step-by-step guide to writing a new Tesseron SDK and a conformance checklist for testing it.","section":"sdk","related":["sdk/index","protocol/index","protocol/wire-format","sdk/typescript/core"],"bodyRaw":"\nTesseron's wire protocol is small enough that a competent engineer can implement an SDK for a new language in a couple of days. This page is your map.\n\n## What you're actually building\n\nA Tesseron SDK is three things glued together:\n\n1. **A WebSocket client** that speaks JSON-RPC 2.0.\n2. **A builder DSL** for declaring actions and resources.\n3. **A bridge** between the JSON-RPC dispatcher and the builder's registered handlers.\n\nThe full [protocol spec](/protocol/) is the source of truth. If anything on this page contradicts it, the protocol wins.\n\n## Step 1 - pick a runtime model\n\nTwo choices decide the shape of everything that follows.\n\n- **Async model.** Native `async`/`await` / futures / goroutines - whatever your language uses for concurrency. All SDK methods that may block (connect, sample, elicit, progress-waiting action handlers) should be async. Synchronous handlers are fine, but the client's I/O loop must not be.\n- **Schema library.** You need a way for users to express typed inputs. Pick one well-known library (Pydantic in Python, `go-playground/validator` in Go, Serde+schemars in Rust), and accept any user-provided schema that can round-trip to JSON Schema.\n\n## Step 2 - model the protocol types\n\nPort these from the [wire format page](/protocol/wire-format/):\n\n- JSON-RPC 2.0 request / notification / success / error envelopes.\n- `HelloParams`, `WelcomeResult`, `ActionAnnotations`, `InvokeParams`, `ProgressParams`, `CancelParams`, `SampleParams`, `ElicitParams`, `ReadParams`, `SubscribeParams`, `UpdatedParams`.\n- The error-code enum from the [errors page](/protocol/errors/).\n\nGive the error codes first-class names. Don't pass bare integers around - they accumulate magic.\n\n## Step 3 - write the dispatcher\n\nA bidirectional JSON-RPC dispatcher with:\n\n- `on(method, handler)` - respond to incoming requests.\n- `onNotification(method, handler)` - respond to incoming notifications.\n- `request(method, params, { timeoutMs })` - send a request, await the response. ID generation, timeout handling, rejection on close.\n- `notify(method, params)` - send a fire-and-forget notification.\n- `receive(message)` - given a parsed JSON-RPC envelope, dispatch to a handler or resolve a pending request.\n\nTest this in isolation against a pair of in-memory dispatchers. No networking yet.\n\n## Step 4 - write the transport\n\nA WebSocket client that:\n\n- Connects to `ws://127.0.0.1:7475` (configurable).\n- Serialises objects with the language's standard JSON library.\n- Exposes `send`, `onMessage`, `onClose`, `close`.\n- Parses incoming text frames as JSON and calls the `onMessage` handler.\n\nDon't reinvent backoff or reconnect inside the transport - that's the user's job.\n\n## Step 5 - builder DSL\n\nWhatever shape is idiomatic. In TypeScript we use a fluent builder (`action(...).describe(...).input(...).handler(...)`). In Python, decorators. In Rust, probably a struct with a method-chain pattern. What matters is that it ultimately produces an `ActionDefinition`:\n\n```\nActionDefinition {\n name: string;\n description?: string;\n inputSchema?: StandardJsonSchema;\n outputSchema?: StandardJsonSchema;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput: boolean;\n handler: (input, ctx) => output;\n}\n```\n\nSame for `ResourceDefinition`.\n\n## Step 6 - bind it together\n\n```\nclass TesseronClient {\n constructor(transport, dispatcher) { … }\n app(info) { … } // records app manifest for hello\n action(name) { return new Builder(this, name) }\n resource(name) { return new ResBuilder(this, name) }\n async connect() {\n await transport.open();\n dispatcher.on('actions/invoke', this._onInvoke);\n dispatcher.onNotification('actions/cancel', this._onCancel);\n dispatcher.on('resources/read', this._onRead);\n dispatcher.on('resources/subscribe', this._onSub);\n dispatcher.on('resources/unsubscribe', this._onUnsub);\n return await dispatcher.request('tesseron/hello', this._manifest());\n }\n}\n```\n\nEach `on(...)` handler maps to the corresponding builder. Implement progress / sample / elicit / log on the `ActionContext` the same way.\n\n## Step 7 - conformance checklist\n\nBefore you ship, make sure the SDK passes every line of this list. An SDK that fails any line is not Tesseron-compliant.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after WebSocket open.\n- [ ] Sends `protocolVersion = \"1.0.0\"` exactly.\n- [ ] Sends `app.id` that matches `/^[a-z][a-z0-9_]*$/`.\n- [ ] Surfaces `welcome.claimCode` to the caller (stdout, event, return value - your choice).\n- [ ] Surfaces `welcome.capabilities` as the authoritative agent capability set to handlers.\n\n**Actions**\n- [ ] Validates action input against the Standard-Schema-equivalent schema before the handler runs.\n- [ ] Returns `-32004 InputValidation` with issues on failure.\n- [ ] Passes output through unchanged by default; validates and returns `-32005` when strict output is enabled and validation fails.\n- [ ] Supports per-invocation timeouts, default 60 000 ms, configurable per action.\n- [ ] Aborts via idiomatic cancellation primitive when the MCP gateway sends `actions/cancel`.\n- [ ] Returns `-32001 Cancelled` on explicit cancel; `-32002 Timeout` on timer.\n- [ ] Emits `actions/progress` notifications from `ctx.progress(...)`.\n\n**Sampling / Confirmation / Elicitation**\n- [ ] Sends `sampling/request` / `elicitation/request` as requests (not notifications).\n- [ ] `ctx.confirm` sends an elicit with an empty-properties object schema and collapses decline / cancel / missing-capability to `false`.\n- [ ] `ctx.elicit` validates the response against the supplied Standard Schema and returns `null` on decline / cancel.\n- [ ] Raises a typed error (`SamplingNotAvailable`, `ElicitationNotAvailable`) when capabilities don't include them - except `ctx.confirm`, which swallows missing elicitation and returns `false`.\n- [ ] Rejects top-level non-object / `oneOf` / `anyOf` / nested-object elicit schemas with `-32602 InvalidParams` at the call site.\n- [ ] Caps sampling depth at 3 (or honours the gateway's cap).\n\n**Resources**\n- [ ] Responds to `resources/read` with `{ value }`.\n- [ ] Accepts `resources/subscribe` and returns the emitter callback's unsubscribe.\n- [ ] Sends `resources/updated` notifications on change.\n- [ ] Cleans up subscriptions on `resources/unsubscribe` and on transport close.\n\n**Lifecycle**\n- [ ] On transport close: rejects all pending outbound requests, aborts all in-flight invocations, clears all subscriptions.\n- [ ] Does not auto-reconnect silently.\n\n**Error model**\n- [ ] Uses exactly the Tesseron error codes from [the errors catalog](/protocol/errors/).\n- [ ] Preserves `error.data` verbatim when surfacing errors to handlers / users.\n\n**Interop**\n- [ ] Round-trips with the reference `@tesseron/mcp` gateway against at least one real MCP client (Claude Code, Cursor, Claude Desktop).\n\n## Step 8 - publish + link\n\nOpen a PR against the main Tesseron repo adding your SDK to the README. Add a page to this docs site under `/sdk/<your-language>/` mirroring the Python skeleton.\n\nOnce your SDK has shipped a 1.0 that passes the checklist on real agents, we'll happily link it as a first-class implementation.\n","bodyText":"Tesseron's wire protocol is small enough that a competent engineer can implement an SDK for a new language in a couple of days. This page is your map.\n\n## What you're actually building\n\nA Tesseron SDK is three things glued together:\n\n1. **A WebSocket client** that speaks JSON-RPC 2.0.\n2. **A builder DSL** for declaring actions and resources.\n3. **A bridge** between the JSON-RPC dispatcher and the builder's registered handlers.\n\nThe full [protocol spec](/protocol/) is the source of truth. If anything on this page contradicts it, the protocol wins.\n\n## Step 1 - pick a runtime model\n\nTwo choices decide the shape of everything that follows.\n\n- **Async model.** Native `async`/`await` / futures / goroutines - whatever your language uses for concurrency. All SDK methods that may block (connect, sample, elicit, progress-waiting action handlers) should be async. Synchronous handlers are fine, but the client's I/O loop must not be.\n- **Schema library.** You need a way for users to express typed inputs. Pick one well-known library (Pydantic in Python, `go-playground/validator` in Go, Serde+schemars in Rust), and accept any user-provided schema that can round-trip to JSON Schema.\n\n## Step 2 - model the protocol types\n\nPort these from the [wire format page](/protocol/wire-format/):\n\n- JSON-RPC 2.0 request / notification / success / error envelopes.\n- `HelloParams`, `WelcomeResult`, `ActionAnnotations`, `InvokeParams`, `ProgressParams`, `CancelParams`, `SampleParams`, `ElicitParams`, `ReadParams`, `SubscribeParams`, `UpdatedParams`.\n- The error-code enum from the [errors page](/protocol/errors/).\n\nGive the error codes first-class names. Don't pass bare integers around - they accumulate magic.\n\n## Step 3 - write the dispatcher\n\nA bidirectional JSON-RPC dispatcher with:\n\n- `on(method, handler)` - respond to incoming requests.\n- `onNotification(method, handler)` - respond to incoming notifications.\n- `request(method, params, { timeoutMs })` - send a request, await the response. ID generation, timeout handling, rejection on close.\n- `notify(method, params)` - send a fire-and-forget notification.\n- `receive(message)` - given a parsed JSON-RPC envelope, dispatch to a handler or resolve a pending request.\n\nTest this in isolation against a pair of in-memory dispatchers. No networking yet.\n\n## Step 4 - write the transport\n\nA WebSocket client that:\n\n- Connects to `ws://127.0.0.1:7475` (configurable).\n- Serialises objects with the language's standard JSON library.\n- Exposes `send`, `onMessage`, `onClose`, `close`.\n- Parses incoming text frames as JSON and calls the `onMessage` handler.\n\nDon't reinvent backoff or reconnect inside the transport - that's the user's job.\n\n## Step 5 - builder DSL\n\nWhatever shape is idiomatic. In TypeScript we use a fluent builder (`action(...).describe(...).input(...).handler(...)`). In Python, decorators. In Rust, probably a struct with a method-chain pattern. What matters is that it ultimately produces an `ActionDefinition`:\n\n```\nActionDefinition {\n name: string;\n description?: string;\n inputSchema?: StandardJsonSchema;\n outputSchema?: StandardJsonSchema;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput: boolean;\n handler: (input, ctx) => output;\n}\n```\n\nSame for `ResourceDefinition`.\n\n## Step 6 - bind it together\n\n```\nclass TesseronClient {\n constructor(transport, dispatcher) { … }\n app(info) { … } // records app manifest for hello\n action(name) { return new Builder(this, name) }\n resource(name) { return new ResBuilder(this, name) }\n async connect() {\n await transport.open();\n dispatcher.on('actions/invoke', this._onInvoke);\n dispatcher.onNotification('actions/cancel', this._onCancel);\n dispatcher.on('resources/read', this._onRead);\n dispatcher.on('resources/subscribe', this._onSub);\n dispatcher.on('resources/unsubscribe', this._onUnsub);\n return await dispatcher.request('tesseron/hello', this._manifest());\n }\n}\n```\n\nEach `on(...)` handler maps to the corresponding builder. Implement progress / sample / elicit / log on the `ActionContext` the same way.\n\n## Step 7 - conformance checklist\n\nBefore you ship, make sure the SDK passes every line of this list. An SDK that fails any line is not Tesseron-compliant.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after WebSocket open.\n- [ ] Sends `protocolVersion = \"1.0.0\"` exactly.\n- [ ] Sends `app.id` that matches `/^[a-z][a-z0-9_]*$/`.\n- [ ] Surfaces `welcome.claimCode` to the caller (stdout, event, return value - your choice).\n- [ ] Surfaces `welcome.capabilities` as the authoritative agent capability set to handlers.\n\n**Actions**\n- [ ] Validates action input against the Standard-Schema-equivalent schema before the handler runs.\n- [ ] Returns `-32004 InputValidation` with issues on failure.\n- [ ] Passes output through unchanged by default; validates and returns `-32005` when strict output is enabled and validation fails.\n- [ ] Supports per-invocation timeouts, default 60 000 ms, configurable per action.\n- [ ] Aborts via idiomatic cancellation primitive when the MCP gateway sends `actions/cancel`.\n- [ ] Returns `-32001 Cancelled` on explicit cancel; `-32002 Timeout` on timer.\n- [ ] Emits `actions/progress` notifications from `ctx.progress(...)`.\n\n**Sampling / Confirmation / Elicitation**\n- [ ] Sends `sampling/request` / `elicitation/request` as requests (not notifications).\n- [ ] `ctx.confirm` sends an elicit with an empty-properties object schema and collapses decline / cancel / missing-capability to `false`.\n- [ ] `ctx.elicit` validates the response against the supplied Standard Schema and returns `null` on decline / cancel.\n- [ ] Raises a typed error (`SamplingNotAvailable`, `ElicitationNotAvailable`) when capabilities don't include them - except `ctx.confirm`, which swallows missing elicitation and returns `false`.\n- [ ] Rejects top-level non-object / `oneOf` / `anyOf` / nested-object elicit schemas with `-32602 InvalidParams` at the call site.\n- [ ] Caps sampling depth at 3 (or honours the gateway's cap).\n\n**Resources**\n- [ ] Responds to `resources/read` with `{ value }`.\n- [ ] Accepts `resources/subscribe` and returns the emitter callback's unsubscribe.\n- [ ] Sends `resources/updated` notifications on change.\n- [ ] Cleans up subscriptions on `resources/unsubscribe` and on transport close.\n\n**Lifecycle**\n- [ ] On transport close: rejects all pending outbound requests, aborts all in-flight invocations, clears all subscriptions.\n- [ ] Does not auto-reconnect silently.\n\n**Error model**\n- [ ] Uses exactly the Tesseron error codes from [the errors catalog](/protocol/errors/).\n- [ ] Preserves `error.data` verbatim when surfacing errors to handlers / users.\n\n**Interop**\n- [ ] Round-trips with the reference `@tesseron/mcp` gateway against at least one real MCP client (Claude Code, Cursor, Claude Desktop).\n\n## Step 8 - publish + link\n\nOpen a PR against the main Tesseron repo adding your SDK to the README. Add a page to this docs site under `/sdk/<your-language>/` mirroring the Python skeleton.\n\nOnce your SDK has shipped a 1.0 that passes the checklist on real agents, we'll happily link it as a first-class implementation."},{"slug":"sdk/python/index","title":"Python SDK (planned)","description":"Status and intended shape of a Python implementation of the Tesseron SDK.","section":"sdk","related":["sdk/index","sdk/porting"],"bodyRaw":"\nA Python SDK is on the roadmap but **not yet shipped**.\n\nWhen it lands, it will implement the same [portable SDK contract](/sdk/#the-portable-sdk-contract) as `@tesseron/core`:\n\n- An action builder that accepts any Python validator (Pydantic v2, `msgspec`, `attrs`+`cattrs`) and produces JSON Schema.\n- An invocation context object with `progress`, `sample`, `confirm`, `elicit`, `log`, and an `asyncio.CancelledError`-based cancellation contract.\n- A resource builder with `.read()` and `.subscribe()`.\n- A WebSocket transport using `websockets` or `aiohttp`.\n- A CLI and an optional `FastAPI` integration helper.\n\n## Why Python at all\n\nTwo use cases we hear most:\n\n1. **Backend services already written in Python.** You have a Flask / FastAPI / Django app and don't want to proxy everything through a Node service just to expose it to Claude.\n2. **Local Python tooling.** Jupyter notebooks, data-analysis scripts, personal CLIs - all things where exposing half a dozen actions to Claude adds real leverage.\n\nBoth are better served by a native Python SDK than by shelling out to Node.\n\n## Design notes\n\nRough shape, subject to change:\n\n```python\nfrom tesseron import Tesseron\nfrom pydantic import BaseModel\n\ntesseron = Tesseron(app={\"id\": \"notes\", \"name\": \"Notes\"})\n\nclass CreateNoteInput(BaseModel):\n title: str\n body: str = \"\"\n\n@tesseron.action(\"createNote\", input=CreateNoteInput)\nasync def create_note(input: CreateNoteInput, ctx):\n note = {\"id\": new_id(), \"title\": input.title, \"body\": input.body}\n store.add(note)\n ctx.progress(message=\"saved\", percent=100)\n return note\n\nawait tesseron.connect()\n```\n\nDecorator-flavoured where it fits the ecosystem better than the fluent builder. The wire contract is identical - any Tesseron SDK must produce the same `tesseron/hello` envelope and respond to the same `actions/invoke` request.\n\n## Roadmap\n\n- Early spike: TBD, tracked in the [Tesseron repo](https://github.com/BrainBlend-AI/tesseron).\n- 1.0 target: feature-parity with `@tesseron/core` + `@tesseron/server`.\n\nIf you want to contribute or help shape the API, open a discussion on GitHub.\n","bodyText":"A Python SDK is on the roadmap but **not yet shipped**.\n\nWhen it lands, it will implement the same [portable SDK contract](/sdk/#the-portable-sdk-contract) as `@tesseron/core`:\n\n- An action builder that accepts any Python validator (Pydantic v2, `msgspec`, `attrs`+`cattrs`) and produces JSON Schema.\n- An invocation context object with `progress`, `sample`, `confirm`, `elicit`, `log`, and an `asyncio.CancelledError`-based cancellation contract.\n- A resource builder with `.read()` and `.subscribe()`.\n- A WebSocket transport using `websockets` or `aiohttp`.\n- A CLI and an optional `FastAPI` integration helper.\n\n## Why Python at all\n\nTwo use cases we hear most:\n\n1. **Backend services already written in Python.** You have a Flask / FastAPI / Django app and don't want to proxy everything through a Node service just to expose it to Claude.\n2. **Local Python tooling.** Jupyter notebooks, data-analysis scripts, personal CLIs - all things where exposing half a dozen actions to Claude adds real leverage.\n\nBoth are better served by a native Python SDK than by shelling out to Node.\n\n## Design notes\n\nRough shape, subject to change:\n\n```python\nfrom tesseron import Tesseron\nfrom pydantic import BaseModel\n\ntesseron = Tesseron(app={\"id\": \"notes\", \"name\": \"Notes\"})\n\nclass CreateNoteInput(BaseModel):\n title: str\n body: str = \"\"\n\n@tesseron.action(\"createNote\", input=CreateNoteInput)\nasync def create_note(input: CreateNoteInput, ctx):\n note = {\"id\": new_id(), \"title\": input.title, \"body\": input.body}\n store.add(note)\n ctx.progress(message=\"saved\", percent=100)\n return note\n\nawait tesseron.connect()\n```\n\nDecorator-flavoured where it fits the ecosystem better than the fluent builder. The wire contract is identical - any Tesseron SDK must produce the same `tesseron/hello` envelope and respond to the same `actions/invoke` request.\n\n## Roadmap\n\n- Early spike: TBD, tracked in the [Tesseron repo](https://github.com/BrainBlend-AI/tesseron).\n- 1.0 target: feature-parity with `@tesseron/core` + `@tesseron/server`.\n\nIf you want to contribute or help shape the API, open a discussion on GitHub."},{"slug":"sdk/typescript/action-builder","title":"Action builder","description":"Every step of the fluent builder, what it does, and when to use it.","section":"sdk","related":["protocol/actions","sdk/typescript/standard-schema","sdk/typescript/context"],"bodyRaw":"\nThe action builder is the fluent API on `tesseron.action(name)`. It chains until `.handler(fn)` terminates it with an `ActionDefinition<I, O>`.\n\n## Signature\n\n```ts\ninterface ActionBuilder<I = unknown, O = unknown> {\n describe(description: string): ActionBuilder<I, O>;\n input<NewI>(schema: StandardSchemaV1<NewI>, jsonSchema?: unknown): ActionBuilder<NewI, O>;\n output<NewO>(schema: StandardSchemaV1<NewO>, jsonSchema?: unknown): ActionBuilder<I, NewO>;\n annotate(annotations: ActionAnnotations): ActionBuilder<I, O>;\n timeout(options: { ms: number }): ActionBuilder<I, O>;\n strictOutput(): ActionBuilder<I, O>;\n handler(fn: (input: I, ctx: ActionContext) => O | Promise<O>): ActionDefinition<I, O>;\n}\n```\n\n## `.describe(string)`\n\nHuman-readable description. Shown to the agent's LLM verbatim as the MCP tool description. This is the single biggest lever for getting the agent to call your action correctly; write it as you would write a function docstring for a teammate.\n\n```ts\ntesseron.action('searchProducts')\n .describe(\n 'Search the product catalog. Returns up to `limit` products ordered by ' +\n 'relevance. Use when the user is trying to find items to buy.'\n );\n```\n\n## `.input(schema)` and `.input(schema, jsonSchema)`\n\nBind a Standard Schema validator for input. The schema is used for:\n\n1. **Runtime validation** - invalid input fails with code `-32004` before the handler runs.\n2. **Type inference** - `I` in `handler: (input: I, ctx) => …`.\n3. **JSON Schema export** - for the MCP tool's `inputSchema`.\n\nMost Standard Schema libraries expose JSON-Schema conversion utilities; the SDK uses whatever your validator provides. If the conversion is missing or inadequate, pass a hand-written JSON Schema as the second argument:\n\n```ts\n.input(\n z.object({ sku: z.string(), qty: z.number().int().positive() }),\n { type: 'object', properties: { sku: { type: 'string' }, qty: { type: 'integer', minimum: 1 } }, required: ['sku', 'qty'] },\n)\n```\n\n## `.output(schema)` / `.output(schema, jsonSchema)`\n\nBind a Standard Schema for the return value. By default **this is informational** - the value is passed through unchanged. Call `.strictOutput()` to enforce.\n\n```ts\n.output(z.object({ id: z.string(), itemId: z.string() }))\n```\n\n## `.annotate({…})`\n\nAdvisory metadata surfaced to the agent.\n\n```ts\ninterface ActionAnnotations {\n readOnly?: boolean;\n destructive?: boolean;\n requiresConfirmation?: boolean;\n}\n```\n\n| Field | Use for |\n|---|---|\n| `readOnly: true` | Pure reads. Agent may parallelise. |\n| `destructive: true` | Mutates persistent state. Agent SHOULD warn the user. |\n| `requiresConfirmation: true` | Agent MUST NOT call without explicit user confirmation. Often paired with `ctx.confirm` inside the handler as a second gate. |\n\n## `.timeout({ ms })`\n\nPer-invocation timeout. Default 60 000 ms. When exceeded, the handler's `ctx.signal` aborts and the invocation returns error `-32002 Timeout`.\n\n```ts\n.timeout({ ms: 5 * 60 * 1000 }) // big report, 5 minutes\n```\n\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\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\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\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\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\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.0.0'\n HelloParams, WelcomeResult, TesseronCapabilities,\n AppMetadata, AgentIdentity, ActionAnnotations,\n ActionInvokeParams, ActionProgressParams, ActionCancelParams,\n ResourceReadParams, ResourceSubscribeParams, ResourceUpdatedParams,\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.0.0'\n HelloParams, WelcomeResult, TesseronCapabilities,\n AppMetadata, AgentIdentity, ActionAnnotations,\n ActionInvokeParams, ActionProgressParams, ActionCancelParams,\n ResourceReadParams, ResourceSubscribeParams, ResourceUpdatedParams,\n} from '@tesseron/core';\n```\n\nSibling-package helpers (`JsonRpcDispatcher`, `SDK_CAPABILITIES`, schema helpers, builder implementation classes) live under `@tesseron/core/internal`. They are deliberately excluded from the main entry point and are **not** part of the v1.0 semver contract — treat them as subject to change. Only the `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, and `@tesseron/mcp` packages should import from that subpath.\n\n## `TesseronClient` (abstract)\n\n`@tesseron/web` and `@tesseron/server` each extend this with a transport. The base class's `connect(transport)` takes a concrete `Transport`. The web / server subclasses override it to accept `Transport | string | undefined` so users can pass a URL (or nothing) and get a default WebSocket transport. The subclassing contract:\n\n```ts\nclass MyTesseronClient extends TesseronClient {\n override async connect(target?: Transport | string): Promise<WelcomeResult> {\n if (target && typeof target !== 'string') return super.connect(target);\n const transport = new MyTransport(target ?? DEFAULT_GATEWAY_URL);\n await transport.ready();\n return super.connect(transport);\n }\n}\n```\n\n`super.connect(transport)` wires the dispatcher, sends `tesseron/hello`, handles `actions/invoke`, and returns the `welcome` result.\n\n## `Transport`\n\n```ts\ninterface Transport {\n send(message: unknown): void;\n onMessage(handler: (message: unknown) => void): void;\n onClose(handler: (reason?: string) => void): void;\n close(reason?: string): void;\n}\n```\n\nThe core client assumes the transport passes objects (not strings). If your transport is string-oriented, JSON.parse / stringify at the boundary. WebSocket-based transports in `@tesseron/web` and `@tesseron/server` already do this.\n\n## `JsonRpcDispatcher`\n\nLow-level bidirectional JSON-RPC router:\n\n```ts\ninterface JsonRpcDispatcher {\n on<M>(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void;\n onNotification<N>(method: string, handler: (params: unknown) => void): void;\n request<R>(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise<R>;\n notify(method: string, params?: unknown): void;\n receive(message: unknown): void;\n}\n```\n\nYou typically only use this directly when implementing extension methods. Day-to-day use of Tesseron goes through the builder, not the dispatcher.\n\n## `TesseronError`\n\n```ts\nclass TesseronError extends Error {\n readonly code: number;\n readonly data?: unknown;\n constructor(code: number, message: string, data?: unknown);\n}\n```\n\nThe dispatcher maps it to / from the `{ code, message, data }` JSON-RPC error object automatically. Throw it from handlers to produce a specific JSON-RPC error:\n\n```ts\n\n.handler(async ({ orderId }, ctx) => {\n const order = await orders.find(orderId);\n if (!order) throw new TesseronError(TesseronErrorCode.ActionNotFound, `no order ${orderId}`, { orderId });\n // …\n});\n```\n\nCatching `TesseronError` is also useful around `ctx.sample` / `ctx.elicit` to pivot on capability errors (note: `ctx.confirm` doesn't throw — it returns `false` when elicitation isn't available, which is the safe default for destructive gates):\n\n```ts\n\ntry {\n const r = await ctx.sample({ prompt });\n} catch (err) {\n if (err instanceof SamplingNotAvailableError) return fallback();\n // equivalent by code:\n if (err instanceof TesseronError && err.code === TesseronErrorCode.SamplingNotAvailable) {\n return fallback();\n }\n throw err;\n}\n```\n\n## Bringing your own transport\n\nA minimal example, for clarity - a loopback transport pair for tests:\n\n```ts\n\nfunction pair(): [Transport, Transport] {\n const aInbox: Array<(m: unknown) => void> = [];\n const bInbox: Array<(m: unknown) => void> = [];\n const a: Transport = {\n send: (m) => bInbox.forEach((h) => h(m)),\n onMessage: (h) => aInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n const b: Transport = {\n send: (m) => aInbox.forEach((h) => h(m)),\n onMessage: (h) => bInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n return [a, b];\n}\n```\n\nYou can attach a `TesseronClient` subclass to one side and a mock gateway to the other. Both `@tesseron/mcp` and the SDK test suites rely on patterns like this."},{"slug":"sdk/typescript/index","title":"Install & first action","description":"The minimum code to go from zero to a working Tesseron integration in TypeScript.","section":"sdk","related":["sdk/typescript/action-builder","sdk/typescript/web","sdk/typescript/standard-schema","overview/quickstart"],"bodyRaw":"\nimport { Tabs, TabItem, Steps } from '@astrojs/starlight/components';\n\n<Steps>\n\n1. **Install the package that matches your runtime.**\n\n <Tabs>\n <TabItem label=\"Browser (Vite, Next, etc.)\">\n ```bash\n pnpm add @tesseron/web zod\n ```\n </TabItem>\n <TabItem label=\"React\">\n ```bash\n pnpm add @tesseron/react zod\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 `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 <code>.\"* 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/).\n","bodyText":"## Typical entry-point layouts\n\n## Where to go next\n\n- The [action builder](/sdk/typescript/action-builder/) in full - chaining, output validation, annotations, timeouts.\n- [Standard Schema validators](/sdk/typescript/standard-schema/) - Zod vs Valibot vs ArkType, and JSON Schema interop.\n- The [context API](/sdk/typescript/context/) - progress, sampling, elicitation, logging.\n- Framework adapters: [@tesseron/react](/sdk/typescript/react/)."},{"slug":"sdk/typescript/mcp","title":"@tesseron/mcp (MCP gateway)","description":"The MCP gateway process - WebSocket server + MCP stdio bridge. Bundled into the Claude Code plugin; you rarely run it by hand.","section":"sdk","related":["protocol/handshake","protocol/security","protocol/transport"],"bodyRaw":"\n`@tesseron/mcp` is the MCP gateway. It:\n\n- Runs a WebSocket server on `127.0.0.1:7475` that your app connects to.\n- Runs an MCP stdio server that the agent connects to.\n- Translates between the two, maintains session state, handles claim codes, enforces origin allowlist, fans out progress / sampling / elicitation across the boundary.\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\nTESSERON_PORT=7475 pnpm dlx @tesseron/mcp\n```\n\nIt starts, listens on stdio for MCP, and accepts WebSockets on `:7475`. Kill it with Ctrl-C.\n\n## Environment\n\nConfiguration is environment-variable driven; there are no CLI flags.\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TESSERON_PORT` | `7475` | WebSocket listen port. |\n| `TESSERON_HOST` | `127.0.0.1` | Listen host. Leave localhost unless you understand the implications. |\n| `TESSERON_ORIGIN_ALLOWLIST` | *(empty)* | Comma-separated extra Origins accepted. |\n| `TESSERON_TOOL_SURFACE` | `both` | `dynamic` / `meta` / `both`. Controls which MCP tools the bridge advertises (per-app tools, meta-dispatcher tools, or both). |\n\nThe advertised protocol version is pinned to `PROTOCOL_VERSION` in `@tesseron/core` and is not configurable at runtime.\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- Three 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- 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 active WebSocket.\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 `Math.random()`. Stored on the session, claimed via `gateway.claimSession(code)`, cleaned on claim or session close.\n\n## Where the plugin bundles it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo bundles the gateway as `plugin/server/index.cjs`, built via:\n\n```bash\npnpm --filter @tesseron/mcp build:plugin\n```\n\nThis esbuild bundle is what ships to plugin installers. If you're hacking on the gateway, rebuild the plugin bundle before testing against Claude Code.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `packages/mcp/src/cli.ts` - entry point, arg parsing.\n- `packages/mcp/src/gateway.ts` - WebSocket server, session management.\n- `packages/mcp/src/session.ts` - a single session's state + claim code.\n- `packages/mcp/src/mcp-bridge.ts` - MCP stdio server + protocol translation.\n\nAdding a new method (e.g., a custom `tesseron__debug_dump` tool) means editing `mcp-bridge.ts` for the MCP side and routing through `gateway.ts` if it also crosses the WebSocket. Keep new methods under a `tesseron__` prefix to avoid colliding with app action tools.\n\n## Not for production agents\n\nThis is a local developer tool. Don't bind it to `0.0.0.0`, don't expose port 7475 to the internet, don't skip the origin allowlist. If you need remote-agent support, wait for the Phase-4 Streamable HTTP transport or build a reverse-tunnel with explicit authentication in front.\n","bodyText":"`@tesseron/mcp` is the MCP gateway. It:\n\n- Runs a WebSocket server on `127.0.0.1:7475` that your app connects to.\n- Runs an MCP stdio server that the agent connects to.\n- Translates between the two, maintains session state, handles claim codes, enforces origin allowlist, fans out progress / sampling / elicitation across the boundary.\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\nTESSERON_PORT=7475 pnpm dlx @tesseron/mcp\n```\n\nIt starts, listens on stdio for MCP, and accepts WebSockets on `:7475`. Kill it with Ctrl-C.\n\n## Environment\n\nConfiguration is environment-variable driven; there are no CLI flags.\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TESSERON_PORT` | `7475` | WebSocket listen port. |\n| `TESSERON_HOST` | `127.0.0.1` | Listen host. Leave localhost unless you understand the implications. |\n| `TESSERON_ORIGIN_ALLOWLIST` | *(empty)* | Comma-separated extra Origins accepted. |\n| `TESSERON_TOOL_SURFACE` | `both` | `dynamic` / `meta` / `both`. Controls which MCP tools the bridge advertises (per-app tools, meta-dispatcher tools, or both). |\n\nThe advertised protocol version is pinned to `PROTOCOL_VERSION` in `@tesseron/core` and is not configurable at runtime.\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- Three 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- 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 active WebSocket.\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 `Math.random()`. Stored on the session, claimed via `gateway.claimSession(code)`, cleaned on claim or session close.\n\n## Where the plugin bundles it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo bundles the gateway as `plugin/server/index.cjs`, built via:\n\n```bash\npnpm --filter @tesseron/mcp build:plugin\n```\n\nThis esbuild bundle is what ships to plugin installers. If you're hacking on the gateway, rebuild the plugin bundle before testing against Claude Code.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `packages/mcp/src/cli.ts` - entry point, arg parsing.\n- `packages/mcp/src/gateway.ts` - WebSocket server, session management.\n- `packages/mcp/src/session.ts` - a single session's state + claim code.\n- `packages/mcp/src/mcp-bridge.ts` - MCP stdio server + protocol translation.\n\nAdding a new method (e.g., a custom `tesseron__debug_dump` tool) means editing `mcp-bridge.ts` for the MCP side and routing through `gateway.ts` if it also crosses the WebSocket. Keep new methods under a `tesseron__` prefix to avoid colliding with app action tools.\n\n## Not for production agents\n\nThis is a local developer tool. Don't bind it to `0.0.0.0`, don't expose port 7475 to the internet, don't skip the origin allowlist. If you need remote-agent support, wait for the Phase-4 Streamable HTTP transport or 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}\n```\n\nOptions:\n\n```ts\ninterface UseTesseronConnectionOptions {\n url?: string; // defaults to ws://127.0.0.1:7475\n enabled?: boolean; // gate the connect, e.g. only when logged in\n}\n```\n\nOnly one component should call `useTesseronConnection` per client - it owns the WebSocket. Most apps put it at the root.\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}\n```\n\nOptions:\n\n```ts\ninterface UseTesseronConnectionOptions {\n url?: string; // defaults to ws://127.0.0.1:7475\n enabled?: boolean; // gate the connect, e.g. only when logged in\n}\n```\n\nOnly one component should call `useTesseronConnection` per client - it owns the WebSocket. Most apps put it at the root.\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. Same action surface as @tesseron/web, different transport.","section":"sdk","related":["sdk/typescript/core","protocol/transport","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. The builder API is identical to `@tesseron/web`; only the transport differs.\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 tesseron,\n ServerTesseronClient,\n NodeWebSocketTransport,\n DEFAULT_GATEWAY_URL, // 'ws://localhost:7475'\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## Express example\n\nThe [`express-todo` example](/examples/express-todo/) shows the canonical \"HTTP + Tesseron on one Node process\" pattern. Keep the shared state outside of both entry points; each channel calls the same functions:\n\n```ts\nconst todos = new Map<string, Todo>();\n\n// REST surface\napp.post('/todos', (req, res) => {\n const todo = createTodo(todos, req.body);\n res.json(todo);\n});\n\n// Tesseron surface - same underlying function\ntesseron.action('addTodo')\n .input(z.object({ text: z.string() }))\n .handler(({ text }) => createTodo(todos, { text }));\n```\n\n## Transport details\n\n`NodeWebSocketTransport` wraps the [`ws`](https://github.com/websockets/ws) npm package (v8). Differences from the browser transport:\n\n- Accepts every frame shape `ws` hands back - `string`, `Buffer`, `Buffer[]`, `ArrayBuffer` - and coerces to UTF-8 before parsing. The browser transport is string-only.\n- Tolerates the gateway sending fragmented messages; `ws` reassembles automatically.\n- No auto-reconnect; see the [reconnect pattern](/sdk/typescript/web/#reconnect-pattern) from the web page - it transfers.\n\n## Running under Docker / systemd\n\nTwo things to get right:\n\n1. **Stdout / stderr** go to the process manager's log, not the gateway's. The claim code surfaces in *your* logs. Plan your startup flow to copy it somewhere humans can see - or, if the service is meant to be headless and always-on, log the claim code only to a file you rotate.\n2. **Signal handling.** `process.on('SIGTERM', …)` to call `tesseron.disconnect()` before exit gives the gateway a clean close (code 1001) and stops the agent from seeing abrupt tool failures.\n\n## Capabilities\n\nServer handlers get the same `ActionContext` as browser handlers. There are two differences worth being aware of:\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. The builder API is identical to `@tesseron/web`; only the transport differs.\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 tesseron,\n ServerTesseronClient,\n NodeWebSocketTransport,\n DEFAULT_GATEWAY_URL, // 'ws://localhost:7475'\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## Express example\n\nThe [`express-todo` example](/examples/express-todo/) shows the canonical \"HTTP + Tesseron on one Node process\" pattern. Keep the shared state outside of both entry points; each channel calls the same functions:\n\n```ts\nconst todos = new Map<string, Todo>();\n\n// REST surface\napp.post('/todos', (req, res) => {\n const todo = createTodo(todos, req.body);\n res.json(todo);\n});\n\n// Tesseron surface - same underlying function\ntesseron.action('addTodo')\n .input(z.object({ text: z.string() }))\n .handler(({ text }) => createTodo(todos, { text }));\n```\n\n## Transport details\n\n`NodeWebSocketTransport` wraps the [`ws`](https://github.com/websockets/ws) npm package (v8). Differences from the browser transport:\n\n- Accepts every frame shape `ws` hands back - `string`, `Buffer`, `Buffer[]`, `ArrayBuffer` - and coerces to UTF-8 before parsing. The browser transport is string-only.\n- Tolerates the gateway sending fragmented messages; `ws` reassembles automatically.\n- No auto-reconnect; see the [reconnect pattern](/sdk/typescript/web/#reconnect-pattern) from the web page - it transfers.\n\n## Running under Docker / systemd\n\nTwo things to get right:\n\n1. **Stdout / stderr** go to the process manager's log, not the gateway's. The claim code surfaces in *your* logs. Plan your startup flow to copy it somewhere humans can see - or, if the service is meant to be headless and always-on, log the claim code only to a file you rotate.\n2. **Signal handling.** `process.on('SIGTERM', …)` to call `tesseron.disconnect()` before exit gives the gateway a clean close (code 1001) and stops the agent from seeing abrupt tool failures.\n\n## Capabilities\n\nServer handlers get the same `ActionContext` as browser handlers. There are two differences worth being aware of:\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). There are two paths:\n\n### 1. Your validator provides it\n\nModern Zod, TypeBox, and Effect Schema can produce JSON Schema natively. The SDK picks it up automatically. No extra work.\n\n### 2. Pass it manually\n\nSome validators don't emit JSON Schema, or the export isn't great for a given shape. 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\n### 3. Fallback\n\nIf neither path produces a schema, the SDK sends `{ type: 'object', additionalProperties: true }` - permissive, unhelpful to the agent, but the call still works.\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). There are two paths:\n\n### 1. Your validator provides it\n\nModern Zod, TypeBox, and Effect Schema can produce JSON Schema natively. The SDK picks it up automatically. No extra work.\n\n### 2. Pass it manually\n\nSome validators don't emit JSON Schema, or the export isn't great for a given shape. 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\n### 3. Fallback\n\nIf neither path produces a schema, the SDK sends `{ type: 'object', additionalProperties: true }` - permissive, unhelpful to the agent, but the call still works.\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/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.\n BrowserWebSocketTransport,\n // Default gateway URL.\n DEFAULT_GATEWAY_URL, // 'ws://localhost:7475'\n} from '@tesseron/web';\n\n// The full `@tesseron/core` surface is also re-exported.\n```\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` | Connects to `ws://localhost:7475`. |\n| `string` (URL) | Connects to that URL. |\n| `Transport` | Uses the supplied transport - mostly for tests. |\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.\n BrowserWebSocketTransport,\n // Default gateway URL.\n DEFAULT_GATEWAY_URL, // 'ws://localhost:7475'\n} from '@tesseron/web';\n\n// The full `@tesseron/core` surface is also re-exported.\n```\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` | Connects to `ws://localhost:7475`. |\n| `string` (URL) | Connects to that URL. |\n| `Transport` | Uses the supplied transport - mostly for tests. |\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":"7e5fe73","generatedAt":"2026-04-24T09:07:19.318Z","count":37,"docs":[{"slug":"examples/express-todo","title":"express-todo","description":"REST API + Tesseron on the same Node process, backed by the same state.","section":"examples","related":["sdk/typescript/server","examples/node-todo"],"bodyRaw":"\n**What it teaches:** how to expose the same backend operations via two channels at once - HTTP for human / programmatic clients, Tesseron for the agent. Neither knows the other exists.\n\n**Source:** [`examples/express-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/express-todo)\n\n## Run it\n\n```bash\npnpm --filter express-todo dev\n# REST on http://localhost:3001\n# WS -> gateway on ws://127.0.0.1:7475\n```\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 todos = new Map<string, Todo>();\n\n// --- REST ---\nconst app = express();\napp.post('/todos', (req, res) => {\n const todo = { id: newId(), text: req.body.text, done: false };\n todos.set(todo.id, todo);\n res.json(todo);\n});\n// GET /todos, PATCH /todos/:id, DELETE /todos/:id ...\n\n// --- Tesseron ---\ntesseron.app({ id: 'express_todo', name: 'Express Todo' });\n\ntesseron.action('addTodo')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.set(todo.id, todo);\n return todo;\n });\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` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), coexistence with an HTTP server in one process**.\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","bodyText":"**What it teaches:** how to expose the same backend operations via two channels at once - HTTP for human / programmatic clients, Tesseron for the agent. Neither knows the other exists.\n\n**Source:** [`examples/express-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/express-todo)\n\n## Run it\n\n```bash\npnpm --filter express-todo dev\n# REST on http://localhost:3001\n# WS -> gateway on ws://127.0.0.1:7475\n```\n\n## Pattern: shared state, two interfaces\n\n```ts title=\"src/index.ts (excerpt)\"\n\nconst todos = new Map<string, Todo>();\n\n// --- REST ---\nconst app = express();\napp.post('/todos', (req, res) => {\n const todo = { id: newId(), text: req.body.text, done: false };\n todos.set(todo.id, todo);\n res.json(todo);\n});\n// GET /todos, PATCH /todos/:id, DELETE /todos/:id ...\n\n// --- Tesseron ---\ntesseron.app({ id: 'express_todo', name: 'Express Todo' });\n\ntesseron.action('addTodo')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.set(todo.id, todo);\n return todo;\n });\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` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), coexistence with an HTTP server in one process**.\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."},{"slug":"examples/index","title":"All examples","description":"Six runnable Todo apps that together cover every framework adapter and every major feature of the SDK.","section":"examples","related":["overview/quickstart","sdk/typescript/index"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\n\nAll six examples live in [`examples/`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples). Each is a complete, runnable Todo app, intentionally simple so the Tesseron-specific code is easy to read.\n\n<CardGrid>\n <LinkCard title=\"vanilla-todo\" href=\"./vanilla-todo/\"\n description=\"Zero-framework baseline. Start here.\" />\n <LinkCard title=\"node-todo\" href=\"./node-todo/\"\n description=\"Headless Node service. No browser.\" />\n <LinkCard title=\"express-todo\" href=\"./express-todo/\"\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` (in `clearCompleted`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.elicit` with schema (in `renameTodo`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.progress` (in `importTodos`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.sample` (in `suggestTodos`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\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-todo](/examples/node-todo/)** - the same action declarations on Node. Proves nothing's tied to the browser.\n3. **[express-todo](/examples/express-todo/)** - adds a REST API next to Tesseron. Shows the \"same state, two channels\" pattern.\n4. **[react-todo](/examples/react-todo/)** - hooks-based integration.\n5. **[svelte-todo](/examples/svelte-todo/)** - mutation via `$state` runes.\n6. **[vue-todo](/examples/vue-todo/)** - mutation via `ref().value` / `computed()`.\n\n## Running any of them\n\n```bash\ngit clone https://github.com/BrainBlend-AI/tesseron\ncd tesseron\npnpm install\npnpm --filter <example-name> dev\n```\n\nThen claim the session from your agent - see the [quickstart](/overview/quickstart/) if you haven't already.\n","bodyText":"All six examples live in [`examples/`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples). Each is a complete, runnable Todo app, intentionally simple so the Tesseron-specific code is easy to read.\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` (in `clearCompleted`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.elicit` with schema (in `renameTodo`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.progress` (in `importTodos`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `ctx.sample` (in `suggestTodos`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\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-todo](/examples/node-todo/)** - the same action declarations on Node. Proves nothing's tied to the browser.\n3. **[express-todo](/examples/express-todo/)** - adds a REST API next to Tesseron. Shows the \"same state, two channels\" pattern.\n4. **[react-todo](/examples/react-todo/)** - hooks-based integration.\n5. **[svelte-todo](/examples/svelte-todo/)** - mutation via `$state` runes.\n6. **[vue-todo](/examples/vue-todo/)** - mutation via `ref().value` / `computed()`.\n\n## Running any of them\n\n```bash\ngit clone https://github.com/BrainBlend-AI/tesseron\ncd tesseron\npnpm install\npnpm --filter <example-name> dev\n```\n\nThen claim the session from your agent - see the [quickstart](/overview/quickstart/) if you haven't already."},{"slug":"examples/node-todo","title":"node-todo","description":"Headless Node service - no HTTP, no browser. Proves the SDK isn't tied to DOM.","section":"examples","related":["sdk/typescript/server"],"bodyRaw":"\n**What it teaches:** a pure-Node Tesseron integration. No Express, no web 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.\n\n**Source:** [`examples/node-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/node-todo)\n\n## Run it\n\n```bash\npnpm --filter node-todo dev\n# prints the claim code to stdout; no browser\n```\n\n## What's inside\n\n```ts title=\"src/index.ts (excerpt)\"\nimport { tesseron } from '@tesseron/server';\nimport { z } from 'zod';\n\nconst todos = new Map<string, Todo>();\n\ntesseron.app({ id: 'node_todo', name: 'Node Todo' });\n\ntesseron.action('addTodo')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.set(todo.id, todo);\n log(`+ addTodo: \"${text}\" (id=${todo.id})`);\n return todo;\n });\n\ntesseron.resource('todoStats')\n .read(() => ({ total: todos.size, completed: [...todos.values()].filter(t => t.done).length }));\n\nconst welcome = await tesseron.connect();\nlog(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n\nprocess.on('SIGINT', async () => { await tesseron.disconnect(); process.exit(0); });\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), structured logging via `log()`, signal-aware shutdown**.\n\nThe same nine actions as `vanilla-todo`, but persistence is an in-memory `Map` and there's no UI - the agent is the only way to interact.\n","bodyText":"**What it teaches:** a pure-Node Tesseron integration. No Express, no web 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.\n\n**Source:** [`examples/node-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/node-todo)\n\n## Run it\n\n```bash\npnpm --filter node-todo dev\n# prints the claim code to stdout; no browser\n```\n\n## What's inside\n\n```ts title=\"src/index.ts (excerpt)\"\n\nconst todos = new Map<string, Todo>();\n\ntesseron.app({ id: 'node_todo', name: 'Node Todo' });\n\ntesseron.action('addTodo')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n todos.set(todo.id, todo);\n log(`+ addTodo: \"${text}\" (id=${todo.id})`);\n return todo;\n });\n\ntesseron.resource('todoStats')\n .read(() => ({ total: todos.size, completed: [...todos.values()].filter(t => t.done).length }));\n\nconst welcome = await tesseron.connect();\nlog(`Tesseron ready. Claim code: ${welcome.claimCode}`);\n\nprocess.on('SIGINT', async () => { await tesseron.disconnect(); process.exit(0); });\n```\n\nFeatures exercised: **actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), structured logging via `log()`, signal-aware shutdown**.\n\nThe same nine actions as `vanilla-todo`, but persistence is an in-memory `Map` and there's no UI - the agent is the only way to interact."},{"slug":"examples/react-todo","title":"react-todo","description":"React 18 + `@tesseron/react` hooks. Idiomatic integration with component lifecycle.","section":"examples","related":["sdk/typescript/react","sdk/typescript/web"],"bodyRaw":"\n**What it teaches:** declarative action registration in React. Mount = register; unmount = unregister. State is mutated through `setTodos` exactly like in a normal React app.\n\n**Source:** [`examples/react-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/react-todo)\n\n## Run it\n\n```bash\npnpm --filter react-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```tsx title=\"src/app.tsx (excerpt)\"\nimport { useTesseronAction, useTesseronResource, useTesseronConnection } from '@tesseron/react';\nimport { z } from 'zod';\nimport { useState } from 'react';\n\nexport function App() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.claimCode && <ClaimBanner code={conn.claimCode} />}\n <TodoList todos={todos} />\n </>\n );\n}\n```\n\nFeatures exercised: **all three React hooks (`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`), annotations, Zod input, setState-driven UI reactivity, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), subscribable resources**.\n\nSee the [React adapter docs](/sdk/typescript/react/) for the full hook API.\n","bodyText":"**What it teaches:** declarative action registration in React. Mount = register; unmount = unregister. State is mutated through `setTodos` exactly like in a normal React app.\n\n**Source:** [`examples/react-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/react-todo)\n\n## Run it\n\n```bash\npnpm --filter react-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```tsx title=\"src/app.tsx (excerpt)\"\n\nexport function App() {\n const [todos, setTodos] = useState<Todo[]>([]);\n const conn = useTesseronConnection();\n\n useTesseronAction('addTodo', {\n description: 'Add a new todo item. Returns the created todo.',\n input: z.object({ text: z.string().min(1) }),\n handler: ({ text }) => {\n const todo = { id: newId(), text, done: false };\n setTodos((prev) => [...prev, todo]);\n return todo;\n },\n });\n\n useTesseronResource('todoStats', () => ({\n total: todos.length,\n completed: todos.filter((t) => t.done).length,\n }));\n\n return (\n <>\n {conn.claimCode && }\n \n </>\n );\n}\n```\n\nFeatures exercised: **all three React hooks (`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`), annotations, Zod input, setState-driven UI reactivity, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), subscribable resources**.\n\nSee the [React adapter docs](/sdk/typescript/react/) for the full hook API."},{"slug":"examples/svelte-todo","title":"svelte-todo","description":"Svelte 5 runes (`$state`, `$derived`). Mutation via direct reassignment.","section":"examples","related":["sdk/typescript/web"],"bodyRaw":"\n**What it teaches:** integrating Tesseron with Svelte 5's rune-based reactivity. Handlers reassign `let todos = $state(...)` and Svelte re-renders.\n\n**Source:** [`examples/svelte-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/svelte-todo)\n\n## Run it\n\n```bash\npnpm --filter svelte-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```svelte title=\"src/app.svelte (excerpt)\"\n<script lang=\"ts\">\n import { tesseron } from '@tesseron/web';\n import { z } from 'zod';\n import { onMount } from 'svelte';\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 tesseron.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 todos = [...todos, todo]; // reassign - Svelte observes $state\n return todo;\n });\n\n tesseron.resource('todoStats')\n .read(() => ({ total: todos.length, completed: todos.filter((t) => t.done).length }));\n\n onMount(async () => {\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n });\n</script>\n```\n\nFeatures exercised: **`$state` / `$derived` runes, actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`, with a graceful fallback when the client doesn't advertise sampling), connection inside `onMount`**.\n\nThere isn't a Svelte-specific package - `@tesseron/web` composes cleanly with runes. If you'd like a `useTesseron*` rune-style API, it's a small wrapper to build - open an issue if you'd use it.\n","bodyText":"**What it teaches:** integrating Tesseron with Svelte 5's rune-based reactivity. Handlers reassign `let todos = $state(...)` and Svelte re-renders.\n\n**Source:** [`examples/svelte-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/svelte-todo)\n\n## Run it\n\n```bash\npnpm --filter svelte-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```svelte title=\"src/app.svelte (excerpt)\"\n<script lang=\"ts\">\n import { tesseron } from '@tesseron/web';\n import { z } from 'zod';\n import { onMount } from 'svelte';\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 tesseron.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 todos = [...todos, todo]; // reassign - Svelte observes $state\n return todo;\n });\n\n tesseron.resource('todoStats')\n .read(() => ({ total: todos.length, completed: todos.filter((t) => t.done).length }));\n\n onMount(async () => {\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n });\n</script>\n```\n\nFeatures exercised: **`$state` / `$derived` runes, actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`, with a graceful fallback when the client doesn't advertise sampling), connection inside `onMount`**.\n\nThere isn't a Svelte-specific package - `@tesseron/web` composes cleanly with runes. If you'd like a `useTesseron*` rune-style API, it's a small wrapper to build - open an issue if you'd use it."},{"slug":"examples/vanilla-todo","title":"vanilla-todo","description":"Plain Vite + TypeScript. The minimum environment for exercising the SDK.","section":"examples","related":["sdk/typescript/web","sdk/typescript/index"],"bodyRaw":"\n**What it teaches:** the raw action / resource builder API with no framework in the way. Read this before any of the framework-specific examples.\n\n**Source:** [`examples/vanilla-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vanilla-todo)\n\n## Run it\n\n```bash\npnpm --filter vanilla-todo dev\n# opens http://localhost:5173\n```\n\n## What's inside\n\n```ts title=\"src/main.ts (excerpt)\"\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'vanilla_todo', name: 'Vanilla Todo' });\n\ntesseron\n .action('addTodo')\n .describe('Add a new todo item. Returns the created todo.')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n state.todos = [...state.todos, todo];\n render();\n return todo;\n });\n\ntesseron.action('toggleTodo')\n .input(z.object({ id: z.string() }))\n .annotate({ destructive: true })\n .handler(/* … */);\n\ntesseron.resource('todoStats')\n .read(() => ({ total: state.todos.length, completed: state.todos.filter(t => t.done).length }));\n\nawait tesseron.connect();\n```\n\nNine actions (`addTodo`, `toggleTodo`, `deleteTodo`, `listTodos`, `setFilter`, `clearCompleted`, `renameTodo`, `importTodos`, `suggestTodos`) and two subscribable resources (`currentFilter`, `todoStats`) - a realistic-but-contained surface for experimenting.\n\nFeatures exercised: **actions, annotations (`destructive`, `requiresConfirmation`, `readOnly`), subscribable resources, Zod input validation, `ctx.confirm` (in `clearCompleted`), `ctx.elicit` with schema (in `renameTodo`), `ctx.progress` (in `importTodos`), `ctx.sample` (in `suggestTodos`), connection lifecycle**.\n","bodyText":"**What it teaches:** the raw action / resource builder API with no framework in the way. Read this before any of the framework-specific examples.\n\n**Source:** [`examples/vanilla-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vanilla-todo)\n\n## Run it\n\n```bash\npnpm --filter vanilla-todo dev\n# opens http://localhost:5173\n```\n\n## What's inside\n\n```ts title=\"src/main.ts (excerpt)\"\n\ntesseron.app({ id: 'vanilla_todo', name: 'Vanilla Todo' });\n\ntesseron\n .action('addTodo')\n .describe('Add a new todo item. Returns the created todo.')\n .input(z.object({ text: z.string().min(1) }))\n .handler(({ text }) => {\n const todo = { id: newId(), text, done: false };\n state.todos = [...state.todos, todo];\n render();\n return todo;\n });\n\ntesseron.action('toggleTodo')\n .input(z.object({ id: z.string() }))\n .annotate({ destructive: true })\n .handler(/* … */);\n\ntesseron.resource('todoStats')\n .read(() => ({ total: state.todos.length, completed: state.todos.filter(t => t.done).length }));\n\nawait tesseron.connect();\n```\n\nNine actions (`addTodo`, `toggleTodo`, `deleteTodo`, `listTodos`, `setFilter`, `clearCompleted`, `renameTodo`, `importTodos`, `suggestTodos`) and two subscribable resources (`currentFilter`, `todoStats`) - a realistic-but-contained surface for experimenting.\n\nFeatures exercised: **actions, annotations (`destructive`, `requiresConfirmation`, `readOnly`), subscribable resources, Zod input validation, `ctx.confirm` (in `clearCompleted`), `ctx.elicit` with schema (in `renameTodo`), `ctx.progress` (in `importTodos`), `ctx.sample` (in `suggestTodos`), connection lifecycle**."},{"slug":"examples/vue-todo","title":"vue-todo","description":"Vue 3 composition API with `ref()` and `computed()`.","section":"examples","related":["sdk/typescript/web"],"bodyRaw":"\n**What it teaches:** integrating Tesseron with Vue 3's reactivity. Handlers mutate `todos.value` and `computed()` recomputes downstream derived state.\n\n**Source:** [`examples/vue-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vue-todo)\n\n## Run it\n\n```bash\npnpm --filter vue-todo dev\n# http://localhost:5173\n```\n\n## What's inside\n\n```vue title=\"src/app.vue (excerpt)\"\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue';\nimport { tesseron } from '@tesseron/web';\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\ntesseron.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 todos.value = [...todos.value, todo]; // .value mutation triggers reactivity\n return todo;\n });\n\ntesseron.resource('todoStats')\n .read(() => ({ total: todos.value.length, completed: todos.value.filter((t) => t.done).length }));\n\nonMounted(async () => {\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n});\n</script>\n```\n\nFeatures exercised: **`ref` + `computed`, actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), connection inside `onMounted`**.\n\nLike Svelte, Vue doesn't need a dedicated adapter package - `@tesseron/web` composes with the composition API directly.\n","bodyText":"**What it teaches:** integrating Tesseron with Vue 3's reactivity. Handlers mutate `todos.value` and `computed()` recomputes downstream derived state.\n\n**Source:** [`examples/vue-todo`](https://github.com/BrainBlend-AI/tesseron/tree/main/examples/vue-todo)\n\n## Run it\n\n```bash\npnpm --filter vue-todo dev\n# http://localhost:5173\n```\n\n## What's inside\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\ntesseron.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 todos.value = [...todos.value, todo]; // .value mutation triggers reactivity\n return todo;\n });\n\ntesseron.resource('todoStats')\n .read(() => ({ total: todos.value.length, completed: todos.value.filter((t) => t.done).length }));\n\nonMounted(async () => {\n const welcome = await tesseron.connect();\n console.log('claim code:', welcome.claimCode);\n});\n</script>\n```\n\nFeatures exercised: **`ref` + `computed`, actions, annotations, subscribable resources, `ctx.confirm` (`clearCompleted`), `ctx.elicit` with schema (`renameTodo`), `ctx.progress` (`importTodos`), `ctx.sample` (`suggestTodos`), connection inside `onMounted`**.\n\nLike Svelte, Vue doesn't need a dedicated adapter package - `@tesseron/web` composes with the composition API directly."},{"slug":"index","title":"Tesseron","description":"Expose typed web-app actions to MCP-compatible AI agents over WebSocket. No browser automation, no scraping.","section":"","related":["overview/quickstart","overview/why","overview/architecture"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Diagram from '../../components/Diagram.astro';\n\n<Diagram\n caption=\"Your web app declares actions. The MCP gateway bridges them to any MCP-capable agent (Claude Code, Cursor, Claude Desktop).\"\n nodeWidth={130}\n spacing={115}\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', code: '@tesseron/web', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'WebSocket + MCP', code: '@tesseron/mcp :7475', 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 { from: 'app', to: 'gw', label: 'WebSocket', bidirectional: true, accent: true },\n { from: 'gw', to: 'agent', label: 'MCP stdio', bidirectional: true, accent: true },\n ]}\n/>\n\n## What you get\n\n<CardGrid>\n <Card title=\"Typed actions\" icon=\"seti:typescript\">\n Declare actions with a fluent builder backed by any [Standard Schema](https://standardschema.dev) validator - Zod, Valibot, ArkType, Effect Schema. The MCP tool schema is derived automatically.\n </Card>\n <Card title=\"Real UI, not a shadow DOM\" icon=\"open-book\">\n The agent drives your actual running app. State, auth, feature flags - all intact. Nothing to scrape, nothing to re-implement.\n </Card>\n <Card title=\"Full MCP capability set\" icon=\"rocket\">\n Streaming progress, cancellation, resources (read + subscribe), sampling, and elicitation work out of the box over a single WebSocket.\n </Card>\n <Card title=\"Framework-agnostic\" icon=\"puzzle\">\n One-file integrations for vanilla TS, React, Svelte, Vue, Node, and Express. Same builder API everywhere.\n </Card>\n</CardGrid>\n\n## Read the docs in two halves\n\n<CardGrid>\n <LinkCard\n title=\"Protocol\"\n href=\"./protocol/\"\n description=\"The wire format, handshake, action model, and advanced MCP features - with a diagram for every flow.\"\n />\n <LinkCard\n title=\"SDK\"\n href=\"./sdk/\"\n description=\"Build with @tesseron/web, /server, /react, or port Tesseron to a new language.\"\n />\n</CardGrid>\n\n## 60-second taste\n\n```ts title=\"src/main.ts\"\nimport { tesseron } from '@tesseron/web';\nimport { z } from 'zod';\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\n\n// 1. A plain action - input, handler, streaming progress, return value.\ntesseron\n .action('searchProducts')\n .describe('Search the product catalog')\n .input(z.object({ query: z.string().min(1), limit: z.number().default(10) }))\n .handler(async ({ query, limit }, ctx) => {\n ctx.progress({ message: 'searching...', percent: 20 });\n const items = await store.search(query, { limit });\n return { items }; // becomes the MCP tool result the agent sees\n });\n\n// 2. An action that pauses to ask the user through the agent's UI.\ntesseron\n .action('checkout')\n .describe('Place the pending order')\n .input(z.object({ cartId: z.string() }))\n .handler(async ({ cartId }, ctx) => {\n const ok = await ctx.confirm({\n question: `Place order for $${cart.total(cartId)}? This charges your card.`,\n });\n if (!ok) throw new Error('User cancelled');\n return await orders.place(cartId);\n });\n\n// 3. A resource - readable, subscribable app state. No polling needed.\ntesseron\n .resource('currentRoute')\n .describe('URL the user is viewing')\n .read(() => location.pathname)\n .subscribe((emit) => {\n const fn = () => emit(location.pathname);\n addEventListener('popstate', fn);\n return () => removeEventListener('popstate', fn);\n });\n\n// 4. Connect. `connect()` resolves with the claim code - surface it\n// in your UI so the human can paste it into their agent.\nconst { claimCode } = await tesseron.connect();\ndocument.querySelector('#connect-banner')!.textContent =\n `Paste \"${claimCode}\" into Claude to connect this tab.`;\n```\n\n**What the agent sees once connected:**\n\n- Two MCP tools: `shop__searchProducts` and `shop__checkout`. It can call either, pass typed input, and receive your typed output.\n- One resource: `tesseron://shop/currentRoute`. It can read once, or subscribe and get pushed updates every time the user navigates - no polling, no webhooks.\n\n**What you didn't have to do:**\n\n- No HTTP server. The WebSocket goes to the gateway that runs next to the agent.\n- No OpenAPI spec, no tool schemas. They're derived from your Zod validators.\n- No glue between tools. The agent reads `searchProducts`'s output, picks a product, calls `checkout` with it, and pauses on `ctx.confirm` until the user approves - all orchestrated by the agent loop.\n\nThat's the whole surface: `.action()`, `.resource()`, and `.connect()`. Everything else is detail.\n\n**The other half runs next to the agent.** The gateway is `@tesseron/mcp` - an MCP server that opens the WebSocket port, hands out claim codes, and translates MCP tool calls into `actions/invoke` frames on your app's socket. You don't write MCP code; the gateway *is* the MCP server.\n\nYou wire it into your agent's MCP config once. Claude Desktop example (`claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@tesseron/mcp\"]\n }\n }\n}\n```\n\nClaude Code / Cursor / any MCP-capable client: same pattern, their own config file.\n","bodyText":"## What you get\n\n## Read the docs in two halves\n\n## 60-second taste\n\n```ts title=\"src/main.ts\"\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\n\n// 1. A plain action - input, handler, streaming progress, return value.\ntesseron\n .action('searchProducts')\n .describe('Search the product catalog')\n .input(z.object({ query: z.string().min(1), limit: z.number().default(10) }))\n .handler(async ({ query, limit }, ctx) => {\n ctx.progress({ message: 'searching...', percent: 20 });\n const items = await store.search(query, { limit });\n return { items }; // becomes the MCP tool result the agent sees\n });\n\n// 2. An action that pauses to ask the user through the agent's UI.\ntesseron\n .action('checkout')\n .describe('Place the pending order')\n .input(z.object({ cartId: z.string() }))\n .handler(async ({ cartId }, ctx) => {\n const ok = await ctx.confirm({\n question: `Place order for $${cart.total(cartId)}? This charges your card.`,\n });\n if (!ok) throw new Error('User cancelled');\n return await orders.place(cartId);\n });\n\n// 3. A resource - readable, subscribable app state. No polling needed.\ntesseron\n .resource('currentRoute')\n .describe('URL the user is viewing')\n .read(() => location.pathname)\n .subscribe((emit) => {\n const fn = () => emit(location.pathname);\n addEventListener('popstate', fn);\n return () => removeEventListener('popstate', fn);\n });\n\n// 4. Connect. `connect()` resolves with the claim code - surface it\n// in your UI so the human can paste it into their agent.\nconst { claimCode } = await tesseron.connect();\ndocument.querySelector('#connect-banner')!.textContent =\n `Paste \"${claimCode}\" into Claude to connect this tab.`;\n```\n\n**What the agent sees once connected:**\n\n- Two MCP tools: `shop__searchProducts` and `shop__checkout`. It can call either, pass typed input, and receive your typed output.\n- One resource: `tesseron://shop/currentRoute`. It can read once, or subscribe and get pushed updates every time the user navigates - no polling, no webhooks.\n\n**What you didn't have to do:**\n\n- No HTTP server. The WebSocket goes to the gateway that runs next to the agent.\n- No OpenAPI spec, no tool schemas. They're derived from your Zod validators.\n- No glue between tools. The agent reads `searchProducts`'s output, picks a product, calls `checkout` with it, and pauses on `ctx.confirm` until the user approves - all orchestrated by the agent loop.\n\nThat's the whole surface: `.action()`, `.resource()`, and `.connect()`. Everything else is detail.\n\n**The other half runs next to the agent.** The gateway is `@tesseron/mcp` - an MCP server that opens the WebSocket port, hands out claim codes, and translates MCP tool calls into `actions/invoke` frames on your app's socket. You don't write MCP code; the gateway *is* the MCP server.\n\nYou wire it into your agent's MCP config once. Claude Desktop example (`claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tesseron\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@tesseron/mcp\"]\n }\n }\n}\n```\n\nClaude Code / Cursor / any MCP-capable client: same pattern, their own config file."},{"slug":"overview/architecture","title":"Architecture at a glance","description":"The three moving parts - your app, the MCP gateway, the agent - and how a single action flows between them.","section":"overview","related":["overview/quickstart","protocol/handshake","protocol/actions","sdk/typescript/mcp"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\n<Diagram\n caption=\"Three processes, two protocols. Your app speaks JSON-RPC over WebSocket to the MCP gateway; the gateway speaks MCP stdio to the agent.\"\n nodes={[\n { id: 'user', label: 'USER', sub: ['human at', 'the keyboard'], icon: 'user' },\n { id: 'app', label: 'YOUR APP', sub: 'browser or node', code: '@tesseron/web', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'WebSocket + MCP', code: '@tesseron/mcp :7475', 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 { 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 process. Hosts the action handlers and the real state they mutate. Uses `@tesseron/web`, `@tesseron/server`, or a framework adapter.\n- **The MCP gateway** - a small Node process (`@tesseron/mcp`) bundled into the Claude Code plugin. Listens on `ws://127.0.0.1:7475` for your app and on stdio for the agent. Translates between the two.\n- **The agent** - Claude Code, Claude Desktop, Cursor, or any other MCP client. Doesn't know or care about WebSockets - 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 |\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- **Origin allowlist.** Non-localhost origins are rejected at the upgrade handshake unless explicitly allowed.\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\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 process. Hosts the action handlers and the real state they mutate. Uses `@tesseron/web`, `@tesseron/server`, or a framework adapter.\n- **The MCP gateway** - a small Node process (`@tesseron/mcp`) bundled into the Claude Code plugin. Listens on `ws://127.0.0.1:7475` for your app and on stdio for the agent. Translates between the two.\n- **The agent** - Claude Code, Claude Desktop, Cursor, or any other MCP client. Doesn't know or care about WebSockets - 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 |\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- **Origin allowlist.** Non-localhost origins are rejected at the upgrade handshake unless explicitly allowed.\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\nNext: the [5-minute quickstart](/overview/quickstart/) walks through getting this running end-to-end."},{"slug":"overview/quickstart","title":"Quickstart (5 minutes)","description":"Install the plugin, drop the SDK into an app, declare one action, watch Claude call it.","section":"overview","related":["sdk/typescript/index","sdk/typescript/action-builder","overview/architecture","examples/index"],"bodyRaw":"\nimport { Steps, Tabs, TabItem } from '@astrojs/starlight/components';\n\n**Prereqs.** Node ≥ 20. Claude Code installed.\n\n<Steps>\n\n1. **Install the Claude Code plugin.** It bundles the MCP gateway and auto-registers it as an MCP server.\n\n ```text\n /plugin marketplace add BrainBlend-AI/tesseron\n /plugin install tesseron@tesseron\n ```\n\n Restart Claude Code after installation. The gateway now runs whenever the plugin is enabled; no separate process to manage.\n\n2. **Add the SDK to your app.**\n\n <Tabs>\n <TabItem label=\"Browser / Vite\">\n ```bash\n pnpm add @tesseron/web zod\n ```\n </TabItem>\n <TabItem label=\"React\">\n ```bash\n pnpm add @tesseron/react zod\n ```\n </TabItem>\n <TabItem label=\"Node / server\">\n ```bash\n pnpm add @tesseron/server zod\n ```\n </TabItem>\n </Tabs>\n\n3. **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 `tesseron.connect()` opens the WebSocket and resolves once the gateway returns a `welcome` with a `claimCode`.\n\n4. **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\n5. **Call your action.** The tool list now contains `notes__createNote`. Ask Claude:\n\n > \"Create a note titled 'Groceries' with body 'eggs, milk, bread'.\"\n\n The handler runs inside your tab. The new note appears in your UI, reactively. Claude sees the returned object as the tool result.\n\n</Steps>\n\n## Next steps\n\n- [Add progress + cancellation](/protocol/progress-cancellation/) - for actions that take more than a beat.\n- [Expose resources](/protocol/resources/) - let Claude read your UI state (current route, selected item, filter settings).\n- [Use sampling](/protocol/sampling/) - let your handler ask the agent's LLM mid-execution.\n- [Pick your framework adapter](/sdk/) - React, Svelte, Vue, Express patterns.\n","bodyText":"**Prereqs.** Node ≥ 20. Claude Code installed.\n\n## Next steps\n\n- [Add progress + cancellation](/protocol/progress-cancellation/) - for actions that take more than a beat.\n- [Expose resources](/protocol/resources/) - let Claude read your UI state (current route, selected item, filter settings).\n- [Use sampling](/protocol/sampling/) - let your handler ask the agent's LLM mid-execution.\n- [Pick your framework adapter](/sdk/) - React, Svelte, Vue, Express patterns."},{"slug":"overview/why","title":"Why Tesseron?","description":"The problem Tesseron solves, and where it fits relative to browser automation, chat widgets, and custom APIs.","section":"overview","related":["overview/architecture","protocol/index"],"bodyRaw":"\nAgents are great at reasoning about what to do. They're bad at reaching into your app to do it.\n\nThere are three common ways to close that gap. Tesseron is a fourth.\n\n## 1. Browser automation (Playwright, Selenium, Computer Use)\n\nThe agent drives a pixel-level browser. Conceptually powerful, practically fragile: every layout tweak breaks selectors, every modal needs bespoke handling, every authentication flow is re-solved from scratch. Token-heavy. Slow.\n\n## 2. Chat widget embedded in the app\n\nYou bolt an AI sidebar into your UI and wire up tool calls manually. The agent can talk to your backend, but it can't touch the running UI state the user is looking at. Two worlds that never meet.\n\n## 3. A bespoke MCP server for your backend\n\nGreat for headless automation. Useless for \"put this in the user's cart on the page they're already viewing.\" The user's session, their open tab, their in-memory draft - all invisible to a backend MCP server.\n\n## 4. Tesseron\n\nThe running app opens a WebSocket to a local MCP gateway and declares its actions:\n\n```ts\ntesseron.action('addToCart').input(...).handler(...);\n```\n\nThe gateway exposes those actions as MCP tools over stdio. Any MCP-capable agent - Claude Code, Cursor, Claude Desktop, any other - sees them and calls them. The handler runs inside the user's real tab, with their real state, their real auth.\n\n## Tradeoffs (be honest)\n\n- **Localhost by default.** Tesseron is a local-first developer tool. The default gateway binds to `127.0.0.1:7475` and rejects non-localhost origins. Remote agents require an allowlist.\n- **Requires the tab to be open.** If the page is closed, the session is gone. This is a feature - it keeps the agent bound to what the user can see.\n- **Not a replacement for a headless API.** If you need scheduled or unattended automation, you want a server-side MCP. Tesseron complements it - it doesn't replace it.\n\n## When Tesseron is the right fit\n\n- Internal tools where power users want to drive the UI via chat.\n- Complex workflows that already exist as UI actions - search, filter, create, approve - and shouldn't be duplicated on the backend.\n- Product demos and prototypes where \"the agent actually does what the user sees\" is the whole point.\n- Personal dashboards, admin panels, CMS editors, developer tooling.\n\nIf you're shipping one of those, keep reading.\n","bodyText":"Agents are great at reasoning about what to do. They're bad at reaching into your app to do it.\n\nThere are three common ways to close that gap. Tesseron is a fourth.\n\n## 1. Browser automation (Playwright, Selenium, Computer Use)\n\nThe agent drives a pixel-level browser. Conceptually powerful, practically fragile: every layout tweak breaks selectors, every modal needs bespoke handling, every authentication flow is re-solved from scratch. Token-heavy. Slow.\n\n## 2. Chat widget embedded in the app\n\nYou bolt an AI sidebar into your UI and wire up tool calls manually. The agent can talk to your backend, but it can't touch the running UI state the user is looking at. Two worlds that never meet.\n\n## 3. A bespoke MCP server for your backend\n\nGreat for headless automation. Useless for \"put this in the user's cart on the page they're already viewing.\" The user's session, their open tab, their in-memory draft - all invisible to a backend MCP server.\n\n## 4. Tesseron\n\nThe running app opens a WebSocket to a local MCP gateway and declares its actions:\n\n```ts\ntesseron.action('addToCart').input(...).handler(...);\n```\n\nThe gateway exposes those actions as MCP tools over stdio. Any MCP-capable agent - Claude Code, Cursor, Claude Desktop, any other - sees them and calls them. The handler runs inside the user's real tab, with their real state, their real auth.\n\n## Tradeoffs (be honest)\n\n- **Localhost by default.** Tesseron is a local-first developer tool. The default gateway binds to `127.0.0.1:7475` and rejects non-localhost origins. Remote agents require an allowlist.\n- **Requires the tab to be open.** If the page is closed, the session is gone. This is a feature - it keeps the agent bound to what the user can see.\n- **Not a replacement for a headless API.** If you need scheduled or unattended automation, you want a server-side MCP. Tesseron complements it - it doesn't replace it.\n\n## When Tesseron is the right fit\n\n- Internal tools where power users want to drive the UI via chat.\n- Complex workflows that already exist as UI actions - search, filter, create, approve - and shouldn't be duplicated on the backend.\n- Product demos and prototypes where \"the agent actually does what the user sees\" is the whole point.\n- Personal dashboards, admin panels, CMS editors, developer tooling.\n\nIf you're shipping one of those, keep reading."},{"slug":"protocol/actions","title":"Action model","description":"How actions are declared, namespaced, invoked, validated, and returned.","section":"protocol","related":["sdk/typescript/action-builder","protocol/wire-format","protocol/elicitation","protocol/sampling","protocol/progress-cancellation"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nAn **action** is a named, typed, handler-backed operation that the app exposes to the agent. On the MCP side it looks like a single tool. On the Tesseron side it has a schema, a handler, and a set of per-invocation context helpers.\n\n<Sequence\n caption=\"One invocation from tools/call to tool result - with input validation between.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: \"tools/call { name: 'shop__addItem', arguments }\" },\n { from: 'g', to: 's', label: 'actions/invoke { name, invocationId, input }' },\n { note: 's', label: 'validate input (Standard Schema)' },\n { note: 's', label: 'run handler(input, ctx)' },\n { from: 's', to: 'g', label: \"result { id: 'item_42', ... }\", style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## Declaration\n\n```ts\ntesseron\n .action('addItem') // action name\n .describe('Add an item to the cart')\n .input(z.object({ sku: z.string(), quantity: z.number().int().positive() }))\n .output(z.object({ cartId: z.string(), itemId: z.string() }))\n .annotate({ destructive: false })\n .timeout({ ms: 10_000 })\n .handler(async ({ sku, quantity }, ctx) => {\n const item = await cart.add(sku, quantity);\n return { cartId: cart.id, itemId: item.id };\n });\n```\n\nBuilder steps:\n\n| Step | Purpose | Required? |\n|---|---|---|\n| `.describe(string)` | Human-readable description shown to the agent's LLM. | Recommended |\n| `.input(schema)` | Standard Schema validator for arguments. Becomes JSON Schema on the wire. | Recommended |\n| `.output(schema)` | Validator for the return value. Not enforced by default. | Optional |\n| `.annotate({…})` | Metadata: `readOnly`, `destructive`, `requiresConfirmation`. | Optional |\n| `.timeout(ms)` | Abort the invocation after N ms. Default 60 000. | Optional |\n| `.strictOutput()` | Enforce the output schema. Default is passthrough. | Optional |\n| `.handler(fn)` | The function that runs. Receives `(input, ctx)`. Terminates the builder. | Required |\n\n## Naming and the MCP tool list\n\nThe MCP gateway registers every action as an MCP tool under the name `<app.id>__<action.name>`. For `app.id = \"shop\"` and `action = \"searchProducts\"` the agent sees `shop__searchProducts`. Multiple apps can coexist - see [multi-app coexistence](/protocol/security/#multi-app-coexistence).\n\n## Invocation wire format\n\nRequest from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"method\": \"actions/invoke\",\n \"params\": {\n \"name\": \"addItem\",\n \"invocationId\": \"inv_abc123\",\n \"input\": { \"sku\": \"SKU-1\", \"quantity\": 2 },\n \"client\": { \"route\": \"/cart\" }\n }\n}\n```\n\nThe SDK turns `params` into an `ActionContext` and calls your handler. `ctx.agent`, `ctx.agentCapabilities`, and the rest of `ctx.client` come from the `welcome` the SDK cached at handshake time - they don't ride on every `actions/invoke`. Response:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"result\": { \"cartId\": \"c_1\", \"itemId\": \"i_42\" }\n}\n```\n\nOr, on error:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"error\": { \"code\": -32005, \"message\": \"Cart is locked\", \"data\": { \"cartId\": \"c_1\" } }\n}\n```\n\n## Validation passes\n\n- **Input** is validated **before** the handler runs. Failure → error code `-32004 InputValidation`, handler never fires. Validation `issues` are returned in `error.data`.\n- **Output** is **not** validated by default. Call `.strictOutput()` to enforce - failure becomes `-32005 HandlerError` with issues in `data`. The permissive default is deliberate: output schemas are often loose, and most teams use `.output()` purely for documentation.\n\n## Annotations\n\n| Field | Meaning |\n|---|---|\n| `readOnly` | The action doesn't mutate state. The agent may parallelise or cache. |\n| `destructive` | The action mutates persistent state. Agents SHOULD surface a confirmation UI. |\n| `requiresConfirmation` | The action MUST NOT be called without explicit user confirmation. |\n\nAnnotations are **advisory**. They ride along with the MCP tool descriptor; honouring them is the agent's job.\n\n## What the handler receives\n\n```ts\nhandler: (input: I, ctx: ActionContext) => O | Promise<O>\n```\n\n`ctx` is the per-invocation context. Full reference in [SDK → Context](/sdk/typescript/context/):\n\n| Field | Purpose |\n|---|---|\n| `ctx.signal` | `AbortSignal` fired on timeout or cancel. |\n| `ctx.agent` | `{ id, name }` of the calling agent. |\n| `ctx.agentCapabilities` | What the agent can do. Gate your sampling / elicit calls on this. |\n| `ctx.client` | `{ origin, route?, userAgent? }`. |\n| `ctx.progress(update)` | Emit an `actions/progress` notification. |\n| `ctx.sample(req)` | Re-enter the agent LLM for a reasoning step. |\n| `ctx.confirm(req)` | Ask the user a yes/no question. Returns `false` when the client can't prompt. |\n| `ctx.elicit(req)` | Ask the user for structured content matching a schema. |\n| `ctx.log({ level, message, meta? })` | Structured log forwarded to MCP logging. |\n\nNext: [progress & cancellation](/protocol/progress-cancellation/).\n","bodyText":"An **action** is a named, typed, handler-backed operation that the app exposes to the agent. On the MCP side it looks like a single tool. On the Tesseron side it has a schema, a handler, and a set of per-invocation context helpers.\n\n## Declaration\n\n```ts\ntesseron\n .action('addItem') // action name\n .describe('Add an item to the cart')\n .input(z.object({ sku: z.string(), quantity: z.number().int().positive() }))\n .output(z.object({ cartId: z.string(), itemId: z.string() }))\n .annotate({ destructive: false })\n .timeout({ ms: 10_000 })\n .handler(async ({ sku, quantity }, ctx) => {\n const item = await cart.add(sku, quantity);\n return { cartId: cart.id, itemId: item.id };\n });\n```\n\nBuilder steps:\n\n| Step | Purpose | Required? |\n|---|---|---|\n| `.describe(string)` | Human-readable description shown to the agent's LLM. | Recommended |\n| `.input(schema)` | Standard Schema validator for arguments. Becomes JSON Schema on the wire. | Recommended |\n| `.output(schema)` | Validator for the return value. Not enforced by default. | Optional |\n| `.annotate({…})` | Metadata: `readOnly`, `destructive`, `requiresConfirmation`. | Optional |\n| `.timeout(ms)` | Abort the invocation after N ms. Default 60 000. | Optional |\n| `.strictOutput()` | Enforce the output schema. Default is passthrough. | Optional |\n| `.handler(fn)` | The function that runs. Receives `(input, ctx)`. Terminates the builder. | Required |\n\n## Naming and the MCP tool list\n\nThe MCP gateway registers every action as an MCP tool under the name `<app.id>__<action.name>`. For `app.id = \"shop\"` and `action = \"searchProducts\"` the agent sees `shop__searchProducts`. Multiple apps can coexist - see [multi-app coexistence](/protocol/security/#multi-app-coexistence).\n\n## Invocation wire format\n\nRequest from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"method\": \"actions/invoke\",\n \"params\": {\n \"name\": \"addItem\",\n \"invocationId\": \"inv_abc123\",\n \"input\": { \"sku\": \"SKU-1\", \"quantity\": 2 },\n \"client\": { \"route\": \"/cart\" }\n }\n}\n```\n\nThe SDK turns `params` into an `ActionContext` and calls your handler. `ctx.agent`, `ctx.agentCapabilities`, and the rest of `ctx.client` come from the `welcome` the SDK cached at handshake time - they don't ride on every `actions/invoke`. Response:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"result\": { \"cartId\": \"c_1\", \"itemId\": \"i_42\" }\n}\n```\n\nOr, on error:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 7,\n \"error\": { \"code\": -32005, \"message\": \"Cart is locked\", \"data\": { \"cartId\": \"c_1\" } }\n}\n```\n\n## Validation passes\n\n- **Input** is validated **before** the handler runs. Failure → error code `-32004 InputValidation`, handler never fires. Validation `issues` are returned in `error.data`.\n- **Output** is **not** validated by default. Call `.strictOutput()` to enforce - failure becomes `-32005 HandlerError` with issues in `data`. The permissive default is deliberate: output schemas are often loose, and most teams use `.output()` purely for documentation.\n\n## Annotations\n\n| Field | Meaning |\n|---|---|\n| `readOnly` | The action doesn't mutate state. The agent may parallelise or cache. |\n| `destructive` | The action mutates persistent state. Agents SHOULD surface a confirmation UI. |\n| `requiresConfirmation` | The action MUST NOT be called without explicit user confirmation. |\n\nAnnotations are **advisory**. They ride along with the MCP tool descriptor; honouring them is the agent's job.\n\n## What the handler receives\n\n```ts\nhandler: (input: I, ctx: ActionContext) => O | Promise<O>\n```\n\n`ctx` is the per-invocation context. Full reference in [SDK → Context](/sdk/typescript/context/):\n\n| Field | Purpose |\n|---|---|\n| `ctx.signal` | `AbortSignal` fired on timeout or cancel. |\n| `ctx.agent` | `{ id, name }` of the calling agent. |\n| `ctx.agentCapabilities` | What the agent can do. Gate your sampling / elicit calls on this. |\n| `ctx.client` | `{ origin, route?, userAgent? }`. |\n| `ctx.progress(update)` | Emit an `actions/progress` notification. |\n| `ctx.sample(req)` | Re-enter the agent LLM for a reasoning step. |\n| `ctx.confirm(req)` | Ask the user a yes/no question. Returns `false` when the client can't prompt. |\n| `ctx.elicit(req)` | Ask the user for structured content matching a schema. |\n| `ctx.log({ level, message, meta? })` | Structured log forwarded to MCP logging. |\n\nNext: [progress & cancellation](/protocol/progress-cancellation/)."},{"slug":"protocol/elicitation","title":"Elicitation","description":"Handlers pause to ask the user a question. Two verbs - ctx.confirm for yes/no, ctx.elicit for structured content.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\n**Elicitation** is sampling's human sibling. Instead of the LLM generating the next value, the user is prompted through the agent UI and submits the answer themselves.\n\nTesseron exposes two verbs on `ctx`, mapping onto MCP elicit's two orthogonal return fields (`action`, `content`):\n\n- **`ctx.confirm({ question })`** returns `Promise<boolean>`. For yes/no safety gates. No schema.\n- **`ctx.elicit({ question, schema, jsonSchema? })`** returns `Promise<T | null>`. For structured content.\n\nPick by intent: a destructive-op gate is a confirm; a \"which warehouse?\" is an elicit.\n\n## ctx.confirm - yes/no safety gates\n\n```ts\ntesseron.action('clearCompleted')\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (_input, ctx) => {\n const ok = await ctx.confirm({\n question: 'Remove 5 completed todos? This cannot be undone.',\n });\n if (!ok) return { removed: 0, cancelled: true };\n // ... proceed\n });\n```\n\nReturns `true` only on explicit accept. Decline, cancel, and absence of elicitation capability all collapse to `false` - the safe default for destructive ops. You don't need to guard with `ctx.agentCapabilities.elicitation`; `confirm` returns `false` when the client can't prompt.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties JSON Schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render a pure Accept/Decline prompt with no input field.\n\n## ctx.elicit - structured content\n\n```ts\nimport { z } from 'zod';\n\nconst warehouseSchema = z.object({ warehouseId: z.string() });\n\ntesseron.action('checkStock')\n .handler(async (_input, ctx) => {\n const answer = await ctx.elicit({\n question: 'Which warehouse should I check?',\n schema: warehouseSchema,\n jsonSchema: z.toJSONSchema(warehouseSchema),\n });\n if (answer === null) return { cancelled: true };\n return stock.lookup(answer.warehouseId);\n });\n```\n\nReturns the validated value on accept, `null` on decline or cancel. Throws `ElicitationNotAvailableError` (code `-32007`) when the client didn't advertise elicitation - structured data has no safe default, so the handler must branch explicitly.\n\n`jsonSchema` is technically optional; if you omit it, the SDK sends a permissive text-only fallback (`{ response: string }`), which Claude Code renders as a single text input. For good UX, always derive it from your validator - Zod 4 has `z.toJSONSchema(schema)` built in.\n\nMCP elicit constrains `requestedSchema`:\n\n- Top level must be `{ type: \"object\" }`.\n- Each property must be a primitive type (`string`, `number`, `integer`, `boolean`).\n- No `oneOf` / `anyOf` / `allOf` / `not` at the top level.\n\nThe SDK enforces this on send and surfaces an `InvalidParams` error (code `-32602`) at the `ctx.elicit` call site if you send something else.\n\n<Sequence\n caption=\"ctx.confirm and ctx.elicit share the same wire flow - the difference is the requestedSchema they send.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.confirm / ctx.elicit', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'u', label: 'USER', icon: 'user' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'elicitation/request { question, schema }', accent: true },\n { from: 'g', to: 'a', label: 'MCP elicitation/elicit', accent: true },\n { from: 'a', to: 'u', label: 'shows form or Accept/Decline', style: 'dashed' },\n { from: 'u', to: 'a', label: 'submits or declines', style: 'dashed' },\n { from: 'a', to: 'g', label: 'elicitation result', accent: true },\n { from: 'g', to: 's', label: '{ action, value? }', accent: true },\n ]}\n/>\n\n## Wire format\n\nRequest:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"method\": \"elicitation/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"question\": \"Which warehouse should I check?\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": { \"warehouseId\": { \"type\": \"string\" } },\n \"required\": [\"warehouseId\"]\n }\n }\n}\n```\n\nResponse (accept):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"result\": { \"action\": \"accept\", \"value\": { \"warehouseId\": \"WH-7\" } }\n}\n```\n\nResponse (decline / cancel):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 11, \"result\": { \"action\": \"decline\" } }\n```\n\nThe SDK maps `action: 'accept'` to the validated value, `decline` / `cancel` to `null` (for `ctx.elicit`) or `false` (for `ctx.confirm`).\n\n## Capability gate\n\n`ctx.agentCapabilities.elicitation` reflects what the connected MCP client advertised during `initialize`. Claude Code advertises elicitation; earlier clients may not.\n\n- `ctx.confirm` is safe in any handler: missing capability returns `false`, which destructive-op guards treat correctly.\n- `ctx.elicit` throws `ElicitationNotAvailableError` when capability is missing - catch it or pre-check the flag and provide a non-interactive fallback.\n\n## Design hints\n\n- **One question per call.** Don't pack a wizard into a schema - chain actions instead.\n- **Use annotations in tandem.** `{ destructive: true, requiresConfirmation: true }` tells the agent to warn upfront; `ctx.confirm` is what gates.\n- **Avoid chained elicitations** in one handler - latency accumulates. If you need multi-step input, build a dedicated action per step.\n\nNext: [resources](/protocol/resources/) - state the agent can read and subscribe to.\n","bodyText":"**Elicitation** is sampling's human sibling. Instead of the LLM generating the next value, the user is prompted through the agent UI and submits the answer themselves.\n\nTesseron exposes two verbs on `ctx`, mapping onto MCP elicit's two orthogonal return fields (`action`, `content`):\n\n- **`ctx.confirm({ question })`** returns `Promise<boolean>`. For yes/no safety gates. No schema.\n- **`ctx.elicit({ question, schema, jsonSchema? })`** returns `Promise<T | null>`. For structured content.\n\nPick by intent: a destructive-op gate is a confirm; a \"which warehouse?\" is an elicit.\n\n## ctx.confirm - yes/no safety gates\n\n```ts\ntesseron.action('clearCompleted')\n .annotate({ destructive: true, requiresConfirmation: true })\n .handler(async (_input, ctx) => {\n const ok = await ctx.confirm({\n question: 'Remove 5 completed todos? This cannot be undone.',\n });\n if (!ok) return { removed: 0, cancelled: true };\n // ... proceed\n });\n```\n\nReturns `true` only on explicit accept. Decline, cancel, and absence of elicitation capability all collapse to `false` - the safe default for destructive ops. You don't need to guard with `ctx.agentCapabilities.elicitation`; `confirm` returns `false` when the client can't prompt.\n\nUnder the hood, `ctx.confirm` sends an elicit request with an empty-properties JSON Schema (`{ type: 'object', properties: {}, required: [] }`), so MCP clients render a pure Accept/Decline prompt with no input field.\n\n## ctx.elicit - structured content\n\n```ts\n\nconst warehouseSchema = z.object({ warehouseId: z.string() });\n\ntesseron.action('checkStock')\n .handler(async (_input, ctx) => {\n const answer = await ctx.elicit({\n question: 'Which warehouse should I check?',\n schema: warehouseSchema,\n jsonSchema: z.toJSONSchema(warehouseSchema),\n });\n if (answer === null) return { cancelled: true };\n return stock.lookup(answer.warehouseId);\n });\n```\n\nReturns the validated value on accept, `null` on decline or cancel. Throws `ElicitationNotAvailableError` (code `-32007`) when the client didn't advertise elicitation - structured data has no safe default, so the handler must branch explicitly.\n\n`jsonSchema` is technically optional; if you omit it, the SDK sends a permissive text-only fallback (`{ response: string }`), which Claude Code renders as a single text input. For good UX, always derive it from your validator - Zod 4 has `z.toJSONSchema(schema)` built in.\n\nMCP elicit constrains `requestedSchema`:\n\n- Top level must be `{ type: \"object\" }`.\n- Each property must be a primitive type (`string`, `number`, `integer`, `boolean`).\n- No `oneOf` / `anyOf` / `allOf` / `not` at the top level.\n\nThe SDK enforces this on send and surfaces an `InvalidParams` error (code `-32602`) at the `ctx.elicit` call site if you send something else.\n\n## Wire format\n\nRequest:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"method\": \"elicitation/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"question\": \"Which warehouse should I check?\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": { \"warehouseId\": { \"type\": \"string\" } },\n \"required\": [\"warehouseId\"]\n }\n }\n}\n```\n\nResponse (accept):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 11,\n \"result\": { \"action\": \"accept\", \"value\": { \"warehouseId\": \"WH-7\" } }\n}\n```\n\nResponse (decline / cancel):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 11, \"result\": { \"action\": \"decline\" } }\n```\n\nThe SDK maps `action: 'accept'` to the validated value, `decline` / `cancel` to `null` (for `ctx.elicit`) or `false` (for `ctx.confirm`).\n\n## Capability gate\n\n`ctx.agentCapabilities.elicitation` reflects what the connected MCP client advertised during `initialize`. Claude Code advertises elicitation; earlier clients may not.\n\n- `ctx.confirm` is safe in any handler: missing capability returns `false`, which destructive-op guards treat correctly.\n- `ctx.elicit` throws `ElicitationNotAvailableError` when capability is missing - catch it or pre-check the flag and provide a non-interactive fallback.\n\n## Design hints\n\n- **One question per call.** Don't pack a wizard into a schema - chain actions instead.\n- **Use annotations in tandem.** `{ destructive: true, requiresConfirmation: true }` tells the agent to warn upfront; `ctx.confirm` is what gates.\n- **Avoid chained elicitations** in one handler - latency accumulates. If you need multi-step input, build a dedicated action per step.\n\nNext: [resources](/protocol/resources/) - state the agent can read and subscribe to."},{"slug":"protocol/errors","title":"Errors & capabilities","description":"Every error code Tesseron defines, what raises each one, and how capability negotiation shapes handler behaviour.","section":"protocol","related":["protocol/wire-format","protocol/handshake"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nTesseron uses JSON-RPC error codes with a Tesseron-specific extension range.\n\n<Sequence\n caption=\"A validation error path. The handler never runs; the agent gets structured issues it can correct.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call arguments: { query: 42 }' },\n { from: 'g', to: 's', label: 'actions/invoke' },\n { note: 's', label: 'validate input (Standard Schema)', variant: 'danger' },\n { from: 's', to: 'g', label: 'error -32004 InputValidation data: [issues]', danger: true, style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call error (agent can retry with corrected args)', danger: true, style: 'dashed' },\n ]}\n/>\n\n## Error catalog\n\n| Code | Name | Raised when |\n|---:|---|---|\n| `-32700` | `ParseError` | JSON-RPC message failed to parse. Almost always a bug or a non-JSON frame. |\n| `-32600` | `InvalidRequest` | Envelope is well-formed JSON but not a valid JSON-RPC request. |\n| `-32601` | `MethodNotFound` | Method isn't registered. Tesseron's method surface is fixed - this is almost always a version mismatch. |\n| `-32602` | `InvalidParams` | Params don't match the method's expected shape. |\n| `-32603` | `InternalError` | Unhandled exception inside the SDK or gateway. Report it. |\n| `-32000` | `ProtocolMismatch` | `tesseron/hello` sent a `protocolVersion` the gateway doesn't accept. |\n| `-32001` | `Cancelled` | Invocation was cancelled by the agent. |\n| `-32002` | `Timeout` | Invocation exceeded its timeout. |\n| `-32003` | `ActionNotFound` | Agent called an action that isn't registered for this session. |\n| `-32004` | `InputValidation` | Input failed Standard Schema validation. Issues in `error.data`. |\n| `-32005` | `HandlerError` | Handler threw, or output failed strict validation. Message comes from the thrown error. |\n| `-32006` | `SamplingNotAvailable` | Handler called `ctx.sample` but agent didn't advertise sampling. |\n| `-32007` | `ElicitationNotAvailable` | Handler called `ctx.elicit` but agent didn't advertise elicitation. (`ctx.confirm` returns `false` instead of throwing — safe default for destructive gates.) |\n| `-32008` | `SamplingDepthExceeded` | Sampling chain exceeded `maxSamplingDepth` (3). |\n| `-32009` | `Unauthorized` | Wrong claim code, unclaimed session invoking action, or origin not allowlisted. |\n\nErrors carry an optional `data` field. Tesseron uses it to attach:\n\n- **For `-32004` InputValidation**: the `issues` array from Standard Schema.\n- **For `-32005` HandlerError** with strict output: the `issues` for the failed output check.\n- **For `-32008` SamplingDepthExceeded**: `{ depth, max }`.\n\n## Capability negotiation\n\nBoth sides declare capabilities during the handshake. The `welcome` response contains their intersection - that's what your handler should trust.\n\n| Capability | Meaning |\n|---|---|\n| `streaming` | `actions/progress` notifications are allowed. |\n| `subscriptions` | Agent will call `resources/subscribe`. |\n| `sampling` | `ctx.sample` is available. |\n| `elicitation` | `ctx.confirm` and `ctx.elicit` are available. |\n\nYour handler, in general:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n return fallbackResponse();\n}\nconst refined = await ctx.sample({ prompt, schema });\n```\n\nIf you'd rather error out than fall back, just call `ctx.sample` unconditionally - the SDK throws `SamplingNotAvailableError` (code `-32006`) which the agent sees as a structured tool failure.\n\n## Errors are data, not disasters\n\nAgents are good at recovering from structured errors. Prefer returning a well-typed error to throwing a vague one:\n\n- Bad input? Let Standard Schema reject it - the agent gets issues to correct.\n- Impossible state? `throw new Error(\"Cart is locked; ask the user to unlock it\")` surfaces as code `-32005` with a useful message.\n- Need clarification? Use [elicitation](/protocol/elicitation/) instead of failing.\n\nNext: [lifecycle & failure modes](/protocol/lifecycle/).\n","bodyText":"Tesseron uses JSON-RPC error codes with a Tesseron-specific extension range.\n\n## Error catalog\n\n| Code | Name | Raised when |\n|---:|---|---|\n| `-32700` | `ParseError` | JSON-RPC message failed to parse. Almost always a bug or a non-JSON frame. |\n| `-32600` | `InvalidRequest` | Envelope is well-formed JSON but not a valid JSON-RPC request. |\n| `-32601` | `MethodNotFound` | Method isn't registered. Tesseron's method surface is fixed - this is almost always a version mismatch. |\n| `-32602` | `InvalidParams` | Params don't match the method's expected shape. |\n| `-32603` | `InternalError` | Unhandled exception inside the SDK or gateway. Report it. |\n| `-32000` | `ProtocolMismatch` | `tesseron/hello` sent a `protocolVersion` the gateway doesn't accept. |\n| `-32001` | `Cancelled` | Invocation was cancelled by the agent. |\n| `-32002` | `Timeout` | Invocation exceeded its timeout. |\n| `-32003` | `ActionNotFound` | Agent called an action that isn't registered for this session. |\n| `-32004` | `InputValidation` | Input failed Standard Schema validation. Issues in `error.data`. |\n| `-32005` | `HandlerError` | Handler threw, or output failed strict validation. Message comes from the thrown error. |\n| `-32006` | `SamplingNotAvailable` | Handler called `ctx.sample` but agent didn't advertise sampling. |\n| `-32007` | `ElicitationNotAvailable` | Handler called `ctx.elicit` but agent didn't advertise elicitation. (`ctx.confirm` returns `false` instead of throwing — safe default for destructive gates.) |\n| `-32008` | `SamplingDepthExceeded` | Sampling chain exceeded `maxSamplingDepth` (3). |\n| `-32009` | `Unauthorized` | Wrong claim code, unclaimed session invoking action, or origin not allowlisted. |\n\nErrors carry an optional `data` field. Tesseron uses it to attach:\n\n- **For `-32004` InputValidation**: the `issues` array from Standard Schema.\n- **For `-32005` HandlerError** with strict output: the `issues` for the failed output check.\n- **For `-32008` SamplingDepthExceeded**: `{ depth, max }`.\n\n## Capability negotiation\n\nBoth sides declare capabilities during the handshake. The `welcome` response contains their intersection - that's what your handler should trust.\n\n| Capability | Meaning |\n|---|---|\n| `streaming` | `actions/progress` notifications are allowed. |\n| `subscriptions` | Agent will call `resources/subscribe`. |\n| `sampling` | `ctx.sample` is available. |\n| `elicitation` | `ctx.confirm` and `ctx.elicit` are available. |\n\nYour handler, in general:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n return fallbackResponse();\n}\nconst refined = await ctx.sample({ prompt, schema });\n```\n\nIf you'd rather error out than fall back, just call `ctx.sample` unconditionally - the SDK throws `SamplingNotAvailableError` (code `-32006`) which the agent sees as a structured tool failure.\n\n## Errors are data, not disasters\n\nAgents are good at recovering from structured errors. Prefer returning a well-typed error to throwing a vague one:\n\n- Bad input? Let Standard Schema reject it - the agent gets issues to correct.\n- Impossible state? `throw new Error(\"Cart is locked; ask the user to unlock it\")` surfaces as code `-32005` with a useful message.\n- Need clarification? Use [elicitation](/protocol/elicitation/) instead of failing.\n\nNext: [lifecycle & failure modes](/protocol/lifecycle/)."},{"slug":"protocol/handshake","title":"Handshake & claiming","description":"How a WebSocket becomes a bound session - tesseron/hello, welcome, claim code, and tools/list_changed.","section":"protocol","related":["protocol/wire-format","protocol/transport","protocol/security","protocol/lifecycle"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nA Tesseron session goes through three states: **connected**, **awaiting claim**, **claimed**. Only claimed sessions can have their actions invoked.\n\n<Sequence\n caption=\"From page load to first tool call.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 'app', label: 'WEB APP', sub: '@tesseron/web', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', sub: '@tesseron/mcp', icon: 'bridge', variant: 'accent' },\n { id: 'u', label: 'USER', icon: 'user' },\n { id: 'a', label: 'AGENT', sub: 'Claude Code', icon: 'agent' },\n ]}\n steps={[\n { from: 'app', to: 'gw', label: 'tesseron/hello { app, actions, resources, caps }' },\n { from: 'gw', to: 'app', label: \"tesseron/welcome { sessionId, claimCode: 'AB3X-7K' }\", style: 'dashed' },\n { from: 'gw', to: 'u', label: 'claim code (web UI or stdout)', style: 'dashed' },\n { from: 'u', to: 'a', label: 'connect AB3X-7K' },\n { from: 'a', to: 'gw', label: 'tools/call tesseron__claim_session', accent: true },\n { from: 'gw', to: 'a', label: 'notifications/tools/list_changed', style: 'dashed' },\n { from: 'a', to: 'gw', label: 'tools/call shop__searchProducts' },\n { from: 'gw', to: 'app', label: 'actions/invoke { invocationId, input }' },\n { from: 'app', to: 'gw', label: 'result', style: 'dashed' },\n { from: 'gw', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## The `tesseron/hello` request\n\nSent by the app right after the WebSocket opens.\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/hello\",\n \"params\": {\n \"protocolVersion\": \"1.0.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.0.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- 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## Why out-of-band claim?\n\nBecause in-band claim is just security theatre over localhost. Anything running on the user's machine can open a WebSocket to `:7475`. The claim code is a **user-typed confirmation** - proof that a human authorised this specific browser tab to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).\n\n## 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.0.0; SDK sent 2.0.0. Major version mismatch - pin compatible package versions.\" } }\n```\n\nNext: the [action model](/protocol/actions/).\n","bodyText":"A Tesseron session goes through three states: **connected**, **awaiting claim**, **claimed**. Only claimed sessions can have their actions invoked.\n\n## The `tesseron/hello` request\n\nSent by the app right after the WebSocket opens.\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/hello\",\n \"params\": {\n \"protocolVersion\": \"1.0.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.0.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- 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## Why out-of-band claim?\n\nBecause in-band claim is just security theatre over localhost. Anything running on the user's machine can open a WebSocket to `:7475`. The claim code is a **user-typed confirmation** - proof that a human authorised this specific browser tab to be controlled by this specific agent session. It's short enough to read aloud, long enough to resist guessing (~1.5 billion combinations of 6 upper-case alphanumeric minus confusables).\n\n## 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.0.0; SDK sent 2.0.0. Major version mismatch - pin compatible package versions.\" } }\n```\n\nNext: the [action model](/protocol/actions/)."},{"slug":"protocol/index","title":"Protocol overview","description":"The Tesseron protocol in one page - wire format, transport, handshake, action model, MCP capabilities, errors, lifecycle.","section":"protocol","related":["protocol/wire-format","protocol/transport","protocol/handshake","protocol/actions","protocol/errors","protocol/lifecycle"],"bodyRaw":"\nimport { Aside, Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Sequence from '../../../components/Sequence.astro';\n\n<Aside type=\"note\" title=\"Spec license\">\nThe Tesseron protocol specification (every page under `docs/protocol/`) is licensed **CC BY 4.0** — independent from the reference implementation. You are free to build a compatible implementation in any language for any purpose, including commercially, with attribution. See [`LICENSE`](https://github.com/BrainBlend-AI/tesseron/blob/main/docs/src/content/docs/protocol/LICENSE) in the protocol directory.\n</Aside>\n\nTesseron speaks **JSON-RPC 2.0 over WebSocket** 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 protocol is at **version `1.0.0`**.\n\n<Sequence\n caption=\"A first-use session, start to finish: from WebSocket open to the first tool-call result returned to the agent.\"\n actorWidth={170}\n actorGap={50}\n actors={[\n { id: 'app', label: 'WEB APP', icon: 'window' },\n { id: 'gw', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'u', label: 'USER', icon: 'user' },\n { id: 'a', label: 'AGENT', icon: 'agent' },\n ]}\n steps={[\n { from: 'app', to: 'gw', label: 'tesseron/hello { app, actions, caps }' },\n { from: 'gw', to: 'app', label: 'tesseron/welcome { sessionId, claimCode }', style: 'dashed' },\n { from: 'gw', to: 'u', label: 'claim code (stdout / web UI)', style: 'dashed' },\n { from: 'u', to: 'a', label: 'connect AB3X-7K' },\n { from: 'a', to: 'gw', label: 'tools/call tesseron__claim_session', accent: true },\n { from: 'gw', to: 'a', label: 'notifications/tools/list_changed', style: 'dashed' },\n { from: 'a', to: 'gw', label: 'tools/call shop__searchProducts' },\n { from: 'gw', to: 'app', label: 'actions/invoke { invocationId, input }' },\n { from: 'app', to: 'gw', label: 'result', style: 'dashed' },\n { from: 'gw', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n## Read the pages in order\n\n<CardGrid>\n <LinkCard title=\"Wire format (JSON-RPC)\" href=\"./wire-format/\"\n description=\"Envelope shapes, methods, notifications, ID correlation.\" />\n <LinkCard title=\"Transport (WebSocket)\" href=\"./transport/\"\n description=\"URL, framing, origin allowlist, reconnection rules.\" />\n <LinkCard title=\"Handshake & claiming\" href=\"./handshake/\"\n description=\"`tesseron/hello` → `welcome` → claim code → bound session.\" />\n <LinkCard title=\"Action model\" href=\"./actions/\"\n description=\"How actions are declared, invoked, validated, and namespaced.\" />\n <LinkCard title=\"Progress & cancellation\" href=\"./progress-cancellation/\"\n description=\"Streaming updates; `AbortSignal`-based cancellation.\" />\n <LinkCard title=\"Sampling\" href=\"./sampling/\"\n description=\"Handlers re-enter the agent LLM for a reasoning step.\" />\n <LinkCard title=\"Elicitation\" href=\"./elicitation/\"\n description=\"Handlers pause and ask the user a question via the agent UI.\" />\n <LinkCard title=\"Resources\" href=\"./resources/\"\n description=\"Typed, readable, optionally subscribable state projected to the agent.\" />\n <LinkCard title=\"Errors & capabilities\" href=\"./errors/\"\n description=\"Error codes, capability negotiation, not-available paths.\" />\n <LinkCard title=\"Lifecycle & failure modes\" href=\"./lifecycle/\"\n description=\"Disconnect, reconnect, timeout, MCP gateway restart, tab close.\" />\n <LinkCard title=\"Security model\" href=\"./security/\"\n description=\"Origin allowlist, claim codes, multi-app namespacing.\" />\n</CardGrid>\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.0.0` |\n| Default gateway URL | `ws://127.0.0.1:7475` |\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 WebSocket** 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 protocol is at **version `1.0.0`**.\n\n## Read the pages in order\n\n## Core constants\n\n| Name | Value |\n|---|---|\n| Protocol version | `1.0.0` |\n| Default gateway URL | `ws://127.0.0.1:7475` |\n| Default action timeout | `60_000` ms |\n| Max sampling depth | `3` |\n| Tool name pattern | `<app_id>__<action_name>` |\n| Resource URI pattern | `tesseron://<app_id>/<resource_name>` |\n| `app.id` validator | `/^[a-z][a-z0-9_]*$/` |\n\nAll of these are **observable from the wire** - they're not SDK-specific. A Python or Go SDK implementing Tesseron MUST match them exactly."},{"slug":"protocol/lifecycle","title":"Lifecycle & failure modes","description":"The session state machine, and what happens to pending work at every transition.","section":"protocol","related":["protocol/handshake","protocol/resume","protocol/transport"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nEvery WebSocket connection to the MCP gateway produces a session that walks this state machine:\n\n<Diagram\n caption=\"Five states. Only Claimed can run actions. Every terminal transition aborts in-flight work.\"\n nodeWidth={130}\n nodeHeight={100}\n spacing={50}\n pad={44}\n nodes={[\n { id: 'dc', label: 'DISCONNECTED', icon: 'x' },\n { id: 'hs', label: 'HANDSHAKING', icon: 'arrow' },\n { id: 'aw', label: 'AWAITING', sub: 'claim pending', icon: 'lock' },\n { id: 'cl', label: 'CLAIMED', sub: 'tools exposed', icon: 'check', variant: 'accent' },\n { id: 'end', label: 'CLOSED', icon: 'x', variant: 'danger' },\n ]}\n edges={[\n { from: 'dc', to: 'hs', label: 'ws open' },\n { from: 'hs', to: 'aw', label: 'welcome' },\n { from: 'aw', to: 'cl', label: 'claim ok', accent: true },\n { from: 'aw', to: 'end', label: 'timeout', style: 'dashed', danger: true },\n { from: 'cl', to: 'end', label: 'ws close', style: 'dashed', danger: true },\n ]}\n/>\n\n## States\n\n- **Disconnected** - no WebSocket yet. Tooling surface shows no tools for this app.\n- **Handshaking** - WebSocket open, `tesseron/hello` in flight.\n- **Awaiting claim** - `welcome` sent with a `claimCode`. Actions are registered in the gateway but *not* exposed as MCP tools until claim.\n- **Claimed** - agent has submitted a matching claim. Tool list is published. Actions can be invoked.\n- **Closed** - WebSocket closed. Session forgotten by the gateway.\n\n## Transitions\n\n| From → To | Trigger | Side effects |\n|---|---|---|\n| Disconnected → Handshaking | App opens WebSocket. | `tesseron/hello` sent. |\n| Handshaking → Awaiting claim | MCP gateway returns `welcome`. | Claim code generated + printed to gateway stderr. |\n| Awaiting claim → Claimed | Agent calls `tesseron__claim_session` with matching code. | `notifications/tools/list_changed` fires. |\n| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |\n| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; `tools/list_changed` fires so the agent drops stale tools. |\n\nThe TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.\n\n## What pending work does on close\n\nFrom inside your handler, on any Closed transition:\n\n- `ctx.signal.aborted` becomes `true`.\n- `ctx.progress(…)` after close is silently dropped.\n- `ctx.sample(…)` / `ctx.confirm(…)` / `ctx.elicit(…)` in flight reject with `TransportClosedError`.\n- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.\n\n**Handler best practices:**\n\n```ts\n.handler(async (input, ctx) => {\n const abortable = new AbortController();\n ctx.signal.addEventListener('abort', () => abortable.abort());\n try {\n return await longWork(input, { signal: abortable.signal });\n } finally {\n abortable.abort(); // release any resources even on normal exit\n }\n});\n```\n\n## Reconnection doesn't resume - it starts over\n\n`tesseron.connect()` after a disconnect yields a **new** `sessionId` and **new** `claimCode`. The agent must re-claim. In-flight work from the old session is gone.\n\nWhy not resumable? Two reasons:\n\n1. The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.\n2. Claim is meant to be a user-visible act. An invisible reconnection would bypass the \"human-in-the-loop authorisation\" the claim code represents.\n\n## MCP gateway restart\n\nIf the gateway process dies (plugin disabled, Claude Code restart, crash):\n\n- Every app's WebSocket closes with code 1001 (`Going Away`).\n- Every SDK instance aborts in-flight work and rejects pending requests.\n- Apps are free to `connect()` again when the gateway comes back.\n\nA small \"reconnect\" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.\n\nNext: the [security model](/protocol/security/).\n","bodyText":"Every WebSocket connection to the MCP gateway produces a session that walks this state machine:\n\n## States\n\n- **Disconnected** - no WebSocket yet. Tooling surface shows no tools for this app.\n- **Handshaking** - WebSocket open, `tesseron/hello` in flight.\n- **Awaiting claim** - `welcome` sent with a `claimCode`. Actions are registered in the gateway but *not* exposed as MCP tools until claim.\n- **Claimed** - agent has submitted a matching claim. Tool list is published. Actions can be invoked.\n- **Closed** - WebSocket closed. Session forgotten by the gateway.\n\n## Transitions\n\n| From → To | Trigger | Side effects |\n|---|---|---|\n| Disconnected → Handshaking | App opens WebSocket. | `tesseron/hello` sent. |\n| Handshaking → Awaiting claim | MCP gateway returns `welcome`. | Claim code generated + printed to gateway stderr. |\n| Awaiting claim → Claimed | Agent calls `tesseron__claim_session` with matching code. | `notifications/tools/list_changed` fires. |\n| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |\n| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; `tools/list_changed` fires so the agent drops stale tools. |\n\nThe TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.\n\n## What pending work does on close\n\nFrom inside your handler, on any Closed transition:\n\n- `ctx.signal.aborted` becomes `true`.\n- `ctx.progress(…)` after close is silently dropped.\n- `ctx.sample(…)` / `ctx.confirm(…)` / `ctx.elicit(…)` in flight reject with `TransportClosedError`.\n- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.\n\n**Handler best practices:**\n\n```ts\n.handler(async (input, ctx) => {\n const abortable = new AbortController();\n ctx.signal.addEventListener('abort', () => abortable.abort());\n try {\n return await longWork(input, { signal: abortable.signal });\n } finally {\n abortable.abort(); // release any resources even on normal exit\n }\n});\n```\n\n## Reconnection doesn't resume - it starts over\n\n`tesseron.connect()` after a disconnect yields a **new** `sessionId` and **new** `claimCode`. The agent must re-claim. In-flight work from the old session is gone.\n\nWhy not resumable? Two reasons:\n\n1. The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.\n2. Claim is meant to be a user-visible act. An invisible reconnection would bypass the \"human-in-the-loop authorisation\" the claim code represents.\n\n## MCP gateway restart\n\nIf the gateway process dies (plugin disabled, Claude Code restart, crash):\n\n- Every app's WebSocket closes with code 1001 (`Going Away`).\n- Every SDK instance aborts in-flight work and rejects pending requests.\n- Apps are free to `connect()` again when the gateway comes back.\n\nA small \"reconnect\" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.\n\nNext: the [security model](/protocol/security/)."},{"slug":"protocol/progress-cancellation","title":"Progress & cancellation","description":"Streaming updates via `actions/progress` and AbortSignal-based cancellation via `actions/cancel`.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nLong-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.\n\n## Streaming progress\n\n<Sequence\n caption=\"Progress notifications ride along while the handler runs. The agent's UI typically renders them as an animated status line.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call { ... }' },\n { from: 'g', to: 's', label: 'actions/invoke' },\n { from: 's', to: 'g', label: 'actions/progress { percent: 10 }', style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/progress', style: 'dashed', accent: true },\n { from: 's', to: 'g', label: 'actions/progress { percent: 60 }', style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/progress', style: 'dashed', accent: true },\n { from: 's', to: 'g', label: 'result { ... }', style: 'dashed' },\n { from: 'g', to: 'a', label: 'tools/call result', style: 'dashed' },\n ]}\n/>\n\n```ts\ntesseron.action('importCsv')\n .input(z.object({ url: z.string().url() }))\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url);\n\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('Cancelled');\n ctx.progress({\n message: `${i}/${rows.length}`,\n percent: 5 + Math.floor((i / rows.length) * 90),\n });\n await importBatch(rows.slice(i, i + 100));\n }\n\n return { imported: rows.length };\n });\n```\n\nWire format - sent by the app as a notification (no response):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"message\": \"500/2000\",\n \"percent\": 27,\n \"data\": { \"etaMs\": 14000 }\n }\n}\n```\n\nAll three payload fields (`message`, `percent`, `data`) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP `notifications/progress`; MCP clients render them at their leisure.\n\n**Guideline:** cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.\n\n## Cancellation\n\n<Sequence\n caption=\"The agent cancels. The gateway translates to actions/cancel. The handler sees ctx.signal.aborted.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'SDK HANDLER', icon: 'window' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'tools/call { ... }' },\n { from: 'g', to: 's', label: \"actions/invoke { invocationId: 'inv_1' }\" },\n { note: 's', label: 'handler running (reads ctx.signal)' },\n { from: 'a', to: 'g', label: 'cancel invocation', style: 'dashed', danger: true },\n { from: 'g', to: 's', label: \"actions/cancel { invocationId: 'inv_1' }\", style: 'dashed', danger: true },\n { note: 's', label: 'ctx.signal.aborted = true', variant: 'danger' },\n { from: 's', to: 'g', label: 'error -32001 Cancelled', style: 'dashed', danger: true },\n { from: 'g', to: 'a', label: 'tools/call error', style: 'dashed', danger: true },\n ]}\n/>\n\n```ts\ntesseron.action('generateReport')\n .input(...)\n .handler(async (input, ctx) => {\n const rows = await slowQuery(ctx.signal); // pass signal down\n if (ctx.signal.aborted) throw new Cancelled();\n return formatReport(rows);\n });\n```\n\n- `ctx.signal` is a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Pass it to `fetch`, `setTimeout`, database drivers, or anything else that accepts one.\n- Cancellation fires for **two reasons**: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.\n- After abort, the SDK returns an error response with code `-32001 Cancelled` (explicit) or `-32002 Timeout` (timer).\n\nWire format - notification from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/cancel\",\n \"params\": { \"invocationId\": \"inv_abc\" }\n}\n```\n\nThe app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.\n\n## Reading the progress on the agent side\n\nWhen the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as \"best-effort hint\".\n\n## What NOT to use progress for\n\n- Final results. Use the response.\n- Data the next handler needs. Use a return value, a sampling round-trip, or a resource.\n- Error surfacing. Return an error response.\n\nNext: [sampling](/protocol/sampling/) - handlers that call back into the LLM.\n","bodyText":"Long-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.\n\n## Streaming progress\n\n```ts\ntesseron.action('importCsv')\n .input(z.object({ url: z.string().url() }))\n .handler(async ({ url }, ctx) => {\n ctx.progress({ message: 'downloading', percent: 5 });\n const rows = await fetchCsv(url);\n\n for (let i = 0; i < rows.length; i += 100) {\n if (ctx.signal.aborted) throw new Error('Cancelled');\n ctx.progress({\n message: `${i}/${rows.length}`,\n percent: 5 + Math.floor((i / rows.length) * 90),\n });\n await importBatch(rows.slice(i, i + 100));\n }\n\n return { imported: rows.length };\n });\n```\n\nWire format - sent by the app as a notification (no response):\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"message\": \"500/2000\",\n \"percent\": 27,\n \"data\": { \"etaMs\": 14000 }\n }\n}\n```\n\nAll three payload fields (`message`, `percent`, `data`) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP `notifications/progress`; MCP clients render them at their leisure.\n\n**Guideline:** cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.\n\n## Cancellation\n\n```ts\ntesseron.action('generateReport')\n .input(...)\n .handler(async (input, ctx) => {\n const rows = await slowQuery(ctx.signal); // pass signal down\n if (ctx.signal.aborted) throw new Cancelled();\n return formatReport(rows);\n });\n```\n\n- `ctx.signal` is a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Pass it to `fetch`, `setTimeout`, database drivers, or anything else that accepts one.\n- Cancellation fires for **two reasons**: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.\n- After abort, the SDK returns an error response with code `-32001 Cancelled` (explicit) or `-32002 Timeout` (timer).\n\nWire format - notification from gateway to app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/cancel\",\n \"params\": { \"invocationId\": \"inv_abc\" }\n}\n```\n\nThe app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.\n\n## Reading the progress on the agent side\n\nWhen the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as \"best-effort hint\".\n\n## What NOT to use progress for\n\n- Final results. Use the response.\n- Data the next handler needs. Use a return value, a sampling round-trip, or a resource.\n- Error surfacing. Return an error response.\n\nNext: [sampling](/protocol/sampling/) - handlers that call back into the LLM."},{"slug":"protocol/resources","title":"Resources","description":"Typed, readable, optionally subscribable state your app projects to the agent.","section":"protocol","related":["sdk/typescript/resources","protocol/wire-format"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\nA **resource** is a named piece of app state the agent can read - and optionally subscribe to for push updates. Resources complement actions: actions cause changes, resources expose what changed.\n\n<Sequence\n caption=\"Agent reads once, then subscribes. When the app's value changes, the SDK pushes a resources/updated notification.\"\n actors={[\n { id: 'a', label: 'AGENT', icon: 'agent' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 's', label: 'RESOURCE HANDLER', icon: 'database' },\n ]}\n steps={[\n { from: 'a', to: 'g', label: 'resources/read tesseron://shop/route' },\n { from: 'g', to: 's', label: 'resources/read' },\n { from: 's', to: 'g', label: \"{ value: '/cart' }\", style: 'dashed' },\n { from: 'g', to: 'a', label: 'read result', style: 'dashed' },\n { from: 'a', to: 'g', label: 'resources/subscribe', accent: true },\n { from: 'g', to: 's', label: 'resources/subscribe', accent: true },\n { note: 's', label: 'register emit() callback' },\n { from: 's', to: 'g', label: \"resources/updated { value: '/checkout' }\", style: 'dashed', accent: true },\n { from: 'g', to: 'a', label: 'notifications/resources/updated', style: 'dashed', accent: true },\n ]}\n/>\n\n## Declaration\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is currently viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `.read()` is a one-shot getter. Called on every `resources/read` the agent issues.\n- `.subscribe()` is optional. It registers an emitter; return an unsubscribe function so the SDK can clean up when the agent unsubscribes or the session closes.\n\n## URI convention\n\nResources are exposed to the agent with the URI `tesseron://<app_id>/<resource_name>`. For `app.id = \"shop\"` and `resource = \"currentRoute\"`, the agent sees `tesseron://shop/currentRoute`.\n\n## Reading from clients that don't speak MCP resources\n\nSome MCP clients don't surface `resources/read` to their model. The MCP gateway ships a meta-tool fallback:\n\n- **`tesseron__read_resource`** (`{ app_id, name }`) - returns the resource's current value as a tool-call result. Prefer this over the generic `ReadMcpResourceTool` because the agent doesn't have to know how the MCP server is namespaced on the client (e.g. `plugin:tesseron:tesseron` in Claude Code plugin installs vs. `tesseron` in a raw config).\n\n`tesseron__list_actions` enumerates every claimed session's resources and includes both the preferred `tesseron__read_resource` args and the `ReadMcpResourceTool` fallback.\n\n## Wire format\n\n### Read (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"method\": \"resources/read\", \"params\": { \"name\": \"currentRoute\" } }\n```\n\nResponse:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"result\": { \"value\": \"/checkout\" } }\n```\n\n### Subscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"method\": \"resources/subscribe\", \"params\": { \"name\": \"currentRoute\", \"subscriptionId\": \"sub_1\" } }\n```\n\nResponse is empty - the SDK just acknowledges and now holds the emitter callback.\n\n### Update (app → gateway, notification)\n\nEach time the emitter fires:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub_1\", \"value\": \"/cart\" }\n}\n```\n\nThe gateway forwards this as MCP `notifications/resources/updated` to the agent.\n\n### Unsubscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"method\": \"resources/unsubscribe\", \"params\": { \"subscriptionId\": \"sub_1\" } }\n```\n\nThe SDK calls the unsubscribe function returned by your `.subscribe()` handler.\n\n### List changed (app → gateway, notification)\n\nIf your app registers or removes resources after the initial `tesseron/hello`, the SDK emits `resources/list_changed` with the new manifest. The gateway forwards this as MCP `notifications/resources/list_changed` so agents can refetch the list. `actions/list_changed` follows the same pattern for dynamic action sets.\n\n## Patterns\n\n### Read-only projection\n\n```ts\ntesseron.resource('filterState').read(() => ({\n search: state.search,\n onlyDone: state.onlyDone,\n}));\n```\n\nPerfect for letting the agent reason about \"what's the user currently looking at\" before proposing actions.\n\n### Debounced subscription\n\nDon't emit on every keystroke - the agent can't meaningfully react at that rate.\n\n```ts\ntesseron.resource('search')\n .read(() => state.search)\n .subscribe((emit) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const onChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => emit(state.search), 250);\n };\n state.on('change', onChange);\n return () => { if (timer) clearTimeout(timer); state.off('change', onChange); };\n });\n```\n\n### Large or expensive resources\n\nIf the value is expensive to produce, remember that `.read()` runs every time the agent fetches. Cache inside the handler, or use `.subscribe()` as the source of truth and cache the latest emitted value in-memory.\n\n## Capability gate\n\nSubscriptions require `agentCapabilities.subscriptions`. Reads do not. If the agent can't subscribe, it will only call `resources/read` and your `.subscribe()` handler is never invoked.\n\nNext: the full [error catalog and capability negotiation](/protocol/errors/).\n","bodyText":"A **resource** is a named piece of app state the agent can read - and optionally subscribe to for push updates. Resources complement actions: actions cause changes, resources expose what changed.\n\n## Declaration\n\n```ts\ntesseron.resource('currentRoute')\n .describe('The URL path the user is currently viewing')\n .read(() => window.location.pathname)\n .subscribe((emit) => {\n const onChange = () => emit(window.location.pathname);\n window.addEventListener('popstate', onChange);\n return () => window.removeEventListener('popstate', onChange);\n });\n```\n\n- `.read()` is a one-shot getter. Called on every `resources/read` the agent issues.\n- `.subscribe()` is optional. It registers an emitter; return an unsubscribe function so the SDK can clean up when the agent unsubscribes or the session closes.\n\n## URI convention\n\nResources are exposed to the agent with the URI `tesseron://<app_id>/<resource_name>`. For `app.id = \"shop\"` and `resource = \"currentRoute\"`, the agent sees `tesseron://shop/currentRoute`.\n\n## Reading from clients that don't speak MCP resources\n\nSome MCP clients don't surface `resources/read` to their model. The MCP gateway ships a meta-tool fallback:\n\n- **`tesseron__read_resource`** (`{ app_id, name }`) - returns the resource's current value as a tool-call result. Prefer this over the generic `ReadMcpResourceTool` because the agent doesn't have to know how the MCP server is namespaced on the client (e.g. `plugin:tesseron:tesseron` in Claude Code plugin installs vs. `tesseron` in a raw config).\n\n`tesseron__list_actions` enumerates every claimed session's resources and includes both the preferred `tesseron__read_resource` args and the `ReadMcpResourceTool` fallback.\n\n## Wire format\n\n### Read (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"method\": \"resources/read\", \"params\": { \"name\": \"currentRoute\" } }\n```\n\nResponse:\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 14, \"result\": { \"value\": \"/checkout\" } }\n```\n\n### Subscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 15, \"method\": \"resources/subscribe\", \"params\": { \"name\": \"currentRoute\", \"subscriptionId\": \"sub_1\" } }\n```\n\nResponse is empty - the SDK just acknowledges and now holds the emitter callback.\n\n### Update (app → gateway, notification)\n\nEach time the emitter fires:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"resources/updated\",\n \"params\": { \"subscriptionId\": \"sub_1\", \"value\": \"/cart\" }\n}\n```\n\nThe gateway forwards this as MCP `notifications/resources/updated` to the agent.\n\n### Unsubscribe (gateway → app, request)\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 16, \"method\": \"resources/unsubscribe\", \"params\": { \"subscriptionId\": \"sub_1\" } }\n```\n\nThe SDK calls the unsubscribe function returned by your `.subscribe()` handler.\n\n### List changed (app → gateway, notification)\n\nIf your app registers or removes resources after the initial `tesseron/hello`, the SDK emits `resources/list_changed` with the new manifest. The gateway forwards this as MCP `notifications/resources/list_changed` so agents can refetch the list. `actions/list_changed` follows the same pattern for dynamic action sets.\n\n## Patterns\n\n### Read-only projection\n\n```ts\ntesseron.resource('filterState').read(() => ({\n search: state.search,\n onlyDone: state.onlyDone,\n}));\n```\n\nPerfect for letting the agent reason about \"what's the user currently looking at\" before proposing actions.\n\n### Debounced subscription\n\nDon't emit on every keystroke - the agent can't meaningfully react at that rate.\n\n```ts\ntesseron.resource('search')\n .read(() => state.search)\n .subscribe((emit) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n const onChange = () => {\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => emit(state.search), 250);\n };\n state.on('change', onChange);\n return () => { if (timer) clearTimeout(timer); state.off('change', onChange); };\n });\n```\n\n### Large or expensive resources\n\nIf the value is expensive to produce, remember that `.read()` runs every time the agent fetches. Cache inside the handler, or use `.subscribe()` as the source of truth and cache the latest emitted value in-memory.\n\n## Capability gate\n\nSubscriptions require `agentCapabilities.subscriptions`. Reads do not. If the agent can't subscribe, it will only call `resources/read` and your `.subscribe()` handler is never invoked.\n\nNext: the full [error catalog and capability negotiation](/protocol/errors/)."},{"slug":"protocol/resume","title":"Session resume","description":"How a Tesseron app rejoins a previously-claimed session after a transport drop via tesseron/resume - protocol shape, gateway behaviour, and the 4-line localStorage recipe.","section":"protocol","related":["protocol/handshake","protocol/transport","protocol/wire-format","protocol/lifecycle"],"bodyRaw":"\nA Tesseron session lives in the gateway's memory. When the underlying WebSocket drops (tab refresh, window close, network blip, HMR reload), the session normally goes away and a reconnecting app would have to go through the full `tesseron/hello` + claim-code dance again - even if the user had already paired it.\n\nThe `tesseron/resume` method lets the app rejoin an existing session it paired earlier, skipping the re-claim. Storage of the resume credentials is deliberately **the implementer's responsibility**: different apps have different opinions about where session credentials can live (localStorage, a cookie, an Electron store, the OS keychain), so the SDK exposes the primitive and leaves the choice to you.\n\n## Flow\n\n1. On a fresh `tesseron/hello`, the gateway returns a `resumeToken` in the welcome. Stash it alongside the `sessionId` wherever fits your app.\n2. When the transport drops, the gateway keeps the session's metadata as a \"zombie\" for `resumeTtlMs` (default 90 seconds).\n3. On reconnect, the app sends `tesseron/resume` with `{ sessionId, resumeToken }`. If the token matches (constant-time compare) and the zombie is still within its TTL, the gateway reattaches the fresh socket to the existing session and rotates the token.\n4. The caller persists the **new** `resumeToken` from the resume response.\n\nResume tokens are **one-shot**: every successful resume rotates the token and the previous value stops working. This means the freshest welcome is always the one to persist.\n\n## The `tesseron/resume` request\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/resume\",\n \"params\": {\n \"protocolVersion\": \"1.0.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.0.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 port: 7475,\n resumeTtlMs: 300_000, // 5 minutes (default: 90_000)\n maxZombies: 500, // cap on zombies held simultaneously (default: 100)\n});\n```\n\n- `resumeTtlMs` — how long a closed session is retained as a resumable zombie. Set to `0` to disable resume entirely: closed sessions drop immediately and any reconnect must start fresh.\n- `maxZombies` — ceiling on the in-memory zombie map. When inserting a new zombie would exceed it, the oldest (longest-retained) zombie is evicted to make room. Keeps a connect/disconnect flood from piling up zombies faster than their TTLs expire. Set to `0` to disable resume entirely (same effect as `resumeTtlMs: 0`).\n\n## Idiomatic SDK usage\n\nThe SDK exposes a single `resume` field on `ConnectOptions`. You decide where to stash the token:\n\n```ts\nimport { tesseron } from '@tesseron/web';\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\ntesseron.action('searchProducts').handler(/* ... */);\n\nconst saved = localStorage.getItem('tesseron:shop');\nconst welcome = await tesseron.connect(\n 'ws://127.0.0.1:7475',\n saved ? { resume: JSON.parse(saved) } : undefined,\n);\n\nlocalStorage.setItem('tesseron:shop', JSON.stringify({\n sessionId: welcome.sessionId,\n resumeToken: welcome.resumeToken,\n}));\n```\n\nFour lines. The SDK does not do this for you; `localStorage` is one answer among many. A desktop app might stash the pair in the OS keychain. A server process might put it in a file next to its config. An iframe-embedded app might have CSP reasons not to persist at all.\n\n### Falling back when resume fails\n\n```ts\ntry {\n await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);\n} catch (err) {\n if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {\n localStorage.removeItem('tesseron:shop');\n await tesseron.connect(url); // fresh hello\n } else {\n throw err;\n }\n}\n```\n\n## What resume does **not** do\n\n- It does not replay in-flight actions. An action the agent invoked just before the socket dropped is cancelled on the gateway; the agent sees an error (see [lifecycle](/protocol/lifecycle/)) and can retry at its own layer.\n- It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.\n- It does not persist across a gateway restart. Zombies live in gateway process memory; stopping the gateway evicts them. A fresh `tesseron/hello` is required after any gateway restart.\n- It does not work for sessions that were never claimed. The gateway surfaces `ResumeFailed` with `never claimed` so the SDK can fall back to `tesseron/hello` without ambiguity.\n\n## See also\n\n- [Handshake & claiming](/protocol/handshake/) - the `tesseron/hello` flow resume complements.\n- [Lifecycle & failure modes](/protocol/lifecycle/) - how the gateway behaves during drops, retries, and gateway restarts.\n- [Errors & capabilities](/protocol/errors/) - the full `TesseronErrorCode` table including `ResumeFailed`.\n","bodyText":"A Tesseron session lives in the gateway's memory. When the underlying WebSocket drops (tab refresh, window close, network blip, HMR reload), the session normally goes away and a reconnecting app would have to go through the full `tesseron/hello` + claim-code dance again - even if the user had already paired it.\n\nThe `tesseron/resume` method lets the app rejoin an existing session it paired earlier, skipping the re-claim. Storage of the resume credentials is deliberately **the implementer's responsibility**: different apps have different opinions about where session credentials can live (localStorage, a cookie, an Electron store, the OS keychain), so the SDK exposes the primitive and leaves the choice to you.\n\n## Flow\n\n1. On a fresh `tesseron/hello`, the gateway returns a `resumeToken` in the welcome. Stash it alongside the `sessionId` wherever fits your app.\n2. When the transport drops, the gateway keeps the session's metadata as a \"zombie\" for `resumeTtlMs` (default 90 seconds).\n3. On reconnect, the app sends `tesseron/resume` with `{ sessionId, resumeToken }`. If the token matches (constant-time compare) and the zombie is still within its TTL, the gateway reattaches the fresh socket to the existing session and rotates the token.\n4. The caller persists the **new** `resumeToken` from the resume response.\n\nResume tokens are **one-shot**: every successful resume rotates the token and the previous value stops working. This means the freshest welcome is always the one to persist.\n\n## The `tesseron/resume` request\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tesseron/resume\",\n \"params\": {\n \"protocolVersion\": \"1.0.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.0.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 port: 7475,\n resumeTtlMs: 300_000, // 5 minutes (default: 90_000)\n maxZombies: 500, // cap on zombies held simultaneously (default: 100)\n});\n```\n\n- `resumeTtlMs` — how long a closed session is retained as a resumable zombie. Set to `0` to disable resume entirely: closed sessions drop immediately and any reconnect must start fresh.\n- `maxZombies` — ceiling on the in-memory zombie map. When inserting a new zombie would exceed it, the oldest (longest-retained) zombie is evicted to make room. Keeps a connect/disconnect flood from piling up zombies faster than their TTLs expire. Set to `0` to disable resume entirely (same effect as `resumeTtlMs: 0`).\n\n## Idiomatic SDK usage\n\nThe SDK exposes a single `resume` field on `ConnectOptions`. You decide where to stash the token:\n\n```ts\n\ntesseron.app({ id: 'shop', name: 'Acme Shop' });\ntesseron.action('searchProducts').handler(/* ... */);\n\nconst saved = localStorage.getItem('tesseron:shop');\nconst welcome = await tesseron.connect(\n 'ws://127.0.0.1:7475',\n saved ? { resume: JSON.parse(saved) } : undefined,\n);\n\nlocalStorage.setItem('tesseron:shop', JSON.stringify({\n sessionId: welcome.sessionId,\n resumeToken: welcome.resumeToken,\n}));\n```\n\nFour lines. The SDK does not do this for you; `localStorage` is one answer among many. A desktop app might stash the pair in the OS keychain. A server process might put it in a file next to its config. An iframe-embedded app might have CSP reasons not to persist at all.\n\n### Falling back when resume fails\n\n```ts\ntry {\n await tesseron.connect(url, saved ? { resume: JSON.parse(saved) } : undefined);\n} catch (err) {\n if (err instanceof TesseronError && err.code === TesseronErrorCode.ResumeFailed) {\n localStorage.removeItem('tesseron:shop');\n await tesseron.connect(url); // fresh hello\n } else {\n throw err;\n }\n}\n```\n\n## What resume does **not** do\n\n- It does not replay in-flight actions. An action the agent invoked just before the socket dropped is cancelled on the gateway; the agent sees an error (see [lifecycle](/protocol/lifecycle/)) and can retry at its own layer.\n- It does not resurrect resource subscriptions. The SDK re-subscribes on reconnect as it does after any handshake.\n- It does not persist across a gateway restart. Zombies live in gateway process memory; stopping the gateway evicts them. A fresh `tesseron/hello` is required after any gateway restart.\n- It does not work for sessions that were never claimed. The gateway surfaces `ResumeFailed` with `never claimed` so the SDK can fall back to `tesseron/hello` without ambiguity.\n\n## See also\n\n- [Handshake & claiming](/protocol/handshake/) - the `tesseron/hello` flow resume complements.\n- [Lifecycle & failure modes](/protocol/lifecycle/) - how the gateway behaves during drops, retries, and gateway restarts.\n- [Errors & capabilities](/protocol/errors/) - the full `TesseronErrorCode` table including `ResumeFailed`."},{"slug":"protocol/sampling","title":"Sampling","description":"How a handler re-enters the agent's LLM for a reasoning step, and what the schema contract looks like.","section":"protocol","related":["protocol/actions","protocol/wire-format","sdk/typescript/context"],"bodyRaw":"\nimport Sequence from '../../../components/Sequence.astro';\n\n**Sampling** lets an action handler ask the agent's LLM to produce a response mid-handler. The LLM is the agent's - not your own - so sampling doesn't require an API key from your side, and it counts against the user's agent budget.\n\n<Sequence\n caption=\"The handler re-enters the agent loop. The agent's LLM replies. The handler then validates the result against your schema.\"\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.sample(...)', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT + LLM', icon: 'agent' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'sampling/request { prompt, schema, maxTokens }' },\n { from: 'g', to: 'a', label: 'MCP sampling/createMessage' },\n { note: 'a', label: 'LLM generates response' },\n { from: 'a', to: 'g', label: 'sampling result', style: 'dashed' },\n { from: 'g', to: 's', label: '{ content }', style: 'dashed' },\n { note: 's', label: 'validate against schema -> parsed value', variant: 'accent' },\n ]}\n/>\n\n## When to use it\n\n- **Natural-language reformatting** - turn a list of rows into a readable summary.\n- **Classification** - given a free-text comment, pick a category from an enum.\n- **Structured extraction** - pull the fields your action needs out of a fuzzy input.\n\nDon't use sampling for:\n\n- Raw chatbot replies. Your action should have a clear return type.\n- Very long generations. Sampling is subject to depth limits (max 3 by default) and counts against the agent budget - keep it targeted.\n\n## Calling sample\n\n```ts\nimport { z } from 'zod';\n\ntesseron.action('classifyComment')\n .input(z.object({ text: z.string() }))\n .output(z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), confidence: z.number() }))\n .handler(async ({ text }, ctx) => {\n const result = await ctx.sample({\n prompt: `Classify the sentiment of this comment: \"\"\"${text}\"\"\"`,\n schema: z.object({\n sentiment: z.enum(['positive', 'neutral', 'negative']),\n confidence: z.number().min(0).max(1),\n }),\n maxTokens: 80,\n });\n return result;\n });\n```\n\n## Wire format\n\nRequest, app → gateway:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"method\": \"sampling/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"prompt\": \"Classify the sentiment …\",\n \"schema\": { \"type\": \"object\", \"properties\": { \"sentiment\": { \"enum\": [\"positive\", \"neutral\", \"negative\"] }, \"confidence\": { \"type\": \"number\" } } },\n \"maxTokens\": 80\n }\n}\n```\n\nResponse, gateway → app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"result\": { \"content\": { \"sentiment\": \"positive\", \"confidence\": 0.82 } }\n}\n```\n\nIf you passed a `schema`, the SDK validates `result.content` against it before returning from `ctx.sample`. If the model's response doesn't parse, you get a validation error and can retry.\n\n## Depth limit\n\nSampling is recursive by construction: the agent is a Claude session that called your action, and you're asking that same Claude to think again. Without a cap, a malicious or buggy chain could spiral.\n\nThe MCP gateway enforces `maxSamplingDepth = 3`. Each request from a handler that was itself invoked via sampling increments the counter. Exceeded → error `-32008 SamplingDepthExceeded`.\n\n## Capability gate\n\nNot every MCP client supports sampling. Before calling `ctx.sample`, check the capability:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n // Fall back: return something useful without the LLM.\n}\nconst result = await ctx.sample({ /* ... */ });\n```\n\nOr let the SDK throw `SamplingNotAvailableError` (error code `-32006`) and catch it. Pick whichever fits your UX.\n\n### Client compatibility\n\nSampling only works when the connected MCP client advertises `capabilities.sampling` during the MCP `initialize` handshake. Tesseron captures the client's capabilities at that point and flows them to every SDK session as `ctx.agentCapabilities.sampling` — so a handler always sees the real answer, even when a particular client (for example, Claude Code as of this writing) has not yet implemented `sampling/createMessage`. If a handler calls `ctx.sample()` anyway on such a client, the SDK throws a structured `SamplingNotAvailableError` including the client name (when available) instead of a raw JSON-RPC `-32601 Method not found`, so callers can branch on `error instanceof SamplingNotAvailableError` and return a graceful fallback.\n\nNext: [elicitation](/protocol/elicitation/) - same shape, but with the user instead of the model.\n","bodyText":"**Sampling** lets an action handler ask the agent's LLM to produce a response mid-handler. The LLM is the agent's - not your own - so sampling doesn't require an API key from your side, and it counts against the user's agent budget.\n\n<Sequence\n caption=\"The handler re-enters the agent loop. The agent's LLM replies. The handler then validates the result against your schema.\"\n actors={[\n { id: 's', label: 'SDK HANDLER', sub: 'ctx.sample(...)', icon: 'window' },\n { id: 'g', label: 'MCP GATEWAY', icon: 'bridge', variant: 'accent' },\n { id: 'a', label: 'AGENT + LLM', icon: 'agent' },\n ]}\n steps={[\n { from: 's', to: 'g', label: 'sampling/request { prompt, schema, maxTokens }' },\n { from: 'g', to: 'a', label: 'MCP sampling/createMessage' },\n { note: 'a', label: 'LLM generates response' },\n { from: 'a', to: 'g', label: 'sampling result', style: 'dashed' },\n { from: 'g', to: 's', label: '{ content }', style: 'dashed' },\n { note: 's', label: 'validate against schema -> parsed value', variant: 'accent' },\n ]}\n/>\n\n## When to use it\n\n- **Natural-language reformatting** - turn a list of rows into a readable summary.\n- **Classification** - given a free-text comment, pick a category from an enum.\n- **Structured extraction** - pull the fields your action needs out of a fuzzy input.\n\nDon't use sampling for:\n\n- Raw chatbot replies. Your action should have a clear return type.\n- Very long generations. Sampling is subject to depth limits (max 3 by default) and counts against the agent budget - keep it targeted.\n\n## Calling sample\n\n```ts\n\ntesseron.action('classifyComment')\n .input(z.object({ text: z.string() }))\n .output(z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), confidence: z.number() }))\n .handler(async ({ text }, ctx) => {\n const result = await ctx.sample({\n prompt: `Classify the sentiment of this comment: \"\"\"${text}\"\"\"`,\n schema: z.object({\n sentiment: z.enum(['positive', 'neutral', 'negative']),\n confidence: z.number().min(0).max(1),\n }),\n maxTokens: 80,\n });\n return result;\n });\n```\n\n## Wire format\n\nRequest, app → gateway:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"method\": \"sampling/request\",\n \"params\": {\n \"invocationId\": \"inv_abc\",\n \"prompt\": \"Classify the sentiment …\",\n \"schema\": { \"type\": \"object\", \"properties\": { \"sentiment\": { \"enum\": [\"positive\", \"neutral\", \"negative\"] }, \"confidence\": { \"type\": \"number\" } } },\n \"maxTokens\": 80\n }\n}\n```\n\nResponse, gateway → app:\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 9,\n \"result\": { \"content\": { \"sentiment\": \"positive\", \"confidence\": 0.82 } }\n}\n```\n\nIf you passed a `schema`, the SDK validates `result.content` against it before returning from `ctx.sample`. If the model's response doesn't parse, you get a validation error and can retry.\n\n## Depth limit\n\nSampling is recursive by construction: the agent is a Claude session that called your action, and you're asking that same Claude to think again. Without a cap, a malicious or buggy chain could spiral.\n\nThe MCP gateway enforces `maxSamplingDepth = 3`. Each request from a handler that was itself invoked via sampling increments the counter. Exceeded → error `-32008 SamplingDepthExceeded`.\n\n## Capability gate\n\nNot every MCP client supports sampling. Before calling `ctx.sample`, check the capability:\n\n```ts\nif (!ctx.agentCapabilities.sampling) {\n // Fall back: return something useful without the LLM.\n}\nconst result = await ctx.sample({ /* ... */ });\n```\n\nOr let the SDK throw `SamplingNotAvailableError` (error code `-32006`) and catch it. Pick whichever fits your UX.\n\n### Client compatibility\n\nSampling only works when the connected MCP client advertises `capabilities.sampling` during the MCP `initialize` handshake. Tesseron captures the client's capabilities at that point and flows them to every SDK session as `ctx.agentCapabilities.sampling` — so a handler always sees the real answer, even when a particular client (for example, Claude Code as of this writing) has not yet implemented `sampling/createMessage`. If a handler calls `ctx.sample()` anyway on such a client, the SDK throws a structured `SamplingNotAvailableError` including the client name (when available) instead of a raw JSON-RPC `-32601 Method not found`, so callers can branch on `error instanceof SamplingNotAvailableError` and return a graceful fallback.\n\nNext: [elicitation](/protocol/elicitation/) - same shape, but with the user instead of the model."},{"slug":"protocol/security","title":"Security model","description":"Origin allowlist, claim codes, multi-app namespacing, and the threats Tesseron does and does not defend against.","section":"protocol","related":["protocol/handshake","protocol/transport"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nTesseron's security model is **local-first, user-authorised**. The MCP gateway binds to localhost and won't expose any action until a human types a short code out-of-band. These are the two gates.\n\n## Gate 1 - origin allowlist\n\n<Diagram\n caption=\"The MCP gateway inspects Origin at the upgrade handshake. Non-localhost origins are rejected unless explicitly allowed.\"\n nodeWidth={200}\n spacing={80}\n nodes={[\n { id: 'app', label: 'YOUR APP', sub: 'http://localhost:3000', icon: 'window' },\n { id: 'evil', label: 'ATTACKER', sub: 'https://evil.com', icon: 'x', variant: 'danger' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'verifyClient()', icon: 'shield', variant: 'accent' },\n { id: 'accept', label: 'ACCEPT', sub: '-> tesseron/hello', icon: 'check' },\n { id: 'reject', label: 'REJECT', sub: '403, close 1008', icon: 'lock', variant: 'danger' },\n ]}\n edges={[\n { from: 'app', to: 'gw', label: 'Origin: localhost' },\n { from: 'evil', to: 'gw', label: 'Origin: evil.com', danger: true },\n { from: 'gw', to: 'accept', label: 'allowlist match', accent: true },\n { from: 'gw', to: 'reject', label: 'otherwise', style: 'dashed', danger: true },\n ]}\n/>\n\nThe WebSocket upgrade is accepted when:\n\n- `Origin` starts with `http://localhost:` or `http://127.0.0.1:`, or\n- `Origin` appears verbatim in `TESSERON_ORIGIN_ALLOWLIST` (comma-separated).\n\nAnything else returns HTTP 403. This is defence-in-depth - it prevents a drive-by page on `evil.com` from spraying `tesseron/hello` messages at your gateway and enumerating your app's surface.\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). 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 can open a WebSocket to `ws://127.0.0.1:7475` and send `tesseron/hello`. The origin check only fires if the client sends an `Origin` header; non-browser clients may not. The claim code is the second gate, and it requires human cooperation.\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- **Never expand `TESSERON_ORIGIN_ALLOWLIST` by default.** Add origins only for specific agent integrations that need them.\n- **Treat the claim code as short-lived.** Don't render it persistently in the UI after the session is claimed.\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 - origin allowlist\n\n<Diagram\n caption=\"The MCP gateway inspects Origin at the upgrade handshake. Non-localhost origins are rejected unless explicitly allowed.\"\n nodeWidth={200}\n spacing={80}\n nodes={[\n { id: 'app', label: 'YOUR APP', sub: 'http://localhost:3000', icon: 'window' },\n { id: 'evil', label: 'ATTACKER', sub: 'https://evil.com', icon: 'x', variant: 'danger' },\n { id: 'gw', label: 'MCP GATEWAY', sub: 'verifyClient()', icon: 'shield', variant: 'accent' },\n { id: 'accept', label: 'ACCEPT', sub: '-> tesseron/hello', icon: 'check' },\n { id: 'reject', label: 'REJECT', sub: '403, close 1008', icon: 'lock', variant: 'danger' },\n ]}\n edges={[\n { from: 'app', to: 'gw', label: 'Origin: localhost' },\n { from: 'evil', to: 'gw', label: 'Origin: evil.com', danger: true },\n { from: 'gw', to: 'accept', label: 'allowlist match', accent: true },\n { from: 'gw', to: 'reject', label: 'otherwise', style: 'dashed', danger: true },\n ]}\n/>\n\nThe WebSocket upgrade is accepted when:\n\n- `Origin` starts with `http://localhost:` or `http://127.0.0.1:`, or\n- `Origin` appears verbatim in `TESSERON_ORIGIN_ALLOWLIST` (comma-separated).\n\nAnything else returns HTTP 403. This is defence-in-depth - it prevents a drive-by page on `evil.com` from spraying `tesseron/hello` messages at your gateway and enumerating your app's surface.\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). 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 can open a WebSocket to `ws://127.0.0.1:7475` and send `tesseron/hello`. The origin check only fires if the client sends an `Origin` header; non-browser clients may not. The claim code is the second gate, and it requires human cooperation.\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- **Never expand `TESSERON_ORIGIN_ALLOWLIST` by default.** Add origins only for specific agent integrations that need them.\n- **Treat the claim code as short-lived.** Don't render it persistently in the UI after the session is claimed.\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 (WebSocket)","description":"URL, framing, origin enforcement, reconnection, and what happens to pending work on disconnect.","section":"protocol","related":["protocol/handshake","protocol/wire-format"],"bodyRaw":"\n## Endpoint\n\nDefault gateway URL: `ws://127.0.0.1:7475`.\n\nOverridable via environment:\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `TESSERON_PORT` | `7475` | MCP gateway listen port. |\n| `TESSERON_HOST` | `127.0.0.1` | Listen host. |\n| `TESSERON_ORIGIN_ALLOWLIST` | *(empty)* | Comma-separated extra origins allowed beyond localhost. |\n\nNo subprotocol is negotiated. Standard RFC 6455 `Upgrade: websocket` handshake.\n\n## Origin allowlist\n\nThe MCP gateway verifies the `Origin` header during the upgrade handshake:\n\n- `http://localhost:*` and `http://127.0.0.1:*` - accepted unconditionally.\n- Any origin in `TESSERON_ORIGIN_ALLOWLIST` - accepted.\n- Everything else - `cb(false, 403)` rejects the upgrade.\n\nThis is a defence-in-depth measure, **not** a substitute for the claim code. Both layers must pass before an agent can invoke actions.\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.\n- No fragmentation, no batching, no compression.\n\n## Heartbeat\n\nThere is no application-level ping. The protocol relies on TCP keep-alive and per-action timeouts (60 s default) to detect dead peers.\n\nIf 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.\n\nTo recover: call `tesseron.connect()` again. You will get a **new** `sessionId` and a **new** `claimCode` - the previous claim does not carry over. If your agent is still alive on its side, it must re-claim.\n\nWhy no auto-reconnect? Because a reclaimed session invalidates cached tool lists on the agent. An app-level reconnect lets you coordinate with UI (e.g., surface the new claim code) instead of silently rebinding.\n\n## Failure matrix\n\n| Event | App sees | MCP gateway does | Agent sees |\n|---|---|---|---|\n| MCP gateway shuts down cleanly | `close(1001)` | - | `tools/list_changed` drops those tools. |\n| Tab closes | - | Session removed, in-flight invocations cancelled. | `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| Origin rejected | `close(1008)` before any app message. | Upgrade refused 403. | N/A - never connected. |\n\nNext: the [handshake and claim flow](/protocol/handshake/).\n","bodyText":"## Endpoint\n\nDefault gateway URL: `ws://127.0.0.1:7475`.\n\nOverridable via environment:\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `TESSERON_PORT` | `7475` | MCP gateway listen port. |\n| `TESSERON_HOST` | `127.0.0.1` | Listen host. |\n| `TESSERON_ORIGIN_ALLOWLIST` | *(empty)* | Comma-separated extra origins allowed beyond localhost. |\n\nNo subprotocol is negotiated. Standard RFC 6455 `Upgrade: websocket` handshake.\n\n## Origin allowlist\n\nThe MCP gateway verifies the `Origin` header during the upgrade handshake:\n\n- `http://localhost:*` and `http://127.0.0.1:*` - accepted unconditionally.\n- Any origin in `TESSERON_ORIGIN_ALLOWLIST` - accepted.\n- Everything else - `cb(false, 403)` rejects the upgrade.\n\nThis is a defence-in-depth measure, **not** a substitute for the claim code. Both layers must pass before an agent can invoke actions.\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.\n- No fragmentation, no batching, no compression.\n\n## Heartbeat\n\nThere is no application-level ping. The protocol relies on TCP keep-alive and per-action timeouts (60 s default) to detect dead peers.\n\nIf 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.\n\nTo recover: call `tesseron.connect()` again. You will get a **new** `sessionId` and a **new** `claimCode` - the previous claim does not carry over. If your agent is still alive on its side, it must re-claim.\n\nWhy no auto-reconnect? Because a reclaimed session invalidates cached tool lists on the agent. An app-level reconnect lets you coordinate with UI (e.g., surface the new claim code) instead of silently rebinding.\n\n## Failure matrix\n\n| Event | App sees | MCP gateway does | Agent sees |\n|---|---|---|---|\n| MCP gateway shuts down cleanly | `close(1001)` | - | `tools/list_changed` drops those tools. |\n| Tab closes | - | Session removed, in-flight invocations cancelled. | `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| Origin rejected | `close(1008)` before any app message. | Upgrade refused 403. | N/A - never connected. |\n\nNext: the [handshake and claim flow](/protocol/handshake/)."},{"slug":"protocol/wire-format","title":"Wire format (JSON-RPC)","description":"The envelope shapes Tesseron uses, the full method surface in both directions, and the ID-correlation rules.","section":"protocol","related":["protocol/transport","protocol/handshake","protocol/errors","protocol/actions"],"bodyRaw":"\nimport Diagram from '../../../components/Diagram.astro';\n\nTesseron's app ↔ MCP gateway hop is **JSON-RPC 2.0**, one JSON object per WebSocket text frame. No batching, no binary, no compression - one message, one parse.\n\n<Diagram\n caption=\"Four envelope shapes. Every message on the wire is exactly one of these.\"\n nodeWidth={190}\n nodeHeight={150}\n spacing={50}\n nodes={[\n { id: 'req', label: 'REQUEST', sub: ['id + method', 'expects response'], code: 'jsonrpc: \"2.0\"', icon: 'arrow' },\n { id: 'ntf', label: 'NOTIFICATION', sub: ['method only', 'fire-and-forget'], code: 'no id', icon: 'arrow' },\n { id: 'ok', label: 'SUCCESS', sub: ['echoes request id', 'carries result'], code: 'result: R', icon: 'check', variant: 'accent' },\n { id: 'err', label: 'ERROR', sub: ['echoes request id', 'carries error object'], code: 'error: {...}', icon: 'x', variant: 'danger' },\n ]}\n edges={[]}\n/>\n\n## Envelope shapes\n\n### Request (expects a response)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"method\": \"actions/invoke\",\n \"params\": { /* method-specific */ }\n}\n```\n\n### Notification (fire-and-forget)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": { \"invocationId\": \"inv_1\", \"percent\": 40 }\n}\n```\n\n### Success response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"result\": { /* method-specific payload */ }\n}\n```\n\n### Error response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"error\": { \"code\": -32004, \"message\": \"Invalid input\", \"data\": [/* issues */] }\n}\n```\n\n`id` can be a string, number, or `null`. The SDK uses monotonically incrementing integers per connection; any JSON-RPC-compliant peer is welcome to do otherwise.\n\n## Method surface\n\n### App → Gateway (you send)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `tesseron/hello` | request | Register app, actions, resources, capabilities. First message. |\n| `actions/progress` | notification | Streaming update during an invocation. |\n| `actions/list_changed` | notification | App (re)registered / removed an action after hello. |\n| `resources/updated` | notification | Push a new value to a subscriber. |\n| `resources/list_changed` | notification | App (re)registered / removed a resource after hello. |\n| `sampling/request` | request | Ask the agent to run an LLM step. |\n| `elicitation/request` | request | Ask the user (confirm or elicit) via the agent UI. |\n| `log` | notification | Structured log forwarded to MCP logging. |\n\nPlus: the **response** for any `actions/invoke`, `resources/read`, `resources/subscribe`, `resources/unsubscribe` the gateway sent you.\n\n### Gateway → App (you handle)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `actions/invoke` | request | Agent called an action. Respond with `result` or `error`. |\n| `actions/cancel` | notification | Agent cancelled an in-flight invocation. |\n| `resources/read` | request | Agent requested current resource value. |\n| `resources/subscribe` | request | Agent subscribed to future updates. |\n| `resources/unsubscribe` | request | Agent unsubscribed. |\n\nAnd the **response** to the `tesseron/hello` you sent.\n\n## ID correlation\n\n- A peer that issues a request assigns the `id`. The other peer echoes the exact same `id` in the response.\n- The SDK keeps a `Map<id, { resolve, reject, timeoutHandle }>` of pending outbound requests. On response it looks up the id, clears the timer, and settles the promise.\n- On transport close, **every pending request is rejected** with `TransportClosedError`. There is no resumable queue; reconnect means re-send.\n- Notifications have no `id` - they never fail visibly and never get a response. Don't send data you care about as a notification.\n\n## Framing\n\n- Each JSON-RPC object is serialized with `JSON.stringify` and sent as **one text frame**.\n- Binary frames sent by the peer are coerced to text and parsed - tolerated but not idiomatic.\n- There is **no length prefix and no framing header**. WebSocket gives us message boundaries for free.\n- There is **no batching**. Every message is self-contained.\n\n## Versioning\n\n`tesseron/hello` includes `protocolVersion: \"1.0.0\"`. The gateway parses it as `major.minor`: a major mismatch is rejected with `-32000 ProtocolMismatch` and the WebSocket is closed, a minor mismatch is accepted with a stderr warning (newer fields may be silently dropped), an exact match is silent. See [Handshake](/protocol/handshake/#protocol-version-mismatch).\n\nNext: how that WebSocket gets established - [Transport](/protocol/transport/).\n","bodyText":"Tesseron's app ↔ MCP gateway hop is **JSON-RPC 2.0**, one JSON object per WebSocket text frame. No batching, no binary, no compression - one message, one parse.\n\n## Envelope shapes\n\n### Request (expects a response)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"method\": \"actions/invoke\",\n \"params\": { /* method-specific */ }\n}\n```\n\n### Notification (fire-and-forget)\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"actions/progress\",\n \"params\": { \"invocationId\": \"inv_1\", \"percent\": 40 }\n}\n```\n\n### Success response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"result\": { /* method-specific payload */ }\n}\n```\n\n### Error response\n\n```json\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 42,\n \"error\": { \"code\": -32004, \"message\": \"Invalid input\", \"data\": [/* issues */] }\n}\n```\n\n`id` can be a string, number, or `null`. The SDK uses monotonically incrementing integers per connection; any JSON-RPC-compliant peer is welcome to do otherwise.\n\n## Method surface\n\n### App → Gateway (you send)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `tesseron/hello` | request | Register app, actions, resources, capabilities. First message. |\n| `actions/progress` | notification | Streaming update during an invocation. |\n| `actions/list_changed` | notification | App (re)registered / removed an action after hello. |\n| `resources/updated` | notification | Push a new value to a subscriber. |\n| `resources/list_changed` | notification | App (re)registered / removed a resource after hello. |\n| `sampling/request` | request | Ask the agent to run an LLM step. |\n| `elicitation/request` | request | Ask the user (confirm or elicit) via the agent UI. |\n| `log` | notification | Structured log forwarded to MCP logging. |\n\nPlus: the **response** for any `actions/invoke`, `resources/read`, `resources/subscribe`, `resources/unsubscribe` the gateway sent you.\n\n### Gateway → App (you handle)\n\n| Method | Kind | Purpose |\n|---|---|---|\n| `actions/invoke` | request | Agent called an action. Respond with `result` or `error`. |\n| `actions/cancel` | notification | Agent cancelled an in-flight invocation. |\n| `resources/read` | request | Agent requested current resource value. |\n| `resources/subscribe` | request | Agent subscribed to future updates. |\n| `resources/unsubscribe` | request | Agent unsubscribed. |\n\nAnd the **response** to the `tesseron/hello` you sent.\n\n## ID correlation\n\n- A peer that issues a request assigns the `id`. The other peer echoes the exact same `id` in the response.\n- The SDK keeps a `Map<id, { resolve, reject, timeoutHandle }>` of pending outbound requests. On response it looks up the id, clears the timer, and settles the promise.\n- On transport close, **every pending request is rejected** with `TransportClosedError`. There is no resumable queue; reconnect means re-send.\n- Notifications have no `id` - they never fail visibly and never get a response. Don't send data you care about as a notification.\n\n## Framing\n\n- Each JSON-RPC object is serialized with `JSON.stringify` and sent as **one text frame**.\n- Binary frames sent by the peer are coerced to text and parsed - tolerated but not idiomatic.\n- There is **no length prefix and no framing header**. WebSocket gives us message boundaries for free.\n- There is **no batching**. Every message is self-contained.\n\n## Versioning\n\n`tesseron/hello` includes `protocolVersion: \"1.0.0\"`. The gateway parses it as `major.minor`: a major mismatch is rejected with `-32000 ProtocolMismatch` and the WebSocket is closed, a minor mismatch is accepted with a stderr warning (newer fields may be silently dropped), an exact match is silent. See [Handshake](/protocol/handshake/#protocol-version-mismatch).\n\nNext: how that WebSocket gets established - [Transport](/protocol/transport/)."},{"slug":"sdk/index","title":"SDK overview","description":"What a Tesseron SDK has to expose - in TypeScript today, in any other language tomorrow.","section":"sdk","related":["sdk/porting","sdk/typescript/index","protocol/index"],"bodyRaw":"\nimport { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';\nimport Mermaid from '../../../components/Mermaid.astro';\n\nAn SDK is the part of Tesseron that lives in **your** process. It serialises outgoing JSON-RPC, dispatches incoming method calls into your handlers, and wraps the per-invocation protocol glue (progress, cancel, sample, elicit) in a shape that feels natural in the host language.\n\nToday we ship five TypeScript packages. The surface they expose - the **SDK contract** - is the portable part. A Python or Go implementation reuses the same concepts.\n\n## The shipped TypeScript SDK\n\n<Mermaid\n caption=\"Five packages. core owns the protocol types and builder; the others wrap transport and framework integration.\"\n code={`\nflowchart LR\n core[\"@tesseron/core<br/>action & resource builders<br/>JSON-RPC dispatcher<br/>protocol types\"]\n web[\"@tesseron/web<br/>browser client<br/>WebSocket transport\"]\n server[\"@tesseron/server<br/>Node client<br/>ws transport\"]\n react[\"@tesseron/react<br/>useTesseronAction<br/>useTesseronResource<br/>useTesseronConnection\"]\n mcp[\"@tesseron/mcp<br/>MCP gateway<br/>MCP stdio bridge\"]\n web -- \"re-exports\" --> core\n server -- \"re-exports\" --> core\n react -- \"wraps\" --> web\n mcp -. \"shared types\" .-> core\n`}\n/>\n\n<CardGrid>\n <LinkCard title=\"Quickstart\" href=\"./typescript/\"\n description=\"Install one package, declare one action, connect.\" />\n <LinkCard title=\"@tesseron/core\" href=\"./typescript/core/\"\n description=\"Action & resource builders, JSON-RPC dispatcher, protocol types. Zero runtime deps beyond Standard Schema.\" />\n <LinkCard title=\"@tesseron/web\" href=\"./typescript/web/\"\n description=\"Browser WebSocket transport + singleton client.\" />\n <LinkCard title=\"@tesseron/server\" href=\"./typescript/server/\"\n description=\"Node `ws`-backed transport + singleton client.\" />\n <LinkCard title=\"@tesseron/react\" href=\"./typescript/react/\"\n description=\"`useTesseronAction`, `useTesseronResource`, `useTesseronConnection`.\" />\n <LinkCard title=\"@tesseron/mcp\" href=\"./typescript/mcp/\"\n description=\"The MCP gateway itself. CLI, bundled into the Claude Code plugin.\" />\n</CardGrid>\n\n## The portable SDK contract\n\nWhatever language you implement Tesseron in, the SDK has to expose these primitives. They correspond 1:1 with the [protocol](/protocol/).\n\n| Primitive | In TypeScript | Covers |\n|---|---|---|\n| **Client lifecycle** | `tesseron.app({ id, name, … })` + `tesseron.connect()` | Handshake, session ID, claim code. |\n| **Action builder** | `.action(name).describe(…).input(…).output(…).handler(fn)` | Declaring a named, typed, handler-backed action. |\n| **Resource builder** | `.resource(name).read(fn).subscribe(emitter)` | Declaring readable + optionally subscribable state. |\n| **Standard Schema bridge** | Accepts any `StandardSchemaV1<T>` validator (Zod, Valibot, ArkType, …) | Input / output / sampling / elicitation validation. |\n| **Invocation context** | `(input, ctx)` passed to every handler | `ctx.signal`, `ctx.progress`, `ctx.sample`, `ctx.confirm`, `ctx.elicit`, `ctx.log`, `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`. |\n| **Transport abstraction** | `Transport { send, onMessage, onClose, close }` | WebSocket in practice, but the protocol is transport-agnostic. |\n| **JSON-RPC dispatcher** | `JsonRpcDispatcher` | Request/notification handling, ID correlation, timeout, error mapping. |\n| **Structured error model** | `TesseronError(code, message, data?)` | Mapping to / from JSON-RPC error objects with the error codes in the [catalog](/protocol/errors/). |\n\n## Other SDKs\n\n<CardGrid>\n <LinkCard title=\"Python SDK (planned)\" href=\"./python/\"\n description=\"Status, intended shape, timeline.\" />\n <LinkCard title=\"Port Tesseron to your language\" href=\"./porting/\"\n description=\"Step-by-step guide, protocol conformance checklist, test strategy.\" />\n</CardGrid>\n","bodyText":"An SDK is the part of Tesseron that lives in **your** process. It serialises outgoing JSON-RPC, dispatches incoming method calls into your handlers, and wraps the per-invocation protocol glue (progress, cancel, sample, elicit) in a shape that feels natural in the host language.\n\nToday we ship five TypeScript packages. The surface they expose - the **SDK contract** - is the portable part. A Python or Go implementation reuses the same concepts.\n\n## The shipped TypeScript SDK\n\naction & resource builders<br/>JSON-RPC dispatcher<br/>protocol types\"]\n web[\"@tesseron/web<br/>browser client<br/>WebSocket transport\"]\n server[\"@tesseron/server<br/>Node client<br/>ws transport\"]\n react[\"@tesseron/react<br/>useTesseronAction<br/>useTesseronResource<br/>useTesseronConnection\"]\n mcp[\"@tesseron/mcp<br/>MCP gateway<br/>MCP stdio bridge\"]\n web -- \"re-exports\" --> core\n server -- \"re-exports\" --> core\n react -- \"wraps\" --> web\n mcp -. \"shared types\" .-> core\n`}\n/>\n\n## The portable SDK contract\n\nWhatever language you implement Tesseron in, the SDK has to expose these primitives. They correspond 1:1 with the [protocol](/protocol/).\n\n| Primitive | In TypeScript | Covers |\n|---|---|---|\n| **Client lifecycle** | `tesseron.app({ id, name, … })` + `tesseron.connect()` | Handshake, session ID, claim code. |\n| **Action builder** | `.action(name).describe(…).input(…).output(…).handler(fn)` | Declaring a named, typed, handler-backed action. |\n| **Resource builder** | `.resource(name).read(fn).subscribe(emitter)` | Declaring readable + optionally subscribable state. |\n| **Standard Schema bridge** | Accepts any `StandardSchemaV1<T>` validator (Zod, Valibot, ArkType, …) | Input / output / sampling / elicitation validation. |\n| **Invocation context** | `(input, ctx)` passed to every handler | `ctx.signal`, `ctx.progress`, `ctx.sample`, `ctx.confirm`, `ctx.elicit`, `ctx.log`, `ctx.agent`, `ctx.agentCapabilities`, `ctx.client`. |\n| **Transport abstraction** | `Transport { send, onMessage, onClose, close }` | WebSocket in practice, but the protocol is transport-agnostic. |\n| **JSON-RPC dispatcher** | `JsonRpcDispatcher` | Request/notification handling, ID correlation, timeout, error mapping. |\n| **Structured error model** | `TesseronError(code, message, data?)` | Mapping to / from JSON-RPC error objects with the error codes in the [catalog](/protocol/errors/). |\n\n## Other SDKs"},{"slug":"sdk/porting","title":"Port Tesseron to your language","description":"Step-by-step guide to writing a new Tesseron SDK and a conformance checklist for testing it.","section":"sdk","related":["sdk/index","protocol/index","protocol/wire-format","sdk/typescript/core"],"bodyRaw":"\nTesseron's wire protocol is small enough that a competent engineer can implement an SDK for a new language in a couple of days. This page is your map.\n\n## What you're actually building\n\nA Tesseron SDK is three things glued together:\n\n1. **A WebSocket client** that speaks JSON-RPC 2.0.\n2. **A builder DSL** for declaring actions and resources.\n3. **A bridge** between the JSON-RPC dispatcher and the builder's registered handlers.\n\nThe full [protocol spec](/protocol/) is the source of truth. If anything on this page contradicts it, the protocol wins.\n\n## Step 1 - pick a runtime model\n\nTwo choices decide the shape of everything that follows.\n\n- **Async model.** Native `async`/`await` / futures / goroutines - whatever your language uses for concurrency. All SDK methods that may block (connect, sample, elicit, progress-waiting action handlers) should be async. Synchronous handlers are fine, but the client's I/O loop must not be.\n- **Schema library.** You need a way for users to express typed inputs. Pick one well-known library (Pydantic in Python, `go-playground/validator` in Go, Serde+schemars in Rust), and accept any user-provided schema that can round-trip to JSON Schema.\n\n## Step 2 - model the protocol types\n\nPort these from the [wire format page](/protocol/wire-format/):\n\n- JSON-RPC 2.0 request / notification / success / error envelopes.\n- `HelloParams`, `WelcomeResult`, `ActionAnnotations`, `InvokeParams`, `ProgressParams`, `CancelParams`, `SampleParams`, `ElicitParams`, `ReadParams`, `SubscribeParams`, `UpdatedParams`.\n- The error-code enum from the [errors page](/protocol/errors/).\n\nGive the error codes first-class names. Don't pass bare integers around - they accumulate magic.\n\n## Step 3 - write the dispatcher\n\nA bidirectional JSON-RPC dispatcher with:\n\n- `on(method, handler)` - respond to incoming requests.\n- `onNotification(method, handler)` - respond to incoming notifications.\n- `request(method, params, { timeoutMs })` - send a request, await the response. ID generation, timeout handling, rejection on close.\n- `notify(method, params)` - send a fire-and-forget notification.\n- `receive(message)` - given a parsed JSON-RPC envelope, dispatch to a handler or resolve a pending request.\n\nTest this in isolation against a pair of in-memory dispatchers. No networking yet.\n\n## Step 4 - write the transport\n\nA WebSocket client that:\n\n- Connects to `ws://127.0.0.1:7475` (configurable).\n- Serialises objects with the language's standard JSON library.\n- Exposes `send`, `onMessage`, `onClose`, `close`.\n- Parses incoming text frames as JSON and calls the `onMessage` handler.\n\nDon't reinvent backoff or reconnect inside the transport - that's the user's job.\n\n## Step 5 - builder DSL\n\nWhatever shape is idiomatic. In TypeScript we use a fluent builder (`action(...).describe(...).input(...).handler(...)`). In Python, decorators. In Rust, probably a struct with a method-chain pattern. What matters is that it ultimately produces an `ActionDefinition`:\n\n```\nActionDefinition {\n name: string;\n description?: string;\n inputSchema?: StandardJsonSchema;\n outputSchema?: StandardJsonSchema;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput: boolean;\n handler: (input, ctx) => output;\n}\n```\n\nSame for `ResourceDefinition`.\n\n## Step 6 - bind it together\n\n```\nclass TesseronClient {\n constructor(transport, dispatcher) { … }\n app(info) { … } // records app manifest for hello\n action(name) { return new Builder(this, name) }\n resource(name) { return new ResBuilder(this, name) }\n async connect() {\n await transport.open();\n dispatcher.on('actions/invoke', this._onInvoke);\n dispatcher.onNotification('actions/cancel', this._onCancel);\n dispatcher.on('resources/read', this._onRead);\n dispatcher.on('resources/subscribe', this._onSub);\n dispatcher.on('resources/unsubscribe', this._onUnsub);\n return await dispatcher.request('tesseron/hello', this._manifest());\n }\n}\n```\n\nEach `on(...)` handler maps to the corresponding builder. Implement progress / sample / elicit / log on the `ActionContext` the same way.\n\n## Step 7 - conformance checklist\n\nBefore you ship, make sure the SDK passes every line of this list. An SDK that fails any line is not Tesseron-compliant.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after WebSocket open.\n- [ ] Sends `protocolVersion = \"1.0.0\"` exactly.\n- [ ] Sends `app.id` that matches `/^[a-z][a-z0-9_]*$/`.\n- [ ] Surfaces `welcome.claimCode` to the caller (stdout, event, return value - your choice).\n- [ ] Surfaces `welcome.capabilities` as the authoritative agent capability set to handlers.\n\n**Actions**\n- [ ] Validates action input against the Standard-Schema-equivalent schema before the handler runs.\n- [ ] Returns `-32004 InputValidation` with issues on failure.\n- [ ] Passes output through unchanged by default; validates and returns `-32005` when strict output is enabled and validation fails.\n- [ ] Supports per-invocation timeouts, default 60 000 ms, configurable per action.\n- [ ] Aborts via idiomatic cancellation primitive when the MCP gateway sends `actions/cancel`.\n- [ ] Returns `-32001 Cancelled` on explicit cancel; `-32002 Timeout` on timer.\n- [ ] Emits `actions/progress` notifications from `ctx.progress(...)`.\n\n**Sampling / Confirmation / Elicitation**\n- [ ] Sends `sampling/request` / `elicitation/request` as requests (not notifications).\n- [ ] `ctx.confirm` sends an elicit with an empty-properties object schema and collapses decline / cancel / missing-capability to `false`.\n- [ ] `ctx.elicit` validates the response against the supplied Standard Schema and returns `null` on decline / cancel.\n- [ ] Raises a typed error (`SamplingNotAvailable`, `ElicitationNotAvailable`) when capabilities don't include them - except `ctx.confirm`, which swallows missing elicitation and returns `false`.\n- [ ] Rejects top-level non-object / `oneOf` / `anyOf` / nested-object elicit schemas with `-32602 InvalidParams` at the call site.\n- [ ] Caps sampling depth at 3 (or honours the gateway's cap).\n\n**Resources**\n- [ ] Responds to `resources/read` with `{ value }`.\n- [ ] Accepts `resources/subscribe` and returns the emitter callback's unsubscribe.\n- [ ] Sends `resources/updated` notifications on change.\n- [ ] Cleans up subscriptions on `resources/unsubscribe` and on transport close.\n\n**Lifecycle**\n- [ ] On transport close: rejects all pending outbound requests, aborts all in-flight invocations, clears all subscriptions.\n- [ ] Does not auto-reconnect silently.\n\n**Error model**\n- [ ] Uses exactly the Tesseron error codes from [the errors catalog](/protocol/errors/).\n- [ ] Preserves `error.data` verbatim when surfacing errors to handlers / users.\n\n**Interop**\n- [ ] Round-trips with the reference `@tesseron/mcp` gateway against at least one real MCP client (Claude Code, Cursor, Claude Desktop).\n\n## Step 8 - publish + link\n\nOpen a PR against the main Tesseron repo adding your SDK to the README. Add a page to this docs site under `/sdk/<your-language>/` mirroring the Python skeleton.\n\nOnce your SDK has shipped a 1.0 that passes the checklist on real agents, we'll happily link it as a first-class implementation.\n","bodyText":"Tesseron's wire protocol is small enough that a competent engineer can implement an SDK for a new language in a couple of days. This page is your map.\n\n## What you're actually building\n\nA Tesseron SDK is three things glued together:\n\n1. **A WebSocket client** that speaks JSON-RPC 2.0.\n2. **A builder DSL** for declaring actions and resources.\n3. **A bridge** between the JSON-RPC dispatcher and the builder's registered handlers.\n\nThe full [protocol spec](/protocol/) is the source of truth. If anything on this page contradicts it, the protocol wins.\n\n## Step 1 - pick a runtime model\n\nTwo choices decide the shape of everything that follows.\n\n- **Async model.** Native `async`/`await` / futures / goroutines - whatever your language uses for concurrency. All SDK methods that may block (connect, sample, elicit, progress-waiting action handlers) should be async. Synchronous handlers are fine, but the client's I/O loop must not be.\n- **Schema library.** You need a way for users to express typed inputs. Pick one well-known library (Pydantic in Python, `go-playground/validator` in Go, Serde+schemars in Rust), and accept any user-provided schema that can round-trip to JSON Schema.\n\n## Step 2 - model the protocol types\n\nPort these from the [wire format page](/protocol/wire-format/):\n\n- JSON-RPC 2.0 request / notification / success / error envelopes.\n- `HelloParams`, `WelcomeResult`, `ActionAnnotations`, `InvokeParams`, `ProgressParams`, `CancelParams`, `SampleParams`, `ElicitParams`, `ReadParams`, `SubscribeParams`, `UpdatedParams`.\n- The error-code enum from the [errors page](/protocol/errors/).\n\nGive the error codes first-class names. Don't pass bare integers around - they accumulate magic.\n\n## Step 3 - write the dispatcher\n\nA bidirectional JSON-RPC dispatcher with:\n\n- `on(method, handler)` - respond to incoming requests.\n- `onNotification(method, handler)` - respond to incoming notifications.\n- `request(method, params, { timeoutMs })` - send a request, await the response. ID generation, timeout handling, rejection on close.\n- `notify(method, params)` - send a fire-and-forget notification.\n- `receive(message)` - given a parsed JSON-RPC envelope, dispatch to a handler or resolve a pending request.\n\nTest this in isolation against a pair of in-memory dispatchers. No networking yet.\n\n## Step 4 - write the transport\n\nA WebSocket client that:\n\n- Connects to `ws://127.0.0.1:7475` (configurable).\n- Serialises objects with the language's standard JSON library.\n- Exposes `send`, `onMessage`, `onClose`, `close`.\n- Parses incoming text frames as JSON and calls the `onMessage` handler.\n\nDon't reinvent backoff or reconnect inside the transport - that's the user's job.\n\n## Step 5 - builder DSL\n\nWhatever shape is idiomatic. In TypeScript we use a fluent builder (`action(...).describe(...).input(...).handler(...)`). In Python, decorators. In Rust, probably a struct with a method-chain pattern. What matters is that it ultimately produces an `ActionDefinition`:\n\n```\nActionDefinition {\n name: string;\n description?: string;\n inputSchema?: StandardJsonSchema;\n outputSchema?: StandardJsonSchema;\n annotations?: ActionAnnotations;\n timeoutMs?: number;\n strictOutput: boolean;\n handler: (input, ctx) => output;\n}\n```\n\nSame for `ResourceDefinition`.\n\n## Step 6 - bind it together\n\n```\nclass TesseronClient {\n constructor(transport, dispatcher) { … }\n app(info) { … } // records app manifest for hello\n action(name) { return new Builder(this, name) }\n resource(name) { return new ResBuilder(this, name) }\n async connect() {\n await transport.open();\n dispatcher.on('actions/invoke', this._onInvoke);\n dispatcher.onNotification('actions/cancel', this._onCancel);\n dispatcher.on('resources/read', this._onRead);\n dispatcher.on('resources/subscribe', this._onSub);\n dispatcher.on('resources/unsubscribe', this._onUnsub);\n return await dispatcher.request('tesseron/hello', this._manifest());\n }\n}\n```\n\nEach `on(...)` handler maps to the corresponding builder. Implement progress / sample / elicit / log on the `ActionContext` the same way.\n\n## Step 7 - conformance checklist\n\nBefore you ship, make sure the SDK passes every line of this list. An SDK that fails any line is not Tesseron-compliant.\n\n**Handshake**\n- [ ] Sends `tesseron/hello` immediately after WebSocket open.\n- [ ] Sends `protocolVersion = \"1.0.0\"` exactly.\n- [ ] Sends `app.id` that matches `/^[a-z][a-z0-9_]*$/`.\n- [ ] Surfaces `welcome.claimCode` to the caller (stdout, event, return value - your choice).\n- [ ] Surfaces `welcome.capabilities` as the authoritative agent capability set to handlers.\n\n**Actions**\n- [ ] Validates action input against the Standard-Schema-equivalent schema before the handler runs.\n- [ ] Returns `-32004 InputValidation` with issues on failure.\n- [ ] Passes output through unchanged by default; validates and returns `-32005` when strict output is enabled and validation fails.\n- [ ] Supports per-invocation timeouts, default 60 000 ms, configurable per action.\n- [ ] Aborts via idiomatic cancellation primitive when the MCP gateway sends `actions/cancel`.\n- [ ] Returns `-32001 Cancelled` on explicit cancel; `-32002 Timeout` on timer.\n- [ ] Emits `actions/progress` notifications from `ctx.progress(...)`.\n\n**Sampling / Confirmation / Elicitation**\n- [ ] Sends `sampling/request` / `elicitation/request` as requests (not notifications).\n- [ ] `ctx.confirm` sends an elicit with an empty-properties object schema and collapses decline / cancel / missing-capability to `false`.\n- [ ] `ctx.elicit` validates the response against the supplied Standard Schema and returns `null` on decline / cancel.\n- [ ] Raises a typed error (`SamplingNotAvailable`, `ElicitationNotAvailable`) when capabilities don't include them - except `ctx.confirm`, which swallows missing elicitation and returns `false`.\n- [ ] Rejects top-level non-object / `oneOf` / `anyOf` / nested-object elicit schemas with `-32602 InvalidParams` at the call site.\n- [ ] Caps sampling depth at 3 (or honours the gateway's cap).\n\n**Resources**\n- [ ] Responds to `resources/read` with `{ value }`.\n- [ ] Accepts `resources/subscribe` and returns the emitter callback's unsubscribe.\n- [ ] Sends `resources/updated` notifications on change.\n- [ ] Cleans up subscriptions on `resources/unsubscribe` and on transport close.\n\n**Lifecycle**\n- [ ] On transport close: rejects all pending outbound requests, aborts all in-flight invocations, clears all subscriptions.\n- [ ] Does not auto-reconnect silently.\n\n**Error model**\n- [ ] Uses exactly the Tesseron error codes from [the errors catalog](/protocol/errors/).\n- [ ] Preserves `error.data` verbatim when surfacing errors to handlers / users.\n\n**Interop**\n- [ ] Round-trips with the reference `@tesseron/mcp` gateway against at least one real MCP client (Claude Code, Cursor, Claude Desktop).\n\n## Step 8 - publish + link\n\nOpen a PR against the main Tesseron repo adding your SDK to the README. Add a page to this docs site under `/sdk/<your-language>/` mirroring the Python skeleton.\n\nOnce your SDK has shipped a 1.0 that passes the checklist on real agents, we'll happily link it as a first-class implementation."},{"slug":"sdk/python/index","title":"Python SDK (planned)","description":"Status and intended shape of a Python implementation of the Tesseron SDK.","section":"sdk","related":["sdk/index","sdk/porting"],"bodyRaw":"\nA Python SDK is on the roadmap but **not yet shipped**.\n\nWhen it lands, it will implement the same [portable SDK contract](/sdk/#the-portable-sdk-contract) as `@tesseron/core`:\n\n- An action builder that accepts any Python validator (Pydantic v2, `msgspec`, `attrs`+`cattrs`) and produces JSON Schema.\n- An invocation context object with `progress`, `sample`, `confirm`, `elicit`, `log`, and an `asyncio.CancelledError`-based cancellation contract.\n- A resource builder with `.read()` and `.subscribe()`.\n- A WebSocket transport using `websockets` or `aiohttp`.\n- A CLI and an optional `FastAPI` integration helper.\n\n## Why Python at all\n\nTwo use cases we hear most:\n\n1. **Backend services already written in Python.** You have a Flask / FastAPI / Django app and don't want to proxy everything through a Node service just to expose it to Claude.\n2. **Local Python tooling.** Jupyter notebooks, data-analysis scripts, personal CLIs - all things where exposing half a dozen actions to Claude adds real leverage.\n\nBoth are better served by a native Python SDK than by shelling out to Node.\n\n## Design notes\n\nRough shape, subject to change:\n\n```python\nfrom tesseron import Tesseron\nfrom pydantic import BaseModel\n\ntesseron = Tesseron(app={\"id\": \"notes\", \"name\": \"Notes\"})\n\nclass CreateNoteInput(BaseModel):\n title: str\n body: str = \"\"\n\n@tesseron.action(\"createNote\", input=CreateNoteInput)\nasync def create_note(input: CreateNoteInput, ctx):\n note = {\"id\": new_id(), \"title\": input.title, \"body\": input.body}\n store.add(note)\n ctx.progress(message=\"saved\", percent=100)\n return note\n\nawait tesseron.connect()\n```\n\nDecorator-flavoured where it fits the ecosystem better than the fluent builder. The wire contract is identical - any Tesseron SDK must produce the same `tesseron/hello` envelope and respond to the same `actions/invoke` request.\n\n## Roadmap\n\n- Early spike: TBD, tracked in the [Tesseron repo](https://github.com/BrainBlend-AI/tesseron).\n- 1.0 target: feature-parity with `@tesseron/core` + `@tesseron/server`.\n\nIf you want to contribute or help shape the API, open a discussion on GitHub.\n","bodyText":"A Python SDK is on the roadmap but **not yet shipped**.\n\nWhen it lands, it will implement the same [portable SDK contract](/sdk/#the-portable-sdk-contract) as `@tesseron/core`:\n\n- An action builder that accepts any Python validator (Pydantic v2, `msgspec`, `attrs`+`cattrs`) and produces JSON Schema.\n- An invocation context object with `progress`, `sample`, `confirm`, `elicit`, `log`, and an `asyncio.CancelledError`-based cancellation contract.\n- A resource builder with `.read()` and `.subscribe()`.\n- A WebSocket transport using `websockets` or `aiohttp`.\n- A CLI and an optional `FastAPI` integration helper.\n\n## Why Python at all\n\nTwo use cases we hear most:\n\n1. **Backend services already written in Python.** You have a Flask / FastAPI / Django app and don't want to proxy everything through a Node service just to expose it to Claude.\n2. **Local Python tooling.** Jupyter notebooks, data-analysis scripts, personal CLIs - all things where exposing half a dozen actions to Claude adds real leverage.\n\nBoth are better served by a native Python SDK than by shelling out to Node.\n\n## Design notes\n\nRough shape, subject to change:\n\n```python\nfrom tesseron import Tesseron\nfrom pydantic import BaseModel\n\ntesseron = Tesseron(app={\"id\": \"notes\", \"name\": \"Notes\"})\n\nclass CreateNoteInput(BaseModel):\n title: str\n body: str = \"\"\n\n@tesseron.action(\"createNote\", input=CreateNoteInput)\nasync def create_note(input: CreateNoteInput, ctx):\n note = {\"id\": new_id(), \"title\": input.title, \"body\": input.body}\n store.add(note)\n ctx.progress(message=\"saved\", percent=100)\n return note\n\nawait tesseron.connect()\n```\n\nDecorator-flavoured where it fits the ecosystem better than the fluent builder. The wire contract is identical - any Tesseron SDK must produce the same `tesseron/hello` envelope and respond to the same `actions/invoke` request.\n\n## Roadmap\n\n- Early spike: TBD, tracked in the [Tesseron repo](https://github.com/BrainBlend-AI/tesseron).\n- 1.0 target: feature-parity with `@tesseron/core` + `@tesseron/server`.\n\nIf you want to contribute or help shape the API, open a discussion on GitHub."},{"slug":"sdk/typescript/action-builder","title":"Action builder","description":"Every step of the fluent builder, what it does, and when to use it.","section":"sdk","related":["protocol/actions","sdk/typescript/standard-schema","sdk/typescript/context"],"bodyRaw":"\nThe action builder is the fluent API on `tesseron.action(name)`. It chains until `.handler(fn)` terminates it with an `ActionDefinition<I, O>`.\n\n## Signature\n\n```ts\ninterface ActionBuilder<I = unknown, O = unknown> {\n describe(description: string): ActionBuilder<I, O>;\n input<NewI>(schema: StandardSchemaV1<NewI>, jsonSchema?: unknown): ActionBuilder<NewI, O>;\n output<NewO>(schema: StandardSchemaV1<NewO>, jsonSchema?: unknown): ActionBuilder<I, NewO>;\n annotate(annotations: ActionAnnotations): ActionBuilder<I, O>;\n timeout(options: { ms: number }): ActionBuilder<I, O>;\n strictOutput(): ActionBuilder<I, O>;\n handler(fn: (input: I, ctx: ActionContext) => O | Promise<O>): ActionDefinition<I, O>;\n}\n```\n\n## `.describe(string)`\n\nHuman-readable description. Shown to the agent's LLM verbatim as the MCP tool description. This is the single biggest lever for getting the agent to call your action correctly; write it as you would write a function docstring for a teammate.\n\n```ts\ntesseron.action('searchProducts')\n .describe(\n 'Search the product catalog. Returns up to `limit` products ordered by ' +\n 'relevance. Use when the user is trying to find items to buy.'\n );\n```\n\n## `.input(schema)` and `.input(schema, jsonSchema)`\n\nBind a Standard Schema validator for input. The schema is used for:\n\n1. **Runtime validation** - invalid input fails with code `-32004` before the handler runs.\n2. **Type inference** - `I` in `handler: (input: I, ctx) => …`.\n3. **JSON Schema export** - for the MCP tool's `inputSchema`.\n\nMost Standard Schema libraries expose JSON-Schema conversion utilities; the SDK uses whatever your validator provides. If the conversion is missing or inadequate, pass a hand-written JSON Schema as the second argument:\n\n```ts\n.input(\n z.object({ sku: z.string(), qty: z.number().int().positive() }),\n { type: 'object', properties: { sku: { type: 'string' }, qty: { type: 'integer', minimum: 1 } }, required: ['sku', 'qty'] },\n)\n```\n\n## `.output(schema)` / `.output(schema, jsonSchema)`\n\nBind a Standard Schema for the return value. By default **this is informational** - the value is passed through unchanged. Call `.strictOutput()` to enforce.\n\n```ts\n.output(z.object({ id: z.string(), itemId: z.string() }))\n```\n\n## `.annotate({…})`\n\nAdvisory metadata surfaced to the agent.\n\n```ts\ninterface ActionAnnotations {\n readOnly?: boolean;\n destructive?: boolean;\n requiresConfirmation?: boolean;\n}\n```\n\n| Field | Use for |\n|---|---|\n| `readOnly: true` | Pure reads. Agent may parallelise. |\n| `destructive: true` | Mutates persistent state. Agent SHOULD warn the user. |\n| `requiresConfirmation: true` | Agent MUST NOT call without explicit user confirmation. Often paired with `ctx.confirm` inside the handler as a second gate. |\n\n## `.timeout({ ms })`\n\nPer-invocation timeout. Default 60 000 ms. When exceeded, the handler's `ctx.signal` aborts and the invocation returns error `-32002 Timeout`.\n\n```ts\n.timeout({ ms: 5 * 60 * 1000 }) // big report, 5 minutes\n```\n\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\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\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\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\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\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.0.0'\n HelloParams, WelcomeResult, TesseronCapabilities,\n AppMetadata, AgentIdentity, ActionAnnotations,\n ActionInvokeParams, ActionProgressParams, ActionCancelParams,\n ResourceReadParams, ResourceSubscribeParams, ResourceUpdatedParams,\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.0.0'\n HelloParams, WelcomeResult, TesseronCapabilities,\n AppMetadata, AgentIdentity, ActionAnnotations,\n ActionInvokeParams, ActionProgressParams, ActionCancelParams,\n ResourceReadParams, ResourceSubscribeParams, ResourceUpdatedParams,\n} from '@tesseron/core';\n```\n\nSibling-package helpers (`JsonRpcDispatcher`, `SDK_CAPABILITIES`, schema helpers, builder implementation classes) live under `@tesseron/core/internal`. They are deliberately excluded from the main entry point and are **not** part of the v1.0 semver contract — treat them as subject to change. Only the `@tesseron/web`, `@tesseron/server`, `@tesseron/react`, and `@tesseron/mcp` packages should import from that subpath.\n\n## `TesseronClient` (abstract)\n\n`@tesseron/web` and `@tesseron/server` each extend this with a transport. The base class's `connect(transport)` takes a concrete `Transport`. The web / server subclasses override it to accept `Transport | string | undefined` so users can pass a URL (or nothing) and get a default WebSocket transport. The subclassing contract:\n\n```ts\nclass MyTesseronClient extends TesseronClient {\n override async connect(target?: Transport | string): Promise<WelcomeResult> {\n if (target && typeof target !== 'string') return super.connect(target);\n const transport = new MyTransport(target ?? DEFAULT_GATEWAY_URL);\n await transport.ready();\n return super.connect(transport);\n }\n}\n```\n\n`super.connect(transport)` wires the dispatcher, sends `tesseron/hello`, handles `actions/invoke`, and returns the `welcome` result.\n\n## `Transport`\n\n```ts\ninterface Transport {\n send(message: unknown): void;\n onMessage(handler: (message: unknown) => void): void;\n onClose(handler: (reason?: string) => void): void;\n close(reason?: string): void;\n}\n```\n\nThe core client assumes the transport passes objects (not strings). If your transport is string-oriented, JSON.parse / stringify at the boundary. WebSocket-based transports in `@tesseron/web` and `@tesseron/server` already do this.\n\n## `JsonRpcDispatcher`\n\nLow-level bidirectional JSON-RPC router:\n\n```ts\ninterface JsonRpcDispatcher {\n on<M>(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void;\n onNotification<N>(method: string, handler: (params: unknown) => void): void;\n request<R>(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise<R>;\n notify(method: string, params?: unknown): void;\n receive(message: unknown): void;\n}\n```\n\nYou typically only use this directly when implementing extension methods. Day-to-day use of Tesseron goes through the builder, not the dispatcher.\n\n## `TesseronError`\n\n```ts\nclass TesseronError extends Error {\n readonly code: number;\n readonly data?: unknown;\n constructor(code: number, message: string, data?: unknown);\n}\n```\n\nThe dispatcher maps it to / from the `{ code, message, data }` JSON-RPC error object automatically. Throw it from handlers to produce a specific JSON-RPC error:\n\n```ts\n\n.handler(async ({ orderId }, ctx) => {\n const order = await orders.find(orderId);\n if (!order) throw new TesseronError(TesseronErrorCode.ActionNotFound, `no order ${orderId}`, { orderId });\n // …\n});\n```\n\nCatching `TesseronError` is also useful around `ctx.sample` / `ctx.elicit` to pivot on capability errors (note: `ctx.confirm` doesn't throw — it returns `false` when elicitation isn't available, which is the safe default for destructive gates):\n\n```ts\n\ntry {\n const r = await ctx.sample({ prompt });\n} catch (err) {\n if (err instanceof SamplingNotAvailableError) return fallback();\n // equivalent by code:\n if (err instanceof TesseronError && err.code === TesseronErrorCode.SamplingNotAvailable) {\n return fallback();\n }\n throw err;\n}\n```\n\n## Bringing your own transport\n\nA minimal example, for clarity - a loopback transport pair for tests:\n\n```ts\n\nfunction pair(): [Transport, Transport] {\n const aInbox: Array<(m: unknown) => void> = [];\n const bInbox: Array<(m: unknown) => void> = [];\n const a: Transport = {\n send: (m) => bInbox.forEach((h) => h(m)),\n onMessage: (h) => aInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n const b: Transport = {\n send: (m) => aInbox.forEach((h) => h(m)),\n onMessage: (h) => bInbox.push(h),\n onClose: () => {},\n close: () => {},\n };\n return [a, b];\n}\n```\n\nYou can attach a `TesseronClient` subclass to one side and a mock gateway to the other. Both `@tesseron/mcp` and the SDK test suites rely on patterns like this."},{"slug":"sdk/typescript/index","title":"Install & first action","description":"The minimum code to go from zero to a working Tesseron integration in TypeScript.","section":"sdk","related":["sdk/typescript/action-builder","sdk/typescript/web","sdk/typescript/standard-schema","overview/quickstart"],"bodyRaw":"\nimport { Tabs, TabItem, Steps } from '@astrojs/starlight/components';\n\n<Steps>\n\n1. **Install the package that matches your runtime.**\n\n <Tabs>\n <TabItem label=\"Browser (Vite, Next, etc.)\">\n ```bash\n pnpm add @tesseron/web zod\n ```\n </TabItem>\n <TabItem label=\"React\">\n ```bash\n pnpm add @tesseron/react zod\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 `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 <code>.\"* 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/).\n","bodyText":"## Typical entry-point layouts\n\n## Where to go next\n\n- The [action builder](/sdk/typescript/action-builder/) in full - chaining, output validation, annotations, timeouts.\n- [Standard Schema validators](/sdk/typescript/standard-schema/) - Zod vs Valibot vs ArkType, and JSON Schema interop.\n- The [context API](/sdk/typescript/context/) - progress, sampling, elicitation, logging.\n- Framework adapters: [@tesseron/react](/sdk/typescript/react/)."},{"slug":"sdk/typescript/mcp","title":"@tesseron/mcp (MCP gateway)","description":"The MCP gateway process - WebSocket server + MCP stdio bridge. Bundled into the Claude Code plugin; you rarely run it by hand.","section":"sdk","related":["protocol/handshake","protocol/security","protocol/transport"],"bodyRaw":"\n`@tesseron/mcp` is the MCP gateway. It:\n\n- Runs a WebSocket server on `127.0.0.1:7475` that your app connects to.\n- Runs an MCP stdio server that the agent connects to.\n- Translates between the two, maintains session state, handles claim codes, enforces origin allowlist, fans out progress / sampling / elicitation across the boundary.\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\nTESSERON_PORT=7475 pnpm dlx @tesseron/mcp\n```\n\nIt starts, listens on stdio for MCP, and accepts WebSockets on `:7475`. Kill it with Ctrl-C.\n\n## Environment\n\nConfiguration is environment-variable driven; there are no CLI flags.\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TESSERON_PORT` | `7475` | WebSocket listen port. |\n| `TESSERON_HOST` | `127.0.0.1` | Listen host. Leave localhost unless you understand the implications. |\n| `TESSERON_ORIGIN_ALLOWLIST` | *(empty)* | Comma-separated extra Origins accepted. |\n| `TESSERON_TOOL_SURFACE` | `both` | `dynamic` / `meta` / `both`. Controls which MCP tools the bridge advertises (per-app tools, meta-dispatcher tools, or both). |\n\nThe advertised protocol version is pinned to `PROTOCOL_VERSION` in `@tesseron/core` and is not configurable at runtime.\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- Three 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- 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 active WebSocket.\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 `Math.random()`. Stored on the session, claimed via `gateway.claimSession(code)`, cleaned on claim or session close.\n\n## Where the plugin bundles it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo bundles the gateway as `plugin/server/index.cjs`, built via:\n\n```bash\npnpm --filter @tesseron/mcp build:plugin\n```\n\nThis esbuild bundle is what ships to plugin installers. If you're hacking on the gateway, rebuild the plugin bundle before testing against Claude Code.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `packages/mcp/src/cli.ts` - entry point, arg parsing.\n- `packages/mcp/src/gateway.ts` - WebSocket server, session management.\n- `packages/mcp/src/session.ts` - a single session's state + claim code.\n- `packages/mcp/src/mcp-bridge.ts` - MCP stdio server + protocol translation.\n\nAdding a new method (e.g., a custom `tesseron__debug_dump` tool) means editing `mcp-bridge.ts` for the MCP side and routing through `gateway.ts` if it also crosses the WebSocket. Keep new methods under a `tesseron__` prefix to avoid colliding with app action tools.\n\n## Not for production agents\n\nThis is a local developer tool. Don't bind it to `0.0.0.0`, don't expose port 7475 to the internet, don't skip the origin allowlist. If you need remote-agent support, wait for the Phase-4 Streamable HTTP transport or build a reverse-tunnel with explicit authentication in front.\n","bodyText":"`@tesseron/mcp` is the MCP gateway. It:\n\n- Runs a WebSocket server on `127.0.0.1:7475` that your app connects to.\n- Runs an MCP stdio server that the agent connects to.\n- Translates between the two, maintains session state, handles claim codes, enforces origin allowlist, fans out progress / sampling / elicitation across the boundary.\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\nTESSERON_PORT=7475 pnpm dlx @tesseron/mcp\n```\n\nIt starts, listens on stdio for MCP, and accepts WebSockets on `:7475`. Kill it with Ctrl-C.\n\n## Environment\n\nConfiguration is environment-variable driven; there are no CLI flags.\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `TESSERON_PORT` | `7475` | WebSocket listen port. |\n| `TESSERON_HOST` | `127.0.0.1` | Listen host. Leave localhost unless you understand the implications. |\n| `TESSERON_ORIGIN_ALLOWLIST` | *(empty)* | Comma-separated extra Origins accepted. |\n| `TESSERON_TOOL_SURFACE` | `both` | `dynamic` / `meta` / `both`. Controls which MCP tools the bridge advertises (per-app tools, meta-dispatcher tools, or both). |\n\nThe advertised protocol version is pinned to `PROTOCOL_VERSION` in `@tesseron/core` and is not configurable at runtime.\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- Three 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- 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 active WebSocket.\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 `Math.random()`. Stored on the session, claimed via `gateway.claimSession(code)`, cleaned on claim or session close.\n\n## Where the plugin bundles it\n\nThe Claude Code plugin at `plugin/` in the Tesseron repo bundles the gateway as `plugin/server/index.cjs`, built via:\n\n```bash\npnpm --filter @tesseron/mcp build:plugin\n```\n\nThis esbuild bundle is what ships to plugin installers. If you're hacking on the gateway, rebuild the plugin bundle before testing against Claude Code.\n\n## Extending it\n\nThe gateway is a small codebase:\n\n- `packages/mcp/src/cli.ts` - entry point, arg parsing.\n- `packages/mcp/src/gateway.ts` - WebSocket server, session management.\n- `packages/mcp/src/session.ts` - a single session's state + claim code.\n- `packages/mcp/src/mcp-bridge.ts` - MCP stdio server + protocol translation.\n\nAdding a new method (e.g., a custom `tesseron__debug_dump` tool) means editing `mcp-bridge.ts` for the MCP side and routing through `gateway.ts` if it also crosses the WebSocket. Keep new methods under a `tesseron__` prefix to avoid colliding with app action tools.\n\n## Not for production agents\n\nThis is a local developer tool. Don't bind it to `0.0.0.0`, don't expose port 7475 to the internet, don't skip the origin allowlist. If you need remote-agent support, wait for the Phase-4 Streamable HTTP transport or 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}\n```\n\nOptions:\n\n```ts\ninterface UseTesseronConnectionOptions {\n url?: string; // defaults to ws://127.0.0.1:7475\n enabled?: boolean; // gate the connect, e.g. only when logged in\n}\n```\n\nOnly one component should call `useTesseronConnection` per client - it owns the WebSocket. Most apps put it at the root.\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}\n```\n\nOptions:\n\n```ts\ninterface UseTesseronConnectionOptions {\n url?: string; // defaults to ws://127.0.0.1:7475\n enabled?: boolean; // gate the connect, e.g. only when logged in\n}\n```\n\nOnly one component should call `useTesseronConnection` per client - it owns the WebSocket. Most apps put it at the root.\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. Same action surface as @tesseron/web, different transport.","section":"sdk","related":["sdk/typescript/core","protocol/transport","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. The builder API is identical to `@tesseron/web`; only the transport differs.\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 tesseron,\n ServerTesseronClient,\n NodeWebSocketTransport,\n DEFAULT_GATEWAY_URL, // 'ws://localhost:7475'\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## Express example\n\nThe [`express-todo` example](/examples/express-todo/) shows the canonical \"HTTP + Tesseron on one Node process\" pattern. Keep the shared state outside of both entry points; each channel calls the same functions:\n\n```ts\nconst todos = new Map<string, Todo>();\n\n// REST surface\napp.post('/todos', (req, res) => {\n const todo = createTodo(todos, req.body);\n res.json(todo);\n});\n\n// Tesseron surface - same underlying function\ntesseron.action('addTodo')\n .input(z.object({ text: z.string() }))\n .handler(({ text }) => createTodo(todos, { text }));\n```\n\n## Transport details\n\n`NodeWebSocketTransport` wraps the [`ws`](https://github.com/websockets/ws) npm package (v8). Differences from the browser transport:\n\n- Accepts every frame shape `ws` hands back - `string`, `Buffer`, `Buffer[]`, `ArrayBuffer` - and coerces to UTF-8 before parsing. The browser transport is string-only.\n- Tolerates the gateway sending fragmented messages; `ws` reassembles automatically.\n- No auto-reconnect; see the [reconnect pattern](/sdk/typescript/web/#reconnect-pattern) from the web page - it transfers.\n\n## Running under Docker / systemd\n\nTwo things to get right:\n\n1. **Stdout / stderr** go to the process manager's log, not the gateway's. The claim code surfaces in *your* logs. Plan your startup flow to copy it somewhere humans can see - or, if the service is meant to be headless and always-on, log the claim code only to a file you rotate.\n2. **Signal handling.** `process.on('SIGTERM', …)` to call `tesseron.disconnect()` before exit gives the gateway a clean close (code 1001) and stops the agent from seeing abrupt tool failures.\n\n## Capabilities\n\nServer handlers get the same `ActionContext` as browser handlers. There are two differences worth being aware of:\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. The builder API is identical to `@tesseron/web`; only the transport differs.\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 tesseron,\n ServerTesseronClient,\n NodeWebSocketTransport,\n DEFAULT_GATEWAY_URL, // 'ws://localhost:7475'\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## Express example\n\nThe [`express-todo` example](/examples/express-todo/) shows the canonical \"HTTP + Tesseron on one Node process\" pattern. Keep the shared state outside of both entry points; each channel calls the same functions:\n\n```ts\nconst todos = new Map<string, Todo>();\n\n// REST surface\napp.post('/todos', (req, res) => {\n const todo = createTodo(todos, req.body);\n res.json(todo);\n});\n\n// Tesseron surface - same underlying function\ntesseron.action('addTodo')\n .input(z.object({ text: z.string() }))\n .handler(({ text }) => createTodo(todos, { text }));\n```\n\n## Transport details\n\n`NodeWebSocketTransport` wraps the [`ws`](https://github.com/websockets/ws) npm package (v8). Differences from the browser transport:\n\n- Accepts every frame shape `ws` hands back - `string`, `Buffer`, `Buffer[]`, `ArrayBuffer` - and coerces to UTF-8 before parsing. The browser transport is string-only.\n- Tolerates the gateway sending fragmented messages; `ws` reassembles automatically.\n- No auto-reconnect; see the [reconnect pattern](/sdk/typescript/web/#reconnect-pattern) from the web page - it transfers.\n\n## Running under Docker / systemd\n\nTwo things to get right:\n\n1. **Stdout / stderr** go to the process manager's log, not the gateway's. The claim code surfaces in *your* logs. Plan your startup flow to copy it somewhere humans can see - or, if the service is meant to be headless and always-on, log the claim code only to a file you rotate.\n2. **Signal handling.** `process.on('SIGTERM', …)` to call `tesseron.disconnect()` before exit gives the gateway a clean close (code 1001) and stops the agent from seeing abrupt tool failures.\n\n## Capabilities\n\nServer handlers get the same `ActionContext` as browser handlers. There are two differences worth being aware of:\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). There are two paths:\n\n### 1. Your validator provides it\n\nModern Zod, TypeBox, and Effect Schema can produce JSON Schema natively. The SDK picks it up automatically. No extra work.\n\n### 2. Pass it manually\n\nSome validators don't emit JSON Schema, or the export isn't great for a given shape. 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\n### 3. Fallback\n\nIf neither path produces a schema, the SDK sends `{ type: 'object', additionalProperties: true }` - permissive, unhelpful to the agent, but the call still works.\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). There are two paths:\n\n### 1. Your validator provides it\n\nModern Zod, TypeBox, and Effect Schema can produce JSON Schema natively. The SDK picks it up automatically. No extra work.\n\n### 2. Pass it manually\n\nSome validators don't emit JSON Schema, or the export isn't great for a given shape. 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\n### 3. Fallback\n\nIf neither path produces a schema, the SDK sends `{ type: 'object', additionalProperties: true }` - permissive, unhelpful to the agent, but the call still works.\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/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.\n BrowserWebSocketTransport,\n // Default gateway URL.\n DEFAULT_GATEWAY_URL, // 'ws://localhost:7475'\n} from '@tesseron/web';\n\n// The full `@tesseron/core` surface is also re-exported.\n```\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` | Connects to `ws://localhost:7475`. |\n| `string` (URL) | Connects to that URL. |\n| `Transport` | Uses the supplied transport - mostly for tests. |\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.\n BrowserWebSocketTransport,\n // Default gateway URL.\n DEFAULT_GATEWAY_URL, // 'ws://localhost:7475'\n} from '@tesseron/web';\n\n// The full `@tesseron/core` surface is also re-exported.\n```\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` | Connects to `ws://localhost:7475`. |\n| `string` (URL) | Connects to that URL. |\n| `Transport` | Uses the supplied transport - mostly for tests. |\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."}]}
|