Skip to content

@tesseron/server

@tesseron/server is what you use in a Node process - an Express server, a NestJS app, a CLI tool, a background worker, an Electron main process. The builder API is identical to @tesseron/web; the transport is what's different.

Unlike the browser SDK, Node can host its own listener. @tesseron/server ships two transport bindings and picks one based on connect() options:

  • WebSocket on loopback (default). Binds 127.0.0.1 on an OS-picked port.
  • Unix domain socket, opt-in via tesseron.connect({ transport: 'uds' }). Linux + macOS only; falls back to WS on Windows.

Either way the connection flow is the same:

  1. On tesseron.connect() the SDK creates the host endpoint.
  2. Writes ~/.tesseron/instances/<instanceId>.json with a { kind, url | path } spec.
  3. Waits for the gateway to dial in.
  4. On the first and only accepted connection, sends tesseron/hello and runs the normal Tesseron handshake.

No environment variables, no fixed ports, no client URL. The instance manifest does everything.

Use server whenUse web when
The handler's work lives on the backend (DB writes, queue jobs, filesystem).The handler's work needs DOM or browser APIs.
You don't need the user's tab to be open.The agent should only work while the user is viewing the page.
You want a headless service that Claude can drive.You want Claude to drive the UI the user is already looking at.

Both can run at the same time against the same MCP gateway - multi-app coexistence is first-class.

import {
// Singleton client - pre-constructed, use directly.
tesseron,
// Class (if you need multiple clients per process).
ServerTesseronClient,
// WS-binding transport — WS server + manifest writer.
NodeWebSocketServerTransport,
type NodeWebSocketServerTransportOptions,
// UDS-binding transport — net server + manifest writer.
UnixSocketServerTransport,
type UnixSocketServerTransportOptions,
} from '@tesseron/server';
import { tesseron } from '@tesseron/server';
import { z } from 'zod';
tesseron.app({
id: 'notes_api',
name: 'Notes API',
description: 'CRUD over the notes store',
});
tesseron
.action('createNote')
.input(z.object({ title: z.string(), body: z.string() }))
.handler(async ({ title, body }) => {
return db.notes.insert({ title, body });
});
tesseron.resource('noteCount').read(() => db.notes.count());
async function main() {
const welcome = await tesseron.connect();
console.log(`Tesseron ready. Claim code: ${welcome.claimCode}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
async function shutdown() {
await tesseron.disconnect();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
await tesseron.connect({ appName: 'notes_api', host: '127.0.0.1', port: 0 });
  • appName - stamped into the instance manifest so the gateway log names your app usefully. Defaults to 'node'.
  • host - always 127.0.0.1 in practice; exposed for tests that need ::1.
  • port - 0 (OS picks) is almost always what you want. Setting a fixed port only matters if you're reverse-tunnelling the transport.
await tesseron.connect({ transport: 'uds', appName: 'notes_api' });
// or, override the socket path:
await tesseron.connect({ transport: 'uds', path: '/tmp/notes.sock' });
  • appName - same as WS.
  • path - omit to let the SDK create a per-process 0700 temp dir under os.tmpdir() and bind <dir>/sock inside (recommended; the parent dir is the access gate). Pin a path only if you need to coordinate with another process that expects it.

Pass a Transport instead to bypass bind-and-announce entirely - useful in tests or when you're piping frames through some other channel.

The express-prompts example shows the canonical "HTTP + Tesseron on one Node process" pattern. Keep the shared state outside both entry points; each channel calls the same functions:

const prompts = new Map<string, Prompt>();
// REST surface
app.post('/prompts', (req, res) => {
const p = createPrompt(prompts, req.body);
res.status(201).json(p);
});
// Tesseron surface - same underlying function
tesseron.action('addPrompt')
.input(z.object({ name: z.string(), template: z.string() }))
.handler((input) => createPrompt(prompts, input));

Wraps the ws npm package (v8). It:

  • Binds a WebSocket server via Node's built-in http.createServer.
  • Accepts exactly one upgrade request that advertises the tesseron-gateway subprotocol; every other upgrade attempt is destroyed.
  • Tolerates every frame shape ws hands back - string, Buffer, Buffer[], ArrayBuffer - and coerces to UTF-8 before parsing.
  • Writes its instance manifest on listen() and deletes it on close().

See the WebSocket binding spec for the wire-level rules.

Wraps Node's net module. It:

  • Creates a private (mode 0700) directory under os.tmpdir() and binds a socket inside it (or uses the path you supplied).
  • chmod 0600s the socket file after bind, so the inode rejects connect attempts from other UIDs.
  • Accepts exactly one connection; rejects subsequent connect attempts.
  • Frames messages as NDJSON: JSON.stringify(msg) + '\n' per outbound, \n-split on inbound.
  • Writes its instance manifest on bind and deletes it (plus the temp dir) on close().

See the UDS binding spec for the wire-level rules and the Windows limitation.

Two things to get right:

  1. Same HOME dir as the gateway. The gateway reads ~/.tesseron/instances/; your Node process has to write there. In containers, mount ~/.tesseron into the container's $HOME.
  2. Signal handling. process.on('SIGTERM', …) to call tesseron.disconnect() before exit cleans up the manifest and gives the gateway a clean close (code 1001 on WS, normal 'close' on UDS) so the agent doesn't see abrupt tool failures.

Claim codes surface on stdout/stderr of your Node process, not the gateway's. Plan how you expose them to humans - a web UI endpoint, a file you rotate, whatever fits.

Server handlers get the same ActionContext as browser handlers. Two differences to know:

  • ctx.client.origin - fabricated. Typically the string "node:<app.id>" or similar. Don't use it for auth.
  • ctx.client.route - always undefined. There's no "current route" on the server.

Everything else - progress, sample, elicit, log, signal - behaves the same.