Talk to LLui Apps via Claude

LLui apps can be driven from Claude. Install the llui-agent MCP bridge once, paste a connect snippet from any LLui-built app, and Claude can read the app's state, list available actions, and dispatch messages — the same Msgs the human user dispatches with clicks and keys.

This page is for end users installing the agent and app authors who want to expose their app to it. If you're debugging code you wrote, see Debugging LLui Apps instead.

Install (Claude Desktop)

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or the equivalent on your OS:

{
  "mcpServers": {
    "llui": {
      "command": "npx",
      "args": ["-y", "llui-agent"]
    }
  }
}

Restart Claude Desktop. Sixteen LLui tools become available: connect_session, disconnect_session, observe, describe_app, get_state, query_state, describe_recent_actions, would_dispatch, list_actions, send_message, get_confirm_result, wait_for_change, narrate, query_dom, describe_visible_content, describe_context.

Install (Claude Code CLI)

claude mcp add --transport stdio llui -- npx -y llui-agent

Run /mcp inside Claude Code to confirm the server connected (or start a new session). The same sixteen tools become available.

If you run Claude Code in auto mode (permissions.defaultMode: "auto" in ~/.claude/settings.json), the auto-classifier silently rejects unrecognized MCP tools the first time they're called — Claude reports "tool was rejected" but no UI prompt is shown. Add the bridge's tools to your allowlist once:

// ~/.claude/settings.json
{
  "permissions": {
    "allow": ["mcp__llui__*"], // replace `llui` with the name you used in `claude mcp add`
  },
}

Upgrading an existing install

npx -y llui-agent caches the first-resolved version under ~/.npm/_npx/, so subsequent invocations don't re-check npm — an existing user stays pinned to whatever shipped the day they ran the install command. To pick up a new release:

  1. Pin @latest in the MCP config so the cache key changes. In Claude Desktop, edit the args to ["-y", "llui-agent@latest"]. In Claude Code, run:
    claude mcp remove llui
    claude mcp add --transport stdio llui -- npx -y llui-agent@latest
    Alternative: leave the spec alone and clear the cache once with rm -rf ~/.npm/_npx. Same effect, fewer config edits — but you'll need the same poke at the next breaking release.
  2. Restart the MCP client. The tool list is fixed at session start, so quit + reopen Claude Desktop, or start a new Claude Code session.
  3. Start a fresh chat. A conversation that was bound under old tool names won't see the new ones until it restarts. Paste a fresh connect snippet from the app — the snippet wording also evolves between releases, so the new one is worth grabbing.

Verify with claude mcp list (Code) or by checking the tool picker (Desktop). Tool names from llui-agent@0.0.5+ are connect_session / disconnect_session (no llui_ prefix); earlier releases used llui_connect_session / llui_disconnect_session.

Use it

Open any app built with @llui/agent/client. Click "Connect with Claude" and copy the generated snippet — a one-line natural-language instruction containing the LAP URL and the bearer token. Paste it into Claude. Claude reads the snippet, calls connect_session, and the chat is now bound to that app.

Each Claude chat is bound to one LLui app at a time. To switch, ask Claude to call disconnect_session and paste a new snippet.

Troubleshooting: "tool isn't available in this session"

If Claude reports that connect_session "isn't available" or "doesn't appear in the list of deferred or loaded tools", check that claude mcp list shows the LLui MCP server as Connected. If it is, the issue is tool-name resolution: Claude Code namespaces MCP tools as mcp__<server-name>__<tool-name> and may defer-load them. Tell Claude to look for mcp__<server>__connect_session and search for it via the tool-search facility — it will load and become callable. The snippets shipped by recent @llui/agent releases already include this hint; paste a fresh snippet if you're stuck.

Slash shortcuts (optional)

The bridge registers an MCP prompt named llui-connect. Both clients expose it as a slash command, but the namespacing differs:

Client Shortcut
Claude Desktop /llui-connect <url> <token>
Claude Code CLI /mcp__<server-name>__llui-connect <url> <token>

<server-name> is whatever you passed to claude mcp addllui if you used the command above. The natural-language snippet from the app works the same in either client and doesn't depend on the server-name choice; the slash form is a power-user shortcut.

How it works

  1. The LLui app mints a per-browser-session token and renders a connect snippet — a one-line instruction containing the LAP URL and the bearer token.
  2. You paste into Claude. Claude reads the snippet, calls connect_session, and the bridge records {url, token} for this chat.
  3. The bridge calls POST {url}/describe to validate and cache the app's schema (Msg union, intents, annotations).
  4. Subsequent tool calls (get_state, send_message, etc.) forward to {url}/<path> with your token as a Bearer.
  5. Sensitive actions marked @requiresConfirm in the app code route through a confirmation prompt — only the human user can approve them.

Confirmation: keeping humans in the loop

Apps mark sensitive Msg variants with @requiresConfirm:

type Msg =
  | { type: 'inc' }
  /** @intent("Delete item") @requiresConfirm */
  | { type: 'delete'; id: string }
  /** @intent("Place order") @humanOnly */
  | { type: 'checkout' }
Tag Effect
@requiresConfirm Claude proposes; the user approves before dispatch.
@humanOnly Claude can't dispatch; not listed in list_actions.
(default) Claude can dispatch directly; logged in the agent log panel.

Sensitive actions never reach the reducer until a human clicks Approve in the app's confirm card. The bridge's get_confirm_result lets Claude poll the result and continue the conversation.

For app authors: expose your app

Apps opt in by installing @llui/agent and enabling the Vite plugin's agent-metadata emission:

pnpm add @llui/agent @llui/effects
// vite.config.ts
import llui from '@llui/vite-plugin'
export default { plugins: [llui({ agent: true })] }

Server

import { createLluiAgentServer } from '@llui/agent/server'
import express from 'express'

const agent = createLluiAgentServer({
  identityResolver: async (req) => req.cookies.user_id ?? null,
})

const app = express()
app.use('/agent', async (req, res) => {
  const webReq = expressToWebRequest(req)
  const webRes = await agent.router(webReq)
  if (!webRes) {
    res.status(404).end()
    return
  }
  webRes.headers.forEach((v, k) => res.setHeader(k, v))
  res.status(webRes.status).send(await webRes.text())
})

const server = app.listen(8787)
server.on('upgrade', agent.wsUpgrade)

Built-in MCP endpoint (optional)

Pass mcp: true (or an McpRouterOptions object) to createLluiAgentServer / AgentPairingDurableObject to serve MCP directly at /agent/mcp, so Claude Desktop can connect without the llui-agent bridge process.

The endpoint stays reachable without a bearer — connect_session is where this protocol authenticates, and a client has to get far enough to call it. Each initialize allocates a transport plus a fully-registered MCP server, so that open path is bounded on several axes:

Option Default What it bounds
maxSessions 64 Total sessions retained OR under construction. An initialize that can't free a slot gets 503, never a smaller allocation.
maxUnauthenticatedSessions 16 Of those, how many may be held by callers that presented no bearer and haven't run connect_session. LRU-evicted within the quota, so anonymous churn can never displace an authenticated session.
maxSessionsPerIdentity 8 How many sessions one tid may hold. Reaching it evicts that identity's own oldest session — so one bearer, or one crash-looping client, can't fill the endpoint with sessions nothing may evict.
unauthenticatedMaxLifetimeMs 1800000 ABSOLUTE lifetime of a session that never connected, measured from initialize. The only clock such a session runs on — nothing refreshes it, so traffic cannot hold a quota slot open.
idleTtlMs 1800000 Idle window before an authenticated session is closed. This is also what releases its bearer token from server memory.
maxResurrectableSessions 256 How many dropped session IDs stay resurrectable (see below). A bounded FIFO on the same clock as unauthenticatedMaxLifetimeMs; past the bound the oldest IDs are forgotten. Raising it is cheap per request — expired entries are reclaimed in constant-size slices, not scanned in full on every call. Default maxSessions * 4.

The quota counts sessions that are still being built, not just registered ones — a burst of concurrent initializes is exactly the case where those two numbers differ, and the ceiling has to hold for the burst.

Two further rules apply to every initialize:

  • It is rate-limited through the server's own rateLimiter (default 30/minute), keyed by the caller's address. By default that address is not taken from any request header (see below), so all callers whose address the server can't establish share one bucket. Set rateLimiter on the server options to change the rate.

  • An Authorization: Bearer header, if present, must be a valid LLui agent token or the request is rejected 401 having allocated nothing. Omitting the header is still fine; presenting a bogus or dead one is not. A valid bearer only buys admission outside the anonymous quota — every tool still refuses until connect_session binds a token.

    A revoked token buys nothing here either. On the bundled InMemoryTokenStore that already held before the check existed: revoking drops the token's hash from the lookup index, so the bearer no longer resolves at all and the request is refused 401 like any other unknown token. The status is now also checked on the record itself, which matters for a custom TokenStore — the interface does not require dropping the index (the row is deliberately kept for audit and replay), and a store that keeps it indexed would otherwise let a dead tid buy admission outside the anonymous quota that an anonymous caller can never reclaim. A record read back as revoked is refused 403 revoked, the same answer the LAP gate and the WebSocket admission give. Do not branch on 403 at this gate for the bundled store — it answers 401 there.

Requests on an already-established session are not throttled here; their tool handlers reach LAP through the core router, which gates them on the per-token bucket. That is why a session that has not authenticated carries unauthenticatedMaxLifetimeMs rather than an idle window: an idle window is refreshable by anyone who can send an empty POST.

A session that has not connected yet is not reclaimed for being idle. Pairing is human-paced — the client initializes at startup, then the session waits while a person opens the app, clicks "Connect with Claude", copies the snippet and pastes it — and the MCP SDK client does not re-initialize when a session goes missing, so a reclaim mid-pairing surfaces as an error rather than a reconnect. What bounds the memory is maxUnauthenticatedSessions, so an idle provisional session is only evicted when that quota is actually contended, least-recently-seen first.

That still puts a mid-pairing session first in line, and it is worth being precise about why. The LRU key is lastSeenAt, and any request refreshes it — so a caller holding sessions of its own can keep all of them newer than yours simply by pinging them. A pairing session is idle by construction (a person is reading the panel), which makes it not merely a likely eviction victim but a selectable one: whoever fills the anonymous quota decides who gets evicted, and it is whoever is mid-pairing.

Being evicted is no longer fatal to the pairing. The MCP SDK cannot recover from a session going missing — it has no re-initialize path on a 404, and clears its session id only on an explicit terminateSession() — so instead of the eviction being prevented, it is made survivable: a session ID this server issued and has since dropped is remembered, and the next request carrying it rebuilds the session under the same ID and replays the request. The client never learns anything happened, and the paste succeeds.

Five things about that are load-bearing:

  • Only IDs the server issued resurrect. The list is a bounded FIFO (maxResurrectableSessions) on the same clock as unauthenticatedMaxLifetimeMs, so an arbitrary ID is still a plain 404 and the free, unthrottled with-session-ID path does not become a second allocation door.
  • A resurrect is an allocation, and passes the same three gates as a fresh initialize, in the same order: the rate limiter, then the fail-closed bearer check (a bearer is not required, but one that is presented must be valid or the request is refused having allocated nothing), then the quota. It is refused 429, 401, 403 or 503 on the same terms.
  • What comes back is provisional. The bearer binding went with the old session, so a resurrected session sits inside the anonymous quota and every tool refuses until connect_session runs again. It also inherits the original createdAt, so resurrection cannot renew unauthenticatedMaxLifetimeMs. Note the deliberate asymmetry with initialize: there a valid bearer buys admission outside the anonymous quota, here it buys nothing at all.
  • Overlapping requests on one session ID are ordinary in MCP (the SDK runs a standalone GET stream beside its POSTs), so concurrent resurrections of one ID are deduplicated: the first rebuilds the session, the others use it. A request that loses that race does not inherit the winner's outcome — it re-derives its own through the same gates, so it is served if the winner's session is there and refused 429/503/401/403 on the same terms if not. A refused resurrect is no longer reported to the loser as a 404: the ID is still remembered and a later attempt still rebuilds it, so the honest answer is the refusal. Barring one defensive branch that cannot be reached (a rebuilt transport coming back under a different ID), a 404 here means the server does not remember this ID.
  • An explicit DELETE is durable. Every other teardown reason — LRU eviction, the per-identity cap, either clock — is a decision the server made that the client has no way to learn about, which is what resurrection exists to paper over. A DELETE is the opposite: the client asked for the session to be destroyed, so its ID is forgotten rather than remembered, and terminateSession() means what it says.

The trade: the bound on how many sessions may exist is unchanged, but allocation churn is now softer — replaying N remembered IDs forces N rate-limited, quota-bounded re-allocations. And under a sustained full quota a resurrect fails like anything else; this recovers a burst, not a siege.

Client IP, proxies, and the rate-limit bucket

/agent/mint and the MCP initialize path are the two endpoints that allocate server state for a caller with no resolved identity, so both throttle on a bucket keyed by the caller's address. Two server options decide what that address is:

Option Default Effect
trustProxy 0 Number of trusted reverse proxies in front, each of which appends to X-Forwarded-For. 0 reads no forwarding header at all. n > 0 reads the hop n from the END of the chain.
clientAddress (req) => string | null returning the peer address the host runtime knows. Node: socket.remoteAddress. Cloudflare: the cf-connecting-ip header.

The default trusts nothing on purpose. X-Forwarded-For and X-Real-IP are ordinary request headers: on a direct-to-origin deployment the caller writes them, so keying a limiter on one hands the caller a fresh bucket per request and the limit stops existing. Behind a proxy the header is evidence, but only the hops your proxies appended — which is why trustProxy is a COUNT and the value read is taken from the right-hand end of the chain, never the first entry.

Declaring trustProxy: n asserts that all n proxies write X-Forwarded-For. Everything else follows: a chain shorter than n did not come through n appending proxies, so it is dropped rather than read, and X-Real-IP is never consulted — it is set rather than appended, so nothing about it says a proxy wrote it, and reading it made the whole declaration bypassable by omitting X-Forwarded-For. If your proxy sets only X-Real-IP (nginx with proxy_set_header X-Real-IP and no X-Forwarded-For is a common config), name it through clientAddress instead of trustProxy:

createLluiAgentServer({ mcp: true, clientAddress: (req) => req.headers.get('x-real-ip') })

One case is unavoidable: a chain of exactly n entries is what a direct client behind n appending proxies produces and also what a caller who spoofs n entries produces, and no property of the request separates them. trustProxy is trusted input — set it only for proxies that are really in the path and really append.

// Behind one reverse proxy (nginx, Caddy, an ALB):
createLluiAgentServer({ mcp: true, trustProxy: 1 })

// Direct-to-origin Node, with the real socket address:
const server = http.createServer(/* … */)
createLluiAgentServer({
  mcp: true,
  clientAddress: (req) => peerAddresses.get(req) ?? null,
})

// Cloudflare Workers / Durable Objects:
new AgentPairingDurableObject({
  mcp: true,
  clientAddress: (req) => req.headers.get('cf-connecting-ip'),
})

On Cloudflare the session quotas are app-wide, not per-user. routeToAgentDO sends every /agent/mcp request to the single __root Durable Object — it has to, because MCP authenticates inside the protocol via connect_session, so there is no tid to shard on when the request arrives. One McpRouter instance therefore serves every caller of the deployment, and maxSessions / maxUnauthenticatedSessions are budgets shared across all of them: the 16 anonymous slots are 16 for the whole app, not 16 per user. Size them for your concurrent-pairing traffic, not for one person.

Client

import { mountApp } from '@llui/dom'
import { createAgentClient, agentConnect, agentConfirm } from '@llui/agent/client'
import { handleEffects } from '@llui/effects'
import { App } from './App'

const root = document.getElementById('app')!
const handle = mountApp(root, App)

const client = createAgentClient({
  handle,
  def: App,
  rootElement: root,
  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 }),
  },
})
client.start()

Render agentConnect (the "Connect with Claude" button + token copy box) and agentConfirm (pending confirmation cards) anywhere in your view tree.

Annotate the Msg union

LLM-driven actions are discovered through JSDoc tags on the Msg variants:

type Msg =
  /** @intent("Increment the counter") */
  | { type: 'inc' }
  /** @intent("Delete item") @requiresConfirm */
  | { type: 'delete'; id: string }
  /** @intent("Place order") @humanOnly */
  | { type: 'checkout' }
  /** @intent("Navigate") @alwaysAffordable */
  | { type: 'nav'; to: 'reports' | 'settings' | 'home' }
Tag Semantics
@intent("...") Human-readable label for Claude, the confirm UI, and logs.
@alwaysAffordable Surfaces to Claude even when no binding is currently visible.
@requiresConfirm Claude proposes; user approves before dispatch.
@humanOnly Claude cannot dispatch; not in list_actions.

App-level annotations (agentDocs.purpose, agentDocs.overview, agentDocs.cautions, agentAffordances, agentContext) attach to the component itself and shape what Claude sees in describe_app and describe_context. Per-field @should("...") hints document expected shapes for payload fields.

Annotation argument grammar

Every tag that takes arguments — @intent, @example, @warning, @emits, @routeGated, @should, @validates — shares one grammar:

@tag("first argument"[, "second argument"])
@example({"type": "inc"})                    // @example only
  • The ( follows the tag on the same line. A tag not in the call form is not an annotation — plain block-form JSDoc (@example followed by a code block) is left alone.
  • Arguments are quoted strings, "…" or “…” (a curly opener must be closed by a curly closer).
  • @example also takes a bare JSON literal@example({"type":"select","id":42}) or @example([…]) — scanned to its balanced closer (a } inside a JSON string does not end it) and captured verbatim. It must parse as JSON; a malformed literal is a build error, never a silently dropped example. Both spellings produce the same value, so @example("{\"type\":\"inc\"}") and @example({"type":"inc"}) are interchangeable — use the JSON form for payload examples and the quoted form for everything else (prose, a send(…) snippet). The JSON form is @example-only: the other tags take a predicate (@routeGated, @validates) or prose (@intent, @warning, @should, @emits), which a JSON literal cannot be, so a brace after any of them is a build error.
  • Escape an embedded quote as \" — it round-trips intact, so a predicate can say @validates("v === \"admin\""). \\ is a literal backslash. Every other backslash sequence is preserved verbatim, so a regex predicate (@validates("/^\d{5}$/.test(v)")) means what it says. Single quotes inside a double-quoted string need no escaping at all.
  • A string may wrap across JSDoc lines. The continuation's * decoration collapses to a single space.

The two predicate tags get a second check: @routeGated's first argument and @validates's argument must parse as JavaScript, exactly as the runtime wraps them (new Function('state' | 'v', 'return (' + src + ')')). @validates("") and an unbalanced paren are well-quoted but not predicates. (@routeGated's optional second argument is prose and is never compiled.)

Anything outside this grammar — or a predicate that does not parse — is a build error (agent-annotation-syntax), not a best-effort read. That matters most for the two predicate tags: a half-read @routeGated compiles to nothing at the agent boundary and degrades to an always-open gate, and a half-read @validates degrades to accept everything — silently, because the boundary contains its own compile failures. The compiler refuses to guess, and the build tells you instead.

CSP note. The predicate annotations that gate affordance visibility — @routeGated("expr", "reason") and field-level @validates("expr") — are compiled to functions with new Function(...) at runtime (they evaluate the expression against live state). This is your own authored source, never agent-supplied input, so it is not an injection vector; but new Function is eval-shaped and a strict Content-Security-Policy without 'unsafe-eval' in script-src will block it. If you ship a strict CSP, avoid these two annotations (use agentAffordances(state) to compute affordability in plain code instead) or relax the policy for the app bundle.

For the full grammar, compiler passes, and tool surface, see the @llui/agent API reference and the @llui/compiler reference. The authoritative definition of the wire protocol, token format, and threat model is the source itself — packages/agent/src/protocol.ts (LAP types + frames) and packages/agent/src/server/ (token lifecycle, pairing, audit).

Tokens

LLui agent tokens are opaque random bearer tokens — agt_ plus 43 base64url characters. They carry 32 bytes of CSPRNG entropy and are stored server-side as SHA-256 hashes only, so the wire form never matches what's in the token store. Tokens are scoped to a single browser session and can be revoked from the connect panel at any time.