@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 the tid in the Authorization: Bearer token to the per-session DO.
  • GET /agent/ws (WebSocket upgrade) → routed by the tid in ?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: true returns a partial snapshot; call observe again 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

  • signingKey must 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/minute limiter is a coarse ceiling — tune it for your workload.
  • @humanOnly is a hard block at the browser RPC layer, not just a convention.
  • @requiresConfirm flows a confirmation message through state; approval requires the user to interact with the app UI, not just the LLM.
  • The corsOrigins option 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/mint path — 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 recover tid from the token alone. The caller passes a resolveTid callback — 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:

  1. 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).
  2. Bare nested types: {kind: 'object', shape} for inline / followed-via-typeIndex shapes; {kind: 'array', element} for T[] / 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.
  3. Rich descriptor: wraps any of the above with {optional?, priority?, hint?} carrying TS optionality and @should hints.
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 /message for these variants are rejected with LapMessageRejectReason: '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 via send({ type: 'X' }). JSDoc sugar: @humanOnly'human-only', @agentOnly'agent-only'. Absence of either tag → 'shared'. The two tags are mutually exclusive (enforced by llui/agent-exclusive-annotations ESLint 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:

  • InMemoryPairingRegistry for 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