@llui/agent
The agent package lets an LLM drive a running LLui app — read state, enumerate available actions, dispatch messages, and observe the result. It is not a debugging surface (see @llui/mcp for that). It is a production-intended control channel authored into your app.
pnpm add @llui/agent
The two packages
| Package | Runs in | Purpose |
|---|---|---|
@llui/agent |
Your app server + browser | LAP server (HTTP + WebSocket) and client runtime; defines the surface |
@llui/agent-bridge |
Claude Desktop (stdio MCP) | Translates MCP tool calls into LAP requests; the CLI is llui-agent |
The user connects Claude to a running instance of your app by pasting a one-line command (/llui-connect <url> <token>) after the app mints a token. From there Claude calls MCP tools (observe, send_message, …), the bridge forwards them to the LAP server, and the LAP server RPCs the paired browser tab.
Quick start
1. Enable the dev middleware
The easiest way to try the agent surface is via the Vite plugin option:
// vite.config.ts
import { defineConfig } from 'vite'
import llui from '@llui/vite-plugin'
export default defineConfig({
plugins: [llui({ agent: true })],
})
With agent: true, the plugin dynamically loads @llui/agent/server and mounts /agent/* (HTTP) and /agent/ws (WebSocket upgrade) on your dev server. No further backend wiring is required for local development.
2. Wire the client runtime
After mountApp, construct the agent client and start it. The client owns three state slices — connect, confirm, log — that you fold into your app's reducer.
// main.ts
import { mountApp } from '@llui/dom'
import { createAgentClient, agentConnect, agentConfirm, agentLog } from '@llui/agent/client'
import { appDef } from './app'
import type { State, Msg } from './types'
const container = document.getElementById('app')!
const handle = mountApp(container, appDef)
const agentClient = createAgentClient<State, Msg>({
handle,
def: appDef,
rootElement: container,
slices: {
getConnect: (s) => s.agent.connect,
getConfirm: (s) => s.agent.confirm,
wrapConnectMsg: (m) => ({ type: 'agent', sub: 'connect', msg: m }),
wrapConfirmMsg: (m) => ({ type: 'agent', sub: 'confirm', msg: m }),
wrapLogMsg: (m) => ({ type: 'agent', sub: 'log', msg: m }),
},
})
agentClient.start()
In your app's update, route the agent.connect, agent.confirm, and agent.log cases to each sub-module's update. Initial state:
init: () => [
{
// …your state
agent: {
connect: agentConnect.init({ mintUrl: '/agent/mint' })[0],
confirm: agentConfirm.init()[0],
log: agentLog.init()[0],
},
},
[],
]
3. Install the MCP bridge
Install the CLI globally (or add it to your dev-deps and call via npx):
npm install -g llui-agent
Add it to Claude Desktop's MCP config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):
{
"mcpServers": {
"llui": { "command": "llui-agent" }
}
}
Restart Claude Desktop. The connect_session, observe, send_message, and related tools will appear in the tool picker.
4. Connect
Open your app (vite dev), use the in-app UI to mint a token (the agentConnect slice provides the state machine — render a button that dispatches { type: 'agent', sub: 'connect', msg: { type: 'RequestMint' } }), copy the resulting /llui-connect <url> <token> command, and paste it into your Claude conversation. Claude calls connect_session, then observe, and you're live.
Annotating messages
Claude decides what to dispatch by reading your Msg discriminated union. JSDoc tags on each variant classify its agent affordability:
type Msg =
/** @intent("increment the counter") */
| { type: 'inc' }
/** @intent("reset to zero") @requiresConfirm */
| { type: 'reset' }
/** @humanOnly */
| { type: 'internalWheelDelta'; dy: number }
/** @alwaysAffordable @intent("navigate to route") */
| { type: 'navigate'; to: string }
| Tag | Effect |
|---|---|
@intent("...") |
Human-readable label shown to the LLM. Without it, the variant name is used directly. |
@requiresConfirm |
The LLM's send_message returns pending-confirmation; a user must approve in the app UI before dispatch. |
@humanOnly |
Hard-block — the LLM cannot dispatch. Use for pointer-event plumbing, internal UI wiring. |
@alwaysAffordable |
The variant is listed in actions even when no UI binding currently references it (e.g. hidden commands). |
The Vite compiler extracts these tags into __msgAnnotations on the component. No runtime cost if the tags aren't present.
Component-level metadata
Three optional functions on the ComponentDef give Claude context beyond the types:
export const App = component<State, Msg, Effect>({
name: 'App',
init,
update,
view,
})
// Purpose + static cautions — shown in `observe`'s description slice.
App.agentDocs = {
purpose: 'Browse GitHub repositories — search, inspect code, READMEs, and issues.',
overview:
'Start on the search page. Type a query, submit, then open results. ' +
'Tabs switch between code and issues; the file tree opens directories.',
cautions: ["GitHub's unauthenticated API is rate-limited."],
}
// Dynamic per-state narrative — shown in `observe`'s context slice.
App.agentContext = (state) => {
switch (state.route.page) {
case 'search':
return {
summary: `On the search page. Query: "${state.query}".`,
hints: ['Dispatch setQuery then submitSearch, or a single navigate Msg.'],
}
case 'repo':
return { summary: `Viewing ${state.route.owner}/${state.route.name}.` }
}
}
// Extra affordances not reachable via visible bindings — e.g. "back" or hotkeys.
App.agentAffordances = (state) => [{ type: 'navigate', to: '/search' }]
DOM tagging
Mark elements you want Claude to be able to read or address with data-agent:
input({
type: 'search',
'data-agent': 'search-input',
onInput: (e) => send({ type: 'setQuery', q: e.currentTarget.value }),
})
ul({ class: 'repo-list', 'data-agent': 'search-results' }, [
// …
])
The query_dom tool reads by data-agent name; describe_visible_content walks the visible subtree and emits a structured outline.
Runtime support
The agent server needs a runtime that can hold a long-lived WebSocket. { agent: true } in the vite-plugin is dev-only; production deployment depends on where your backend runs.
| Runtime | Supported | Entry point | Notes |
|---|---|---|---|
| Node.js (server process) | yes | @llui/agent/server |
Uses the ws library. Default path. |
| Bun (server process) | yes | @llui/agent/server/core + @llui/agent/server/web |
Wire server.upgrade() + createWHATWGPairingConnection. |
| Deno / Deno Deploy | yes | @llui/agent/server/core + @llui/agent/server/web |
Uses handleDenoUpgrade(). |
| Cloudflare Workers + Durable Objects | yes | @llui/agent/server/cloudflare |
Pairing state lives in a DO. See below. |
| Cloudflare Workers (bare, no DO) | no | — | Worker isolates are stateless; can't own a long-lived WebSocket. |
| Vercel Edge, plain Lambda | no | — | No native WebSocket + stateless. Not viable. |
All supported paths share the same LAP wire protocol and MCP bridge — the runtime differences are just in how each one accepts a WebSocket upgrade.
Node deployment
@llui/agent/server exports createLluiAgentServer, which returns an HTTP router and a WebSocket upgrade handler you attach to your Node server:
// server.ts
import { createServer } from 'node:http'
import { createLluiAgentServer } from '@llui/agent/server'
const agent = createLluiAgentServer({
signingKey: process.env.AGENT_SIGNING_KEY!, // ≥ 32 bytes
// Optional — defaults are in-memory, single-process:
// tokenStore: myRedisTokenStore,
// identityResolver: myAuthResolver,
// auditSink: myAuditSink,
// rateLimiter: defaultRateLimiter({ perBucket: '30/minute' }),
// corsOrigins: ['https://app.example.com'],
})
const server = createServer(async (req, res) => {
// Convert Node req → Fetch Request, hand to agent.router first
// (fall through to your own router on null)
// …
})
server.on('upgrade', (req, socket, head) => {
if (req.url?.startsWith('/agent/ws')) agent.wsUpgrade(req, socket, head)
else socket.destroy()
})
server.listen(3000)
The defaults (InMemoryTokenStore, consoleAuditSink, 30-req/min limiter) are fine for a single-process dev server. For production, swap in a persistent TokenStore, an IdentityResolver tied to your auth system, and a durable AuditSink.
Deno deployment
Import the runtime-neutral core + the web upgrade helper:
import { createLluiAgentCore } from '@llui/agent/server/core'
import { handleDenoUpgrade } from '@llui/agent/server/web'
const agent = createLluiAgentCore({ signingKey: Deno.env.get('AGENT_SIGNING_KEY')! })
Deno.serve(async (req) => {
const url = new URL(req.url)
if (url.pathname === '/agent/ws') return handleDenoUpgrade(req, agent)
return (await agent.router(req)) ?? new Response('Not Found', { status: 404 })
})
Bun deployment
Bun's server.upgrade() hands the socket to your websocket.open() handler. Wire it to createWHATWGPairingConnection and call agent.acceptConnection:
import { createLluiAgentCore } from '@llui/agent/server/core'
import { createWHATWGPairingConnection } from '@llui/agent/server/web'
const agent = createLluiAgentCore({ signingKey: Bun.env.AGENT_SIGNING_KEY! })
Bun.serve({
fetch(req, server) {
const url = new URL(req.url)
if (url.pathname === '/agent/ws') {
const token = url.searchParams.get('token')
if (!token) return new Response('Unauthorized', { status: 401 })
if (server.upgrade(req, { data: { token } })) return undefined
return new Response('Upgrade failed', { status: 500 })
}
return agent.router(req).then((r) => r ?? new Response('Not Found', { status: 404 }))
},
websocket: {
async open(ws) {
const conn = createWHATWGPairingConnection(ws as unknown as WebSocket)
const { token } = ws.data as { token: string }
const result = await agent.acceptConnection(token, conn)
if (!result.ok) ws.close()
},
},
})
Cloudflare deployment
Cloudflare Workers are stateless isolates — a bare Worker cannot own a long-lived WebSocket. The agent's pairing state lives in a Durable Object (one DO per session tid). The DO IS the registry; the Worker just routes requests to the right DO.
// worker.ts
import { AgentPairingDurableObject, routeToAgentDO } from '@llui/agent/server/cloudflare'
export interface Env {
AGENT_SIGNING_KEY: string
AGENT_DO: DurableObjectNamespace
}
export class AgentDO {
private agent: AgentPairingDurableObject
constructor(_state: DurableObjectState, env: Env) {
this.agent = new AgentPairingDurableObject({
signingKey: env.AGENT_SIGNING_KEY,
})
}
fetch(req: Request): Promise<Response> {
return this.agent.fetch(req)
}
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
return routeToAgentDO(req, env.AGENT_DO, env.AGENT_SIGNING_KEY)
},
}
wrangler.toml:
[[durable_objects.bindings]]
name = "AGENT_DO"
class_name = "AgentDO"
[[migrations]]
tag = "v1"
new_classes = ["AgentDO"]
Set the signing key via wrangler secret put AGENT_SIGNING_KEY in production.
How it routes:
POST /agent/mint,/agent/resume/*,/agent/sessions,/agent/revoke→ all go to a shared root DO (__root) that owns the token store.POST /agent/lap/v1/*→ routed by thetidin theAuthorization: Bearertoken to the per-session DO.GET /agent/ws(WebSocket upgrade) → routed by thetidin?token=to the per-session DO.
Each session DO holds its own InMemoryPairingRegistry + open WebSocket. Cloudflare's DO instance affinity by name guarantees every request for a given tid hits the same isolate, so pairing state stays consistent without external sync.
Crypto
All HMAC operations use the WebCrypto standard (crypto.subtle), available in Node ≥ 15, Cloudflare Workers, Deno, and Bun. The agent package does not depend on node:crypto; that was removed in 0.0.31.
Efficient tool usage
The bridge exposes a two-tier tool surface:
Recommended path: observe + send_message. One observe call returns state, actions, description, and context together — replacing describe_app + get_state + list_actions. send_message defaults to waitFor: 'drained', which blocks until the message queue goes idle (http/delay/debounce round-trips feed back as messages), then returns the fresh state and actions in the response. Two round-trips per interaction instead of five.
// observe →
{
"state": { "count": 0, "loading": false, "results": [] },
"actions": [{ "variant": "search", "intent": "run search", "requiresConfirm": false, "source": "binding" }],
"description": { "name": "Explorer", "messages": { /* schemas */ }, "docs": { /* agentDocs */ } },
"context": { "summary": "On the search page", "hints": [ /* */ ] }
}
// send_message { msg: { type: "search", q: "llui" } } →
{
"status": "dispatched",
"stateAfter": { "count": 0, "loading": false, "results": [ /* 10 items */ ] },
"actions": [ /* now includes nextPage */ ],
"drain": { "effectsObserved": 3, "durationMs": 184, "timedOut": false, "errors": [] }
}
send_message controls:
waitFor: 'drained' | 'idle' | 'none'—'drained'(default) waits for quiescence;'idle'flushes the synchronous update cycle only (no async effects);'none'is fire-and-forget.drainQuietMs— quiet-window size. Drain completes when no commit fires for this many ms. Default 100.timeoutMs— hard cap. Default 5000. If reached,drain.timedOut: truereturns a partial snapshot; callobserveagain once activity settles.
Legacy path: describe_app, get_state, list_actions, wait_for_change. Kept for back-compat and specialized cases (e.g. JSON-pointer state slices, long-polling for externally-pushed state changes like WebSocket events arriving while the LLM is idle).
See @llui/agent-bridge for the full MCP tool list and CLI reference.
Security notes
signingKeymust be ≥ 32 bytes. Rotating it invalidates all outstanding tokens.- Tokens are stored with a 1-hour sliding TTL by default; re-configure via
slidingTtlMs. - Rate limiting applies per-token. The default
30/minutelimiter is a coarse ceiling — tune it for your workload. @humanOnlyis a hard block at the browser RPC layer, not just a convention.@requiresConfirmflows a confirmation message through state; approval requires the user to interact with the app UI, not just the LLM.- The
corsOriginsoption defaults to "any" — set it explicitly in production.
Functions
createLluiAgentCore()
Compose the runtime-neutral agent server. The returned handle has
everything the LAP HTTP routes and the WebSocket acceptance
plumbing need; runtime adapters wire the native upgrade API on
top (see @llui/agent/server for Node, @llui/agent/server/web
for WHATWG runtimes).
function createLluiAgentCore(opts: CoreOptions = {}): AgentCoreHandle
createLluiAgentServer()
Node adapter. Wraps the runtime-neutral core with a Node-specific
wsUpgrade handler that uses the ws library. Imports ws
eagerly, so this module only works where ws is available — use
@llui/agent/server/web for Cloudflare Workers, Deno, or other
WHATWG runtimes.
Spec §10.1, §10.4.
function createLluiAgentServer(opts: ServerOptions = {}): AgentServerHandle
mintToken()
Mint an opaque random bearer token + the SHA-256 hash the server
stores as a lookup key. Tokens are 32 bytes of CSPRNG entropy (256
bits) base64url-encoded with the agt_ prefix — total ~48 chars.
The prefix is intentionally generic so LLM clients don't mistake the
token format for a hint about which MCP tool namespace to use.
The token itself never persists; only the hash does. A leaked store
therefore does not compromise live tokens, since the bearer secret
isn't recoverable from the hash. This matches the standard "session
cookie / API key" pattern.
The opaque form is the only token format the server understands as
of 0.0.35. The previous HMAC-signed JWT format is gone; clients
carrying old tokens will fail with unknown on first call and need
to remint. See CHANGELOG.
function mintToken(): Promise<{ token: AgentToken; tokenHash: string }>
tokenHashOf()
Compute the SHA-256 hash of a presented bearer token. Returns null
when the prefix is missing — the verify path uses that to fail-fast
on garbage-shaped Authorization headers without a crypto round-trip.
Hash is hex-encoded for portability across stores (Postgres text,
KV string, etc.).
function tokenHashOf(token: string): Promise<string | null>
defaultIdentityResolver()
function defaultIdentityResolver(cfg: IdentityCookieConfig): IdentityResolver
signCookieValue()
Async because crypto.subtle.sign is the cross-runtime standard.
Callers building a Set-Cookie header must await this.
function signCookieValue(value: string, signingKey: string | Uint8Array): Promise<string>
defaultRateLimiter()
function defaultRateLimiter(cfg: RateLimitConfig, now: () => number = () => Date.now()): RateLimiter
rpc()
Send an rpc frame to the paired browser and await its
matching rpc-reply / rpc-error. Runs its own one-shot frame
subscription against the registry — no state stored on the
registry itself, which keeps the registry small enough to
implement in a Durable Object or other stateful primitive.
Rejects with {code: 'paused'} when the pairing is absent,
{code: 'timeout'} when the browser doesn't reply in time,
or whatever the browser sent in its rpc-error frame otherwise.
function rpc(registry: PairingRegistry, tid: string, tool: string, args: unknown, opts: RpcOptions = {}): Promise<unknown>
waitForConfirm()
Await a confirm-resolved frame for the given confirmId.
Resolves with {outcome: 'user-cancelled'} on timeout or pairing
drop (approvals lapse when the user isn't present to act on them).
function waitForConfirm(registry: PairingRegistry, tid: string, confirmId: string, timeoutMs: number): Promise<{ outcome: 'confirmed' | 'user-cancelled'; stateAfter?: unknown }>
waitForChange()
Await a state-update frame whose path matches (exact or prefix).
Used by the long-poll /lap/v1/wait endpoint for external state
pushes (WebSocket messages, timers) arriving while the LLM is idle.
function waitForChange(registry: PairingRegistry, tid: string, path: string | undefined, timeoutMs: number): Promise<{ status: 'changed' | 'timeout'; stateAfter: unknown }>
createWHATWGPairingConnection()
Wrap a WHATWG WebSocket in a PairingConnection. This is the
common denominator across Cloudflare Workers (WebSocketPair
server half), Deno (Deno.upgradeWebSocket().socket), Bun's
upgraded socket, and any other runtime that exposes a
standards-compliant WebSocket object.
The input type is intentionally the browser/global WebSocket
interface — not the Node ws library's variant, which uses an
EventEmitter API (on('message', ...)) rather than
addEventListener('message', ...). Use ./node/upgrade.ts for
the ws library path.
function createWHATWGPairingConnection(socket: WebSocket): PairingConnection
extractToken()
Extract the bearer token from a LAP WebSocket upgrade request.
Accepts the token on either ?token= or Authorization: Bearer —
query-string is the common pattern because browsers can't set
arbitrary headers on WebSocket construction.
function extractToken(req: Request): string | null
handleCloudflareUpgrade()
Cloudflare Workers handler. Accepts a WebSocket upgrade using
WebSocketPair, validates the token via
agent.acceptConnection, and returns the 101 upgrade Response.
Usage:
const agent = createLluiAgentCore()
export default {
async fetch(req, env) {
const url = new URL(req.url)
if (url.pathname === '/agent/ws') return handleCloudflareUpgrade(req, agent)
return (await agent.router(req)) ?? new Response('Not Found', { status: 404 })
},
}
function handleCloudflareUpgrade(req: Request, agent: AgentCoreHandle): Promise<Response>
handleDenoUpgrade()
Deno handler. Uses Deno.upgradeWebSocket(req) to produce the
response + socket pair, then plugs the socket into the registry.
Usage:
Deno.serve(async (req) => {
const url = new URL(req.url)
if (url.pathname === '/agent/ws') return handleDenoUpgrade(req, agent)
return (await agent.router(req)) ?? new Response('Not Found', { status: 404 })
})
function handleDenoUpgrade(req: Request, agent: AgentCoreHandle): Promise<Response>
routeToAgentDO()
Route an incoming Worker fetch request to the Durable Object
that owns its tid.
The token travels in three places depending on the route:
- LAP HTTP calls:
Authorization: Bearer <token>header - Mint / resume HTTP calls: no token (identity resolver runs
inside the DO via the LAP router; we route by origin or a
special
/agent/mintpath — see below) - WebSocket upgrade:
?token=<token>in the URL Requests that don't carry a tid (mint, resume-list, sessions) are routed to a "root" DO named__root, which handles identity / token store operations centrally. LAP and WS calls route to the per-tid DO so the pairing state stays local. This is the recommended entry for Cloudflare Workers deployments; users who need custom routing can write their own and call the underlying primitives directly. As of 0.0.35 the token format is opaque (random, not signed), so we can't recovertidfrom the token alone. The caller passes aresolveTidcallback — typically(token) => stub.fetch(...)to the root DO's token-resolution endpoint — that turns a bearer into its tid via the shared token store. Callers that don't shard by tid can pass() => Promise.resolve(rootName)to route everything through the root DO.
function routeToAgentDO(req: Request, namespace: MinimalDurableObjectNamespace, resolveTid: (token: string) => Promise<string | null>, opts: { rootName?: string; mcpPath?: string } = {}): Promise<Response>
defaultSessionStorage()
The default AgentSessionStorage — wraps window.sessionStorage
under a single key and treats parse / type-mismatch failures as
"no session". Returns null from the factory when window is
undefined (SSR/tests), so calling code never has to feature-detect
the browser environment itself.
function defaultSessionStorage(storageKey: string = 'llui-agent:session'): AgentSessionStorage | null
createAgentClient()
function createAgentClient<State, Msg>(opts: CreateAgentClientOpts<State, Msg>): AgentClient
init()
Component shape is [State, Effect[]] — consistent with @llui/components.
function init(_opts: AgentConnectInitOpts): [AgentConnectState, AgentEffect[]]
update()
function update(state: AgentConnectState, msg: AgentConnectMsg, opts: AgentConnectInitOpts = {}): [AgentConnectState, AgentEffect[]]
connect()
Builds prop bags for the view. Static-bag-with-reactive-accessors shape (matches the @llui/components convention); spread directly into element helpers.
function connect<S>(get: (s: S) => AgentConnectState, send: Send<AgentConnectMsg>, _opts: AgentConnectConnectOptions = {}): ConnectBag<S>
Types
CoreOptions
Options accepted by createLluiAgentCore. Strict subset of
ServerOptions — everything needed to build the router, registry,
and accept-connection primitive. The Node factory adds WebSocket
upgrade wiring on top.
export type CoreOptions = {
tokenStore?: TokenStore
identityResolver?: IdentityResolver
auditSink?: AuditSink
rateLimiter?: RateLimiter
lapBasePath?: string
/**
* Override the default `InMemoryPairingRegistry`. Web runtimes that
* need a different pairing implementation (e.g. a Cloudflare
* Durable Object that persists across isolates) pass it here.
*/
registry?: PairingRegistry
/**
* How long, in milliseconds, a token's record stays in
* `pending-resume` after the WS pairing closes. During this window
* the same browser can reconnect with the same bearer token and
* the WS re-pairs without going through the rotate-on-resume path
* (`/resume/claim`). The agent's existing token stays valid the
* whole time, so brief network drops, page reloads, and quick
* server restarts don't invalidate the agent's session.
*
* After the window, LAP calls report `X-LLui-Reconnect: expired`
* and the record becomes resume-claimable (rotation required).
* Set to `0` to opt out — the WS close immediately drops the
* record and any reconnect must go through `/resume/claim`.
*
* Default: 60 seconds — long enough for laptop sleep, brief Wi-Fi
* flicker, and a server restart; short enough that a deliberately-
* closed tab doesn't keep the record alive forever.
*/
pendingResumeGraceMs?: number
}
AcceptResult
export type AcceptResult =
| { ok: true; tid: string }
| { ok: false; status: number; code: 'auth-failed' | 'revoked' }
AgentCoreHandle
Handle returned by createLluiAgentCore. Purely runtime-neutral —
router is a Fetch-style handler, acceptConnection is the
primitive that runtime-specific WebSocket adapters call after
accepting a socket in their native way.
export type AgentCoreHandle = {
router: (req: Request) => Promise<Response | null>
registry: PairingRegistry
tokenStore: TokenStore
auditSink: AuditSink
/**
* Validate an agent token and register a `PairingConnection` with
* the registry. Use this after accepting a WebSocket upgrade via
* your runtime's native API (e.g. `WebSocketPair` on Cloudflare,
* `Deno.upgradeWebSocket` on Deno, `server.upgrade` on Bun).
*
* On success: marks the token `awaiting-claude`, writes an audit
* entry, and returns `{ok: true, tid}`. On failure: returns an
* appropriate HTTP status for the caller to encode into the
* upgrade response (401 for auth failure, 403 for revoked).
*/
acceptConnection: (token: string, conn: PairingConnection) => Promise<AcceptResult>
}
ServerOptions
Options accepted by createLluiAgentServer. All values are
optional and fall back to in-memory defaults. See spec §10.1.
Pre-0.0.35 this required a signingKey for HMAC-signed JWT tokens.
The new opaque-token scheme (token.ts) doesn't sign anything — the
server stores the SHA-256 hash and looks tokens up. The option is
gone; existing config that passed signingKey should drop it.
export type ServerOptions = {
/** Token store. Defaults to an `InMemoryTokenStore`. */
tokenStore?: TokenStore
/** Identity resolver. Defaults to anonymous (always null). */
identityResolver?: IdentityResolver
/** Audit sink. Defaults to `consoleAuditSink`. */
auditSink?: AuditSink
/** Rate limiter. Defaults to `defaultRateLimiter` with 30/minute. */
rateLimiter?: RateLimiter
/** Base path prefix for LAP endpoints. Defaults to `/agent/lap/v1`. */
lapBasePath?: string
/** Pairing grace window after a tab closes, in ms. Default 15 min. */
pairingGraceMs?: number
/** Sliding TTL for active tokens, in ms. Default 1 h. */
slidingTtlMs?: number
/** Allowed origins for the HTTP surface (CORS). Empty = any. */
corsOrigins?: readonly string[]
/**
* Enable the server-side MCP endpoint at `/agent/mcp` (or a custom
* path). When set, Claude Desktop can connect directly to the app
* backend without installing the `llui-agent` bridge — the user pastes
* the token via `connect_session` in chat, same flow as the bridge but
* no separate process required.
*
* Pass `true` to use all defaults, or an `McpRouterOptions` object to
* customise the path, server name, and connect_session description.
*/
mcp?: boolean | McpRouterOptions
}
AgentServerHandle
Value returned by createLluiAgentServer. router matches any
/agent/* request and returns a Response (or null to fall through).
wsUpgrade handles Node HTTP upgrade events for /agent/ws.
export type AgentServerHandle = {
router: (req: Request) => Promise<Response | null>
/**
* Handles Node HTTP upgrade events for `/agent/ws`. Returns a Promise
* because token verification uses WebCrypto (async). Node's
* `server.on('upgrade', handler)` fires the handler without awaiting,
* which is fine — the handler writes errors directly to the socket
* and never throws back to the caller.
*/
wsUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void>
/** The pairing registry. Runtime-neutral adapters may access it. */
registry: PairingRegistry
/** The active token store. */
tokenStore: TokenStore
/** The active audit sink. */
auditSink: AuditSink
/**
* Runtime-neutral WebSocket acceptance primitive. Validates a token
* and registers a `PairingConnection` with the registry. The Node
* `wsUpgrade` above calls this internally; web-runtime adapters
* (`@llui/agent/server/web`) use it after accepting a WebSocket via
* their native API.
*/
acceptConnection: (token: string, conn: PairingConnection) => Promise<AcceptResult>
}
VerifyResult
Result of looking up a presented token. The expired reason is
returned by the verify path when the token's record exists but its
hard-expiry has passed; unknown covers both "no record" and
"wrong hash" so a probe-by-hash leak surface is uniform.
export type VerifyResult =
| { kind: 'ok'; tid: string }
| { kind: 'invalid'; reason: 'malformed' | 'unknown' | 'expired' }
IdentityResolver
export type IdentityResolver = (req: Request) => Promise<string | null>
IdentityCookieConfig
export type IdentityCookieConfig = {
name: string
signingKey: string | Uint8Array
}
AuditSink
export type AuditSink = {
write: (entry: AuditEntry) => void | Promise<void>
}
RateLimitResult
export type RateLimitResult = { allowed: true } | { allowed: false; retryAfterMs: number }
RateLimitConfig
export type RateLimitConfig = {
perBucket: string
}
FrameSubscriber
A per-call frame subscriber. Return true to remove this
subscriber (one-shot), or false to keep receiving. The registry
dispatches every inbound ClientFrame to every active subscriber
for the given tid; subscribers filter by frame.t + identifiers
(correlation id, confirm id, state path) to find the one that
belongs to their request.
export type FrameSubscriber = (frame: ClientFrame) => boolean
RpcError
export type RpcError = {
code: 'paused' | 'invalid' | 'timeout' | 'schema-error' | 'internal' | string
detail?: string
}
RpcOptions
export type RpcOptions = { timeoutMs?: number }
DurableObjectOptions
export type DurableObjectOptions = Omit<CoreOptions, 'registry'> & {
/**
* Enable the server-side MCP endpoint at `/agent/mcp` (or a custom
* path). Pass `true` for all defaults, or an `McpRouterOptions`
* object to customise path, server name, and connect_session
* description.
*/
mcp?: boolean | McpRouterOptions
}
MsgSchemaBareType
The shape the compiler emits as __msgSchema. Mirrors MsgField
from @llui/vite-plugin/src/msg-schema.ts. Three coexisting forms:
- Bare primitive:
'string' | 'number' | 'boolean' | 'unknown'and bare enum:{enum: [...]}(values may be string, number, or boolean — the compiler preserves the literal kind so JSON round-trips don't lose type info). - Bare nested types:
{kind: 'object', shape}for inline / followed-via-typeIndex shapes;{kind: 'array', element}forT[]/readonly T[]/Array<T>;{kind: 'discriminated- union', discriminant, variants}for tagged unions of objects (e.g.Format = {kind:'exact'} | {kind:'range', min, max}). The synthesizer recurses to build copy-paste-ready nested examples; the validator walks the same tree. - Rich descriptor: wraps any of the above with
{optional?, priority?, hint?}carrying TS optionality and@shouldhints.
export type MsgSchemaBareType =
| string
| { enum: ReadonlyArray<string | number | boolean> }
| { kind: 'object'; shape: Record<string, MsgSchemaField> }
| { kind: 'array'; element: MsgSchemaBareType }
| {
kind: 'discriminated-union'
discriminant: string
variants: Record<string, Record<string, MsgSchemaField>>
}
MsgSchemaField
export type MsgSchemaField =
| MsgSchemaBareType
| {
type: MsgSchemaBareType
optional?: boolean
priority?: 'should'
hint?: string
/**
* Boolean JS expression authored with `@validates("expr")` JSDoc.
* Has `v` bound to the field value at runtime; the validator
* compiles it lazily with `new Function('v', 'return (' + src +
* ')')` and caches the function across calls. Use for invariants
* the type system can't express — numeric ranges, format
* predicates, length bounds.
*/
validates?: string
}
MsgSchemaShape
export type MsgSchemaShape = {
discriminant: string
variants: Record<string, Record<string, MsgSchemaField>>
}
CreateAgentClientOpts
export type CreateAgentClientOpts<State, Msg> = {
handle: AppHandle
def: ComponentMetadata
appVersion?: string
rootElement: Element | null
slices: {
getConnect: (s: State) => unknown
getConfirm: (s: State) => AgentConfirmState
wrapConnectMsg: (m: unknown) => Msg
wrapConfirmMsg: (m: unknown) => Msg
/**
* Optional: wrap an agentLog msg so the client-side activity feed
* mirrors what Claude is doing. If omitted, outbound log-append
* frames still go to the server, but the local agent.log slice
* stays empty (the UI won't show activity).
*/
wrapLogMsg?: (m: unknown) => Msg
/**
* Optional: wrap an agentAttention msg so the visual-attention
* slice can clear its spotlight on the auto-clear timer. Hosts
* that wire `agentAttention` should set this; hosts that don't
* leave it unset and the spotlight (which they aren't rendering)
* never matters. The factory uses it for the reverse direction
* too: `onLogEntry` re-dispatches the same `Append { entry }`
* payload into the attention slice when wired, so a single
* incoming `log-append` frame fans out to both slices without
* the host needing to write the routing.
*/
wrapAttentionMsg?: (m: unknown) => Msg
}
/**
* Codec registry for non-JSON-safe values (Date, Blob, Map, …)
* crossing the LAP boundary. Defaults to `makeDefaultCodecs()`
* which ships `iso-date` and `epoch-millis`. Provide a custom
* registry to register additional codecs (e.g. `base64-blob` for
* file uploads). See `@llui/agent/codecs` for the convention.
*/
codecs?: CodecRegistry
/**
* Base path for agent HTTP endpoints. Default: `'/agent'` (matches
* the canonical paths in `@llui/vite-plugin`'s dev middleware and
* `@llui/agent/server`). The mint URL, resume URLs, and revoke URL
* derive from this so consumers don't have to keep them in sync.
*
* Override when:
* - **Cross-origin agent server**: pass the full base, e.g.
* `'https://api.example.com/agent'` or `'http://localhost:8787/agent'`.
* - **`@cloudflare/vite-plugin` in dev**: pass `'/cdn-cgi/agent'`
* because cloudflare-vite shadows non-`/cdn-cgi/*` routes.
*/
agentBasePath?: string
/**
* Storage adapter for the active session blob. When provided the
* framework owns the persist/restore loop end-to-end: writes on
* `MintSucceeded`, reads on `start()` (auto-dispatching
* `RestoreSession` when a non-expired blob is found), clears on
* `Disconnect` / `Revoke` / explicit clear effects.
*
* Default: `defaultSessionStorage()` — uses `window.sessionStorage`
* under the key `'llui-agent:session'`. Tab-scoped (survives
* refresh, dies on tab close), which matches how a single-tab
* agent connection should behave.
*
* Pass `null` to opt out entirely; the framework then emits the
* `AgentSessionPersist` / `AgentSessionClear` effects unchanged
* and the host owns storage. Useful for SSR builds where
* `sessionStorage` is undefined and the host wants to no-op the
* storage layer.
*
* Pass a custom adapter for tests, IndexedDB-backed apps, or
* environments where `sessionStorage` is unavailable but the
* persistence semantics are still wanted (e.g. Web Workers).
*/
sessionStorage?: AgentSessionStorage | null
}
AgentSessionStorage
Tab-lifetime persistence for the active agent session. Reads /
writes a single blob; the framework synchronizes it with the
connect lifecycle so refresh-survival is automatic. Implementations
must be synchronous on the read path so start() can decide
whether to dispatch RestoreSession before any UI mounts —
otherwise the idle-only guard in the reducer might miss the
restore when a Mint click races the async lookup.
export type AgentSessionStorage = {
read(): PersistedAgentSession | null
write(session: PersistedAgentSession): void
clear(): void
}
PersistedAgentSession
export type PersistedAgentSession = {
token: AgentToken
tid: string
lapUrl: string
wsUrl: string
expiresAt: number
}
AgentClient
export type AgentClient = {
effectHandler: (effect: AgentEffect) => Promise<void>
start(): void
stop(): void
}
AgentEffect
export type AgentEffect =
/**
* Mint a fresh agent token. `mintUrl` is optional — when omitted the
* effect handler derives it from `EffectHandlerHost.agentBasePath`
* (default `/agent`), producing `<agentBasePath>/mint`. Pass an
* explicit value when the mint endpoint lives outside the configured
* base path.
*/
| { type: 'AgentMintRequest'; mintUrl?: string }
| { type: 'AgentOpenWS'; token: AgentToken; wsUrl: string }
| { type: 'AgentCloseWS' }
| { type: 'AgentResumeCheck'; tids: string[] }
| { type: 'AgentResumeClaim'; tid: string }
| { type: 'AgentRevoke'; tid: string }
| { type: 'AgentSessionsList' }
| { type: 'AgentForwardMsg'; payload: unknown }
// Handler reads `text` (no state lookup needed at handler time —
// update() resolved it from the current state.pendingToken). Lets
// the static-bag `connect()` shape avoid leaking state-reads into
// event handlers.
| { type: 'AgentClipboardWrite'; text: string }
/**
* Persist active session credentials so a page refresh can restore
* the same WS without re-minting (and without invalidating the
* agent's token via the rotate-on-resume path). Hosts typically
* write to `sessionStorage` so the credentials are tab-scoped:
* survive refresh, die on tab close. The framework emits this on
* `MintSucceeded`; the matching `AgentSessionClear` is emitted on
* `Revoke` of the active tid. Hosts that don't implement the
* persist/restore loop can ignore both — the rest of the connect
* lifecycle still works (the page just falls back to "mint a new
* session" after refresh, same as before this effect existed).
*/
| {
type: 'AgentSessionPersist'
token: AgentToken
tid: string
lapUrl: string
wsUrl: string
expiresAt: number
}
| { type: 'AgentSessionClear' }
/**
* Schedule the next WS-reconnect attempt. The handler waits
* `delayMs` and dispatches `ReconnectAttempt { elapsedMs: delayMs }`
* back into the reducer, which decides whether to re-open the WS
* or transition to `failed` based on the cumulative wait. The
* delay schedule itself is computed reducer-side from
* `reconnectAttempt` — this effect is a thin setTimeout wrapper.
*
* The handler doesn't track cancellation: if the user dispatches
* `Disconnect` while the timer is pending, the reducer transitions
* to `idle` and the subsequent `ReconnectAttempt` becomes a no-op
* via the status guard. Simpler than coordinating cancel handles.
*/
| { type: 'AgentReconnectSchedule'; delayMs: number }
/**
* Auto-clear the `agentAttention` spotlight after `delayMs`. The
* handler waits and dispatches `Clear { entryId }` back into the
* attention slice via `wrapAgentAttention`. The clear is conditional
* (matches `entryId` against `latestDispatch.entryId` in the reducer),
* so a fast follow-up dispatch isn't wiped by the previous dispatch's
* pending timer — same race-avoidance pattern as
* `AgentReconnectSchedule`'s status guard.
*
* No cancel handle: the handler is a thin `setTimeout` wrapper. If
* the host doesn't wire `wrapAttentionMsg` in the factory, the
* handler no-ops and the spotlight stays set until the next dispatch
* overwrites it (graceful degradation — the activity log still
* works, just without auto-clearing visual highlights).
*/
| { type: 'AgentAttentionFlashTimeout'; entryId: string; delayMs: number }
AgentEffectHandler
export type AgentEffectHandler = (effect: AgentEffect) => Promise<void>
AgentConnectStatus
export type AgentConnectStatus =
| 'idle'
| 'minting'
| 'pending-claude'
| 'active'
| 'reconnecting'
| 'failed'
| 'error'
AgentConnectPendingToken
export type AgentConnectPendingToken = {
token: AgentToken
tid: string
lapUrl: string
/**
* Natural-language connect instruction the user copies into Claude.
* Includes URL, token, and the explicit `connect_session` tool
* call. Works in any Claude client (Desktop, CC CLI, etc.) — the
* Desktop-specific `/llui-connect` slash command is sugar over the
* same tool call.
*/
connectSnippet: string
expiresAt: number
/**
* Cached so the auto-reconnect path can re-open the WS without
* re-minting. The MintSucceeded → AgentOpenWS path stores it; the
* RestoreSession path also fills it in. Cleared by `Disconnect`.
*/
wsUrl: string
}
AgentConnectState
export type AgentConnectState = {
status: AgentConnectStatus
pendingToken: AgentConnectPendingToken | null
sessions: AgentSession[]
resumable: AgentSession[]
error: { code: string; detail: string } | null
/**
* Reconnect attempt counter. Incremented on each WS-close that
* triggers an auto-reconnect; reset on `WsOpened` and on user
* actions (`Disconnect`, fresh `Mint`). Drives the backoff schedule
* (1s, 2s, 4s, 8s, 16s, 30s, 30s, …) and surfaces to UI as
* "reconnecting (attempt 3 / next in 4s)".
*/
reconnectAttempt: number
/**
* Total cumulative ms spent in `reconnecting` for the current
* outage. Compared against `reconnectGiveUpMs` (effect-side option,
* default 5 min) to decide when to surface `failed` to the user.
* Reset whenever a WS opens successfully.
*/
reconnectElapsedMs: number
}
AgentConnectMsg
export type AgentConnectMsg =
/** @intent("Mint a new agent token and open the pairing WebSocket") */
| { type: 'Mint' }
/**
* @humanOnly — internal: dispatched by the AgentMintRequest effect
* handler when the mint endpoint replies success. Carries the token
* and connection URLs into state.
*/
| {
type: 'MintSucceeded'
token: AgentToken
tid: string
lapUrl: string
wsUrl: string
expiresAt: number
}
/** @humanOnly — internal: dispatched by the AgentMintRequest handler on failure. */
| { type: 'MintFailed'; error: { code: string; detail: string } }
/** @humanOnly — internal: WS adapter signalled the pairing socket is open. */
| { type: 'WsOpened' }
/** @humanOnly — internal: WS adapter signalled the pairing socket is closed. */
| { type: 'WsClosed' }
/** @humanOnly — internal: Claude bound the session via /agent/claim. */
| { type: 'ActivatedByClaude' }
/** @intent("Check which previously-issued agent sessions can be resumed") */
| { type: 'ResumeList'; tids: string[] }
/** @humanOnly — internal: AgentResumeCheck effect handler returned the list. */
| { type: 'ResumeListLoaded'; sessions: AgentSession[] }
/** @intent("Resume an existing agent session by tid") */
| { type: 'Resume'; tid: string }
/** @intent("Revoke an agent session by tid") */
| { type: 'Revoke'; tid: string }
/** @intent("Dismiss the current agent connect error") */
| { type: 'ClearError' }
/** @humanOnly — internal: AgentSessionsList effect handler returned the list. */
| { type: 'SessionsLoaded'; sessions: AgentSession[] }
/** @intent("Refresh the list of active agent sessions") */
| { type: 'RefreshSessions' }
/**
* @intent("Copy the agent connect snippet to the clipboard")
* Resolves the pendingToken's snippet in update() (state-reading is
* what update() is for) and dispatches a clipboard-write effect.
*/
| { type: 'CopyConnectSnippet' }
/**
* @humanOnly — internal: app boot dispatches this with credentials
* read from sessionStorage to skip the mint round-trip after page
* refresh. The agent's token (still alive on the server) keeps
* working since we don't go through the rotate-on-resume path. The
* reducer is idempotent against an in-flight Mint — only fires from
* `idle`.
*/
| {
type: 'RestoreSession'
token: AgentToken
tid: string
lapUrl: string
wsUrl: string
expiresAt: number
}
/**
* @intent("Disconnect the active agent session and clear all
* persisted credentials. Stops any in-flight reconnect attempt;
* subsequent WS closures stay in `idle` instead of triggering
* auto-reconnect. Use when the user explicitly clicks Disconnect
* in the panel — for transient drops, do nothing and let the
* reconnect loop run.")
*/
| { type: 'Disconnect' }
/**
* @humanOnly — internal: scheduler effect dispatched this when the
* backoff timer fired. The reducer increments the attempt counter,
* adds the just-elapsed delay to `reconnectElapsedMs`, and emits
* `AgentOpenWS` with the cached pendingToken/wsUrl so the WS can
* reattach without minting.
*/
| { type: 'ReconnectAttempt'; elapsedMs: number }
/**
* @humanOnly — internal: scheduler effect dispatched this when the
* give-up ceiling was reached without a successful WS open.
* Reducer flips status to `failed` so the UI can surface a clear
* error and offer a manual reconnect.
*/
| { type: 'ReconnectGaveUp' }
AgentConnectInitOpts
Options threaded through init() and update(). mintUrl is
optional — when omitted the agent effect handler derives it from
EffectHandlerHost.agentBasePath (default /agent → /agent/mint).
Set explicitly only when the mint endpoint lives outside the
configured base path.
export type AgentConnectInitOpts = { mintUrl?: string }
AgentConnectConnectOptions
export type AgentConnectConnectOptions = {
id?: string // optional DOM id prefix
}
ConnectBag
Static prop bag with reactive accessors. Mirrors the @llui/components
pattern (e.g. dialog.connect): callers spread bag keys directly
into element helpers, and function-valued props re-evaluate per
binding-mask hit. The previous shape — (state) => bag — required
callers to wrap every prop access in their own arrow, which the
documented usage didn't do (and silently produced undefined props
when spread).
export type ConnectBag<S> = {
root: { 'data-scope': 'agent-connect'; 'data-state': (s: S) => AgentConnectStatus }
mintTrigger: { onClick: () => void; disabled: (s: S) => boolean }
pendingTokenBox: { 'data-part': 'pending-token'; 'data-visible': (s: S) => boolean }
copyConnectSnippetButton: { onClick: () => void; disabled: (s: S) => boolean }
sessionsList: { 'data-part': 'sessions-list' }
sessionItem: (tid: string) => { 'data-part': 'session-item'; 'data-tid': string }
revokeButton: (tid: string) => { onClick: () => void }
resumeBanner: { 'data-part': 'resume-banner'; 'data-visible': (s: S) => boolean }
resumeItem: (tid: string) => { 'data-part': 'resume-item'; 'data-tid': string }
resumeButton: (tid: string) => { onClick: () => void }
dismissButton: (tid: string) => { onClick: () => void }
error: {
'data-part': 'error'
'data-visible': (s: S) => boolean
onClick: () => void
}
}
ConfirmEntry
export type ConfirmEntry = {
id: string
variant: string
payload: unknown
intent: string
reason: string | null
proposedAt: number
status: 'pending' | 'approved' | 'rejected'
}
AgentConfirmState
export type AgentConfirmState = { pending: ConfirmEntry[] }
AgentConfirmMsg
export type AgentConfirmMsg =
/**
* @humanOnly — internal: dispatched by `handleSendMessage` on the
* @llui/dom side when an agent message is gated by @requiresConfirm.
* Adds a pending entry to state; the user (not the agent) decides
* with Approve / Reject.
*/
| { type: 'Propose'; entry: ConfirmEntry }
/** @intent("Approve a pending agent action") */
| { type: 'Approve'; id: string }
/** @intent("Reject a pending agent action") */
| { type: 'Reject'; id: string }
/**
* @humanOnly — internal: the host app dispatches this on a timer to
* garbage-collect entries that have been pending past `maxAgeMs`.
* Agents have no business poking at the timer wheel directly.
*/
| { type: 'ExpireStale'; now: number; maxAgeMs: number }
AgentLogFilter
export type AgentLogFilter = { kinds?: LogKind[]; since?: number }
AgentLogState
export type AgentLogState = {
entries: LogEntry[]
filter: AgentLogFilter
}
AgentLogInitOpts
export type AgentLogInitOpts = { maxEntries?: number }
AgentLogMsg
export type AgentLogMsg =
/**
* @humanOnly — internal: WS frame router dispatches this on every
* `log-append` frame from the runtime. Agents observe the log via
* the LAP read surface, not by emitting Append themselves.
*/
| { type: 'Append'; entry: LogEntry }
/** @intent("Clear the agent activity log") */
| { type: 'Clear' }
/** @intent("Set the visibility filter for the agent log") */
| { type: 'SetFilter'; filter: AgentLogFilter }
LapErrorCode
export type LapErrorCode =
| 'auth-failed'
| 'revoked'
| 'paused'
| 'rate-limited'
| 'invalid'
| 'schema-error'
| 'timeout'
| 'internal'
LapError
export type LapError = {
error: {
code: LapErrorCode
detail?: string
retryAfterMs?: number
}
}
DispatchMode
Who can dispatch a Msg variant.
'shared'(default) — both UI bindings and the agent can dispatch.'human-only'— UI-only. Agent calls to/messagefor these variants are rejected withLapMessageRejectReason: 'human-only'. Use for internal UI events (focus/blur, scroll, hover) the LLM has no business triggering.'agent-only'— no UI binding exists. Reserved for LLM-driven flows like batch operations or "explain this state" introspection variants. Lint warns if a view references one viasend({ type: 'X' }). JSDoc sugar:@humanOnly→'human-only',@agentOnly→'agent-only'. Absence of either tag →'shared'. The two tags are mutually exclusive (enforced byllui/agent-exclusive-annotationsESLint rule).
export type DispatchMode = 'shared' | 'human-only' | 'agent-only'
MessageAnnotations
export type MessageAnnotations = {
intent: string | null
alwaysAffordable: boolean
requiresConfirm: boolean
dispatchMode: DispatchMode
/**
* Concrete copy-paste example dispatches authored as `@example`
* JSDoc tags. Multiple tags on one variant become multiple
* entries (mix typical / edge cases without nesting strings).
*/
examples: string[]
/**
* Non-blocking caution authored as `@warning`. Distinct from
* `requiresConfirm` (runtime user gate); this informs the LLM at
* affordance time so it can decide whether the dispatch's
* downstream is acceptable.
*/
warning: string | null
/**
* Effect kinds this variant emits when dispatched, declared via
* `@emits("kind1", "kind2")`. Lets the agent reason about side
* effects (cloud writes, analytics, persistent state changes)
* before dispatching, and chunk multi-step flows accordingly
* ("don't dispatch X 100 times — each one fires cloud/save").
* Empty when the variant doesn't emit effects or the author hasn't
* annotated it yet.
*/
emits: string[]
/**
* Boolean predicate authored as `@routeGated("expr")` JSDoc, with
* `state` bound at evaluation time. The variant only surfaces in
* `list_actions` when the predicate returns true. Compile-time
* alternative to `agentAffordances(state) => Msg[]` for the common
* case of "this Msg is reachable when state.X looks like Y." Null
* when the variant has no `@routeGated` tag (default affordance
* behavior applies).
*/
routeGate?: string | null
}
MessageSchemaEntry
export type MessageSchemaEntry = {
payloadSchema: object
annotations: MessageAnnotations
}
LapDescribeResponse
export type LapDescribeResponse = {
name: string
version: string
stateSchema: object
messages: Record<string, MessageSchemaEntry>
docs: AgentDocs | null
conventions: {
dispatchModel: 'TEA'
confirmationModel: 'runtime-mediated'
readSurfaces: readonly (
| 'state'
| 'query_dom'
| 'describe_visible_content'
| 'describe_context'
)[]
}
schemaHash: string
}
LapStateRequest
export type LapStateRequest = { path?: string }
LapStateResponse
export type LapStateResponse = { state: unknown }
LapActionsResponse
export type LapActionsResponse = {
actions: Array<{
variant: string
/**
* Human-readable phrase from `@intent("…")`, or `null` when the
* variant has no `@intent` annotation. Callers that surface
* affordances to an LLM should treat `null` as "this action is
* undocumented" — neither synthesise a label from the variant name
* nor invent one. Pre-`@intent` variants would previously surface
* as `intent: "<variant>"` here, which made unannotated actions
* indistinguishable from properly-labelled ones; emitting `null`
* keeps the gap visible.
*/
intent: string | null
requiresConfirm: boolean
/**
* `'shared'` — both UI and agent can dispatch. `'agent-only'` — no UI
* binding exists; the agent is the sole dispatcher. `'human-only'`
* variants never appear here (filtered before serialization).
*/
dispatchMode: 'shared' | 'agent-only'
/**
* Where this affordance came from:
* - `'binding'` — a tagged event handler is currently
* mounted in the rendered DOM.
* - `'always-affordable'` — the app's `agentAffordances(state)`
* hook listed it as available right now.
* - `'schema'` — neither of the above; the variant
* is in the Msg union and annotated `@agentOnly`. The
* `payloadHint` carries a synthesized example from the
* compiler-derived field types — copy-paste-ready for
* `send_message`. Bulk-edit operations land here.
*/
source: 'binding' | 'always-affordable' | 'schema'
selectorHint: string | null
payloadHint: object | null
/** Cautionary text from `@warning` JSDoc, or null. */
warning: string | null
/** Concrete examples from `@example` JSDoc, in source order. */
examples: string[]
/**
* Effect kinds this variant emits, from `@emits("k1", "k2")`.
* Empty when not annotated.
*/
emits: string[]
/**
* Per-field guidance lifted from `@should("…")` JSDoc on payload
* fields. Path is dot/bracket notation rooted at the payload (e.g.
* `"cells[].meta"`). Surfaces hints that would otherwise be buried
* inside the schema tree, so callers can read them alongside
* `examples` without diving into `description.messages.variants`.
*/
fieldHints: Array<{ path: string; hint: string }>
}>
}
LapMessageRequest
export type LapMessageRequest = {
msg: { type: string; [k: string]: unknown }
reason?: string
/**
* Backpressure contract for how long `/message` waits before returning:
* - `drained` (default): dispatch, then loop until the message queue is
* idle for `drainQuietMs` ms or the 5s hard cap trips. Captures any
* effect round-trips (http/delay/debounce) that feed back as messages.
* - `idle`: dispatch + flush + one microtask yield. Captures the
* synchronous update cycle but not async effects.
* - `none`: dispatch and return without flushing. For high-throughput
* fire-and-forget dispatch.
*/
waitFor?: 'drained' | 'idle' | 'none'
/**
* Quiescence window when `waitFor === 'drained'`. Drain completes when
* no new update cycle fires for this many ms. Default 100ms — long
* enough for a localhost HTTP round-trip, short enough to be
* imperceptible. Ignored for `idle` / `none`.
*/
drainQuietMs?: number
/**
* Hard cap on total wait time. When `waitFor === 'drained'`, this is
* the upper bound on how long the drain loop can run; if reached, the
* response carries `drain.timedOut: true` with partial results. For
* `pending-confirmation` messages, this is how long to wait for
* the user's confirm/reject. Default 5_000ms.
*/
timeoutMs?: number
/**
* Include the full post-drain `stateAfter` snapshot in the response.
* Default `false` — the response carries `stateDiff` only and the
* caller applies it to the prior snapshot (from connect/observe). For
* apps with non-trivial state, the diff is orders of magnitude
* smaller than the full state, and resending the snapshot on every
* dispatch wastes bandwidth and (for LLM callers) context budget.
*
* Set `true` when the caller doesn't track state incrementally and
* wants the snapshot back. The legacy `confirmed` and `wait` paths
* always carry `stateAfter` because their flow is asynchronous and
* a diff would be ambiguous.
*/
includeState?: boolean
}
LapMessageRejectReason
export type LapMessageRejectReason =
| 'human-only'
| 'user-cancelled'
| 'timeout'
| 'invalid'
| 'schema-error'
| 'revoked'
| 'paused'
LapDrainMeta
Drain metadata attached to dispatched / confirmed responses.
effectsObserved counts update-cycle commits (not individual effects) —
it's a proxy for "how much activity happened during the drain window."
errors surfaces sync throws from onEffect and unhandled rejections
from effect handlers that fired during the drain window, so the LLM
can see when an HTTP handler crashed silently.
warnings surfaces non-blocking observations from the schema
validator — typically untyped-field flags raised in strict mode
when the agent provided a value for an 'unknown'-typed field. The
dispatch landed (we accepted the value) but the validator couldn't
structurally check it, so the agent learns of the gap and can
tighten the next try if needed. Lenient mode never emits warnings;
the field is omitted in that case.
export type LapDrainMeta = {
effectsObserved: number
durationMs: number
timedOut: boolean
errors: Array<{ kind: 'error' | 'unhandledrejection'; message: string; stack?: string }>
warnings?: Array<{ path: string; code: string; message: string }>
}
LapMessageResponse
export type LapMessageResponse =
| {
status: 'dispatched'
/**
* Full post-drain state snapshot. Present only when the caller
* passed `includeState: true` in the request — by default,
* `stateDiff` is the only state-shaped field on the response
* because callers can apply the diff to the prior snapshot from
* `connect` / `observe`. See `LapMessageRequest.includeState`.
*/
stateAfter?: unknown
/**
* Structural diff from pre-dispatch state to post-drain state,
* in JSON-Patch shape (RFC 6902 subset: `add`, `remove`,
* `replace`). Empty when the dispatch produced no observable
* state change. The default state surface for callers — apply
* incrementally to the snapshot from `connect`/`observe`.
*/
stateDiff: import('./state-diff.js').StateDiff
actions: LapActionsResponse['actions']
drain: LapDrainMeta
}
| { status: 'pending-confirmation'; confirmId: string }
| {
/**
* The user approved a `pending-confirmation` message. `stateAfter`
* is the state snapshot captured when the approve was resolved;
* effects produced by the approved dispatch may still be in
* flight. The LLM should follow up with an `observe` call to
* pick up a drained view and fresh actions — by design the
* confirm path doesn't carry drain semantics because approval
* can arrive arbitrarily later than the original request.
*/
status: 'confirmed'
stateAfter: unknown
}
| { status: 'rejected'; reason: LapMessageRejectReason; detail?: string }
LapConfirmResultRequest
export type LapConfirmResultRequest = { confirmId: string; timeoutMs?: number }
LapConfirmResultResponse
export type LapConfirmResultResponse =
| { status: 'confirmed'; stateAfter: unknown }
| { status: 'rejected'; reason: 'user-cancelled' | 'timeout' }
| { status: 'still-pending' }
LapNarrateRequest
Push narration prose into the activity feed without dispatching a
Msg. The agent uses this for "I'm thinking…" / "About to do X
because…" / "I noticed Y, going to investigate" — running commentary
the user can read inline with agent actions.
The server synthesizes a LogEntry { kind: 'narrate', detail: text },
appends it to the per-tid recent-log buffer (visible to subsequent
describe_recent_actions calls), AND pushes a log-push frame to
the paired browser so the in-app activity feed renders it in real
time. No client roundtrip — the agent gets { ok: true } synchronously
once the server has accepted the narration.
export type LapNarrateRequest = {
text: string
/**
* Optional one-line label for the entry's `intent` field, e.g.
* "Thinking" / "Notice" / "Plan". Defaults to "Agent narrated"
* when omitted.
*/
intent?: string
}
LapNarrateResponse
export type LapNarrateResponse = { ok: true }
LapWaitRequest
export type LapWaitRequest = { path?: string; timeoutMs?: number }
LapWaitResponse
export type LapWaitResponse =
| { status: 'changed'; stateAfter: unknown }
| { status: 'timeout'; stateAfter: unknown }
LapQueryDomRequest
export type LapQueryDomRequest = { name: string; multiple?: boolean }
LapQueryDomResponse
export type LapQueryDomResponse = {
elements: Array<{ text: string; attrs: Record<string, string>; path: number[] }>
}
OutlineNode
export type OutlineNode =
| { kind: 'heading'; level: number; text: string }
| { kind: 'text'; text: string }
| { kind: 'list'; items: OutlineNode[] }
| { kind: 'item'; text: string; children?: OutlineNode[] }
| { kind: 'button'; text: string; disabled: boolean; actionVariant: string | null }
| { kind: 'input'; label: string | null; value: string | null; type: string }
| { kind: 'link'; text: string; href: string }
LapDescribeVisibleResponse
export type LapDescribeVisibleResponse = {
outline: OutlineNode[]
/**
* Where the outline came from:
* - `'data-agent'`: the app has `data-agent`-tagged zones and the
* walker scoped the outline to them. The author chose what to
* surface; trust the result.
* - `'fallback'`: no `data-agent` tags exist; the walker fell back
* to a depth- and count-limited semantic walk of the entire
* root element. Useful for first-pass dogfood targets that
* haven't tagged their views.
* - `'truncated'`: same as `'fallback'` but the cap (200 nodes)
* was hit before the walk finished. The visible content beyond
* that point is not represented; reach for `query_dom` or state
* reads if you need more.
*/
source: 'data-agent' | 'fallback' | 'truncated'
}
AgentDocs
export type AgentDocs = {
purpose: string
overview?: string
cautions?: string[]
/**
* Free-form idiomatic-usage examples authored by the app: typical
* sequences of dispatches the LLM should know about, like "to
* delete a saved matrix: dispatch Confirm/Ask first, then on
* approve dispatch Cloud/Delete." Each entry is one example;
* order is up to the author.
*/
examples?: string[]
}
AgentContext
export type AgentContext = {
summary: string
hints?: string[]
cautions?: string[]
}
LapContextResponse
export type LapContextResponse = { context: AgentContext }
LapObserveResponse
export type LapObserveResponse = {
state: unknown
actions: LapActionsResponse['actions']
description: LapDescribeResponse
context: AgentContext | null
}
LapEndpointMap
export type LapEndpointMap = {
'/lap/v1/describe': { req: null; res: LapDescribeResponse }
'/lap/v1/state': { req: LapStateRequest; res: LapStateResponse }
'/lap/v1/actions': { req: null; res: LapActionsResponse }
'/lap/v1/message': { req: LapMessageRequest; res: LapMessageResponse }
'/lap/v1/confirm-result': { req: LapConfirmResultRequest; res: LapConfirmResultResponse }
'/lap/v1/wait': { req: LapWaitRequest; res: LapWaitResponse }
'/lap/v1/narrate': { req: LapNarrateRequest; res: LapNarrateResponse }
'/lap/v1/query-dom': { req: LapQueryDomRequest; res: LapQueryDomResponse }
'/lap/v1/describe-visible': { req: null; res: LapDescribeVisibleResponse }
'/lap/v1/context': { req: null; res: LapContextResponse }
'/lap/v1/observe': { req: null; res: LapObserveResponse }
}
LapPath
export type LapPath = keyof LapEndpointMap
LapRequest
export type LapRequest<P extends LapPath> = LapEndpointMap[P]['req']
LapResponse
export type LapResponse<P extends LapPath> = LapEndpointMap[P]['res']
LogKind
export type LogKind =
| 'proposed'
| 'dispatched'
| 'confirmed'
| 'rejected'
| 'blocked'
| 'read'
| 'error'
/**
* The agent emitted prose into the activity feed via `/lap/v1/narrate`
* — narration like "thinking about your request…", "I'm about to add
* an alternative because…", or any out-of-band commentary that
* doesn't fit a `dispatched` / `read` lifecycle. Lets the agent talk
* to the user inside the app without inventing a fake `@agentOnly`
* Msg type.
*/
| 'narrate'
LogEntry
export type LogEntry = {
id: string
at: number
kind: LogKind
variant?: string
intent?: string
detail?: string
/**
* Structural diff from pre-dispatch state to post-drain state, in
* JSON-Patch shape. Populated only for `kind: 'dispatched'` entries
* — read entries (get_state / list_actions / observe / …) don't
* mutate state, and an empty diff would just be noise. Lets the
* agent reconstruct what each past action did without re-fetching
* state snapshots.
*/
stateDiff?: import('./state-diff.js').StateDiff
}
HelloFrame
export type HelloFrame = {
t: 'hello'
appName: string
appVersion: string
msgSchema: Record<string, MessageSchemaEntry>
stateSchema: object
affordancesSample: object[]
docs: AgentDocs | null
schemaHash: string
}
RpcReplyFrame
export type RpcReplyFrame = { t: 'rpc-reply'; id: string; result: unknown }
RpcErrorFrame
export type RpcErrorFrame = { t: 'rpc-error'; id: string; code: string; detail?: string }
ConfirmResolvedFrame
export type ConfirmResolvedFrame = {
t: 'confirm-resolved'
confirmId: string
outcome: 'confirmed' | 'user-cancelled'
stateAfter?: unknown
}
StateUpdateFrame
export type StateUpdateFrame = { t: 'state-update'; path: string; stateAfter: unknown }
LogAppendFrame
export type LogAppendFrame = { t: 'log-append'; entry: LogEntry }
ClientFrame
export type ClientFrame =
| HelloFrame
| RpcReplyFrame
| RpcErrorFrame
| ConfirmResolvedFrame
| StateUpdateFrame
| LogAppendFrame
RpcFrame
export type RpcFrame = { t: 'rpc'; id: string; tool: string; args: unknown }
RevokedFrame
export type RevokedFrame = { t: 'revoked' }
ActiveFrame
export type ActiveFrame = { t: 'active' }
LogPushFrame
Server-pushed log entry. Used today by the narrate LAP method:
the agent calls /lap/v1/narrate { text }, the server synthesizes
a LogEntry { kind: 'narrate' } and pushes it down to the paired
runtime so the in-app activity feed renders the narration in real
time. Distinct from the browser-emitted log-append frame:
log-append is browser → server (rpc-derived audit), log-push
is server → browser (server-originated entries, no echo).
export type LogPushFrame = { t: 'log-push'; entry: LogEntry }
ServerFrame
export type ServerFrame = RpcFrame | RevokedFrame | ActiveFrame | LogPushFrame
AgentToken
export type AgentToken = string & { readonly [TokenBrand]: 'AgentToken' }
TokenStatus
export type TokenStatus =
| 'awaiting-ws'
| 'awaiting-claude'
| 'active'
| 'pending-resume'
| 'revoked'
TokenRecord
export type TokenRecord = {
tid: string
/**
* SHA-256 hex of the bearer token. The plaintext token is never
* stored — incoming requests hash their `Authorization: Bearer …`
* value and look up by this field. Hash-only storage keeps a leaked
* store from being a live-token leak. Mirrors the standard session-
* cookie / API-key pattern.
*/
tokenHash: string
uid: string | null
status: TokenStatus
createdAt: number
/**
* Hard-expiry in milliseconds since epoch. The mint endpoint sets
* this to `now + hardExpiryMs`; the verify path rejects requests
* presenting tokens whose record has `expiresAt <= now`. Pre-0.0.35
* the equivalent value lived inside the JWT payload as `exp` (in
* seconds); the new opaque-token flow keeps it server-side so the
* record is the single source of truth.
*/
expiresAt: number
lastSeenAt: number
pendingResumeUntil: number | null
origin: string
label: string | null
}
AgentSession
export type AgentSession = {
tid: string
label: string
status: 'active' | 'pending-resume' | 'revoked'
createdAt: number
lastSeenAt: number
}
MintRequest
export type MintRequest = Record<string, never>
MintResponse
export type MintResponse = {
token: AgentToken
tid: string
wsUrl: string
lapUrl: string
expiresAt: number
}
ResumeListRequest
export type ResumeListRequest = { tids: string[] }
ResumeListResponse
export type ResumeListResponse = { sessions: AgentSession[] }
ResumeClaimRequest
export type ResumeClaimRequest = { tid: string }
ResumeClaimResponse
export type ResumeClaimResponse = { token: AgentToken; wsUrl: string }
RevokeRequest
export type RevokeRequest = { tid: string }
RevokeResponse
export type RevokeResponse = { status: 'revoked' }
SessionsResponse
export type SessionsResponse = { sessions: AgentSession[] }
AuditEvent
export type AuditEvent =
| 'mint'
| 'claim'
| 'resume'
| 'revoke'
| 'lap-call'
| 'msg-dispatched'
| 'msg-blocked'
| 'confirm-proposed'
| 'confirm-approved'
| 'confirm-rejected'
| 'rate-limited'
| 'auth-failed'
AuditEntry
export type AuditEntry = {
at: number
tid: string | null
uid: string | null
event: AuditEvent
detail: object
}
Interfaces
TokenStore
Append-only, read-friendly storage for token records. See spec §10.3.
Tokens are looked up by tokenHash (SHA-256 of the presented bearer
value) on every authenticated request. The tid index is kept for
the resume / revoke / sessions surfaces — those operate on session
IDs the user can see and copy.
export interface TokenStore {
create(record: TokenRecord): Promise<void>
findByTid(tid: string): Promise<TokenRecord | null>
/**
* Look up a record by the SHA-256 hash of its bearer token. Returns
* `null` when the hash isn't in the store (the typical "this token
* isn't ours / has been revoked / never existed" case).
*/
findByTokenHash(tokenHash: string): Promise<TokenRecord | null>
listByIdentity(uid: string): Promise<TokenRecord[]>
touch(tid: string, now: number): Promise<void>
markPendingResume(tid: string, until: number): Promise<void>
/** Transition to awaiting-claude: browser WS is connected, waiting for Claude's first call. */
markAwaitingClaude(tid: string, now: number): Promise<void>
markActive(tid: string, label: string, now: number): Promise<void>
revoke(tid: string): Promise<void>
/**
* Replace the bearer token's hash and bump expiry. Used by the
* resume-claim flow: the old token is invalidated (its hash is no
* longer indexed) and a freshly-minted opaque token takes its
* place. The `tid` stays stable so existing audit / pairing state
* carries over.
*/
rotateTokenHash(tid: string, newTokenHash: string, expiresAt: number): Promise<void>
}
RateLimiter
export interface RateLimiter {
check(key: string, bucket: 'token' | 'identity'): Promise<RateLimitResult>
}
PairingConnection
Thin abstraction over a single paired WebSocket. Consumed by the
registry implementations; runtime-specific adapters (ws-lib,
WebSocketPair, Deno.upgradeWebSocket, Bun.serve upgrade) build
one of these and pass it to registry.register().
export interface PairingConnection {
send(frame: ServerFrame): void
onFrame(handler: (f: ClientFrame) => void): void
onClose(handler: () => void): void
close(): void
}
PairingRegistry
Registry of live browser pairings. Pure routing + hello cache — request-lifecycle state (in-flight RPC promises, confirm waits, long-polls) lives in the LAP handlers that need it, not here. Two implementations ship today:
InMemoryPairingRegistryfor long-lived server processes (Node, Bun, Deno, Deno Deploy).- A Cloudflare Durable Object implementation (see
server/cloudflare) for stateless Worker runtimes. Other runtimes can implement this interface the same way; the contract is intentionally small.
export interface PairingRegistry {
// ── Routing primitives ─────────────────────────────────────────
register(tid: string, conn: PairingConnection): void
unregister(tid: string): void
isPaired(tid: string): boolean
getHello(tid: string): HelloFrame | null
/** Send a frame. No-op when the pairing is absent or closed. */
send(tid: string, frame: ServerFrame): void
/**
* Subscribe to frames from the paired browser. Returns an
* unsubscribe function. A subscriber can remove itself mid-dispatch
* by returning `true` from its callback — useful for one-shot
* request/response correlation.
*/
subscribe(tid: string, handler: FrameSubscriber): () => void
/**
* Observe the pairing closing (WebSocket drop, `unregister`, etc.).
* Handlers registered before close fire; handlers registered after
* close fire synchronously. Returns an unsubscribe function.
*/
onClose(tid: string, handler: () => void): () => void
/**
* Read the most recent `n` log entries for a tid (newest first).
* Backed by an in-memory ring buffer populated as the registry
* sees `log-append` frames; capped per-tid to bound memory across
* long-lived sessions. Drained on close. Returns an empty array
* for unknown tids.
*/
getRecentLog(tid: string, n: number): LogEntry[]
// ── Request/response helpers ───────────────────────────────────
// These are part of the contract (LAP handlers call them directly)
// but implementations almost always delegate to the free helpers in
// `./rpc.ts`, which are built on the routing primitives above. The
// Cloudflare Durable Object registry uses the same helpers; the
// split exists so the routing surface is small enough to implement
// across stateful boundaries (DO storage, WebSocket hibernation),
// while the correlation logic lives once in a runtime-neutral file.
/**
* Send a typed rpc frame and await its matching reply. See
* `./rpc.ts::rpc` for the full contract.
*/
rpc(tid: string, tool: string, args: unknown, opts?: RpcOptions): Promise<unknown>
/** See `./rpc.ts::waitForConfirm`. */
waitForConfirm(
tid: string,
confirmId: string,
timeoutMs: number,
): Promise<{ outcome: 'confirmed' | 'user-cancelled'; stateAfter?: unknown }>
/** See `./rpc.ts::waitForChange`. */
waitForChange(
tid: string,
path: string | undefined,
timeoutMs: number,
): Promise<{ status: 'changed' | 'timeout'; stateAfter: unknown }>
}
MinimalDurableObjectNamespace
Minimal DurableObjectNamespace surface we need — idFromName +
get returning a Stub with fetch(req). Kept structural so we
don't depend on @cloudflare/workers-types (the user's project has
them; we shouldn't duplicate).
export interface MinimalDurableObjectNamespace {
idFromName(name: string): MinimalDurableObjectId
get(id: MinimalDurableObjectId): MinimalDurableObjectStub
}
MinimalDurableObjectId
export interface MinimalDurableObjectId {
// Opaque, but DO ids are passed back into `namespace.get()`.
readonly name?: string
}
MinimalDurableObjectStub
export interface MinimalDurableObjectStub {
fetch(req: Request): Promise<Response>
}
Classes
InMemoryTokenStore
class InMemoryTokenStore implements TokenStore {
byTid
tidByTokenHash
create(record: TokenRecord): Promise<void>
findByTid(tid: string): Promise<TokenRecord | null>
findByTokenHash(tokenHash: string): Promise<TokenRecord | null>
listByIdentity(uid: string): Promise<TokenRecord[]>
touch(tid: string, now: number): Promise<void>
markPendingResume(tid: string, until: number): Promise<void>
markAwaitingClaude(tid: string, now: number): Promise<void>
markActive(tid: string, label: string, now: number): Promise<void>
revoke(tid: string): Promise<void>
rotateTokenHash(tid: string, newTokenHash: string, expiresAt: number): Promise<void>
}
InMemoryPairingRegistry
class InMemoryPairingRegistry implements PairingRegistry {
pairings
onLogAppend: ((tid: string, entry: LogEntry) => void) | null
recentLog
constructor(opts: {
onLogAppend?: (tid: string, entry: LogEntry) => void
} = {})
getRecentLog(tid: string, n: number): LogEntry[]
register(tid: string, conn: PairingConnection): void
unregister(tid: string): void
isPaired(tid: string): boolean
getHello(tid: string): HelloFrame | null
send(tid: string, frame: ServerFrame): void
subscribe(tid: string, handler: FrameSubscriber): () => void
onClose(tid: string, handler: () => void): () => void
dispatch(tid: string, frame: ClientFrame): void
rpc(tid: string, tool: string, args: unknown, opts: RpcOptions = {}): Promise<unknown>
waitForConfirm(tid: string, confirmId: string, timeoutMs: number): Promise<{ outcome: 'confirmed' | 'user-cancelled'; stateAfter?: unknown }>
waitForChange(tid: string, path: string | undefined, timeoutMs: number): Promise<{ status: 'changed' | 'timeout'; stateAfter: unknown }>
notify(tid: string, frame: ServerFrame): void
handleClose(tid: string): void
}
AgentPairingDurableObject
Agent server instance scoped to a single Durable Object. All
pairing state lives in the DO's in-process memory — which is safe
here because the DO is a persistent addressable entity, not a
one-shot Worker isolate.
Users instantiate one of these inside their DO class's constructor
and delegate fetch to agent.fetch(req). LAP HTTP routes,
WebSocket upgrades, and the optional MCP endpoint all flow through
this single entry.
class AgentPairingDurableObject {
agent: AgentCoreHandle
mcpRouter: ((req: Request) => Promise<Response | null>) | null
constructor(opts: DurableObjectOptions)
fetch(req: Request): Promise<Response>
}
Constants
WsPairingRegistry
Back-compat alias for the prior class name. New code should use
InMemoryPairingRegistry. Removed in a future major.
@deprecated Use InMemoryPairingRegistry directly.
const WsPairingRegistry