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:
- Pin
@latestin the MCP config so the cache key changes. In Claude Desktop, edit theargsto["-y", "llui-agent@latest"]. In Claude Code, run:
Alternative: leave the spec alone and clear the cache once withclaude mcp remove llui claude mcp add --transport stdio llui -- npx -y llui-agent@latestrm -rf ~/.npm/_npx. Same effect, fewer config edits — but you'll need the same poke at the next breaking release. - Restart the MCP client. The tool list is fixed at session start, so quit + reopen Claude Desktop, or start a new Claude Code session.
- 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 add — llui 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
- 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.
- You paste into Claude. Claude reads the snippet, calls
connect_session, and the bridge records{url, token}for this chat. - The bridge calls
POST {url}/describeto validate and cache the app's schema (Msg union, intents, annotations). - Subsequent tool calls (
get_state,send_message, etc.) forward to{url}/<path>with your token as a Bearer. - Sensitive actions marked
@requiresConfirmin 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. SetrateLimiteron the server options to change the rate. -
An
Authorization: Bearerheader, if present, must be a valid LLui agent token or the request is rejected401having 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 untilconnect_sessionbinds a token.A revoked token buys nothing here either. On the bundled
InMemoryTokenStorethat 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 refused401like any other unknown token. The status is now also checked on the record itself, which matters for a customTokenStore— 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 deadtidbuy admission outside the anonymous quota that an anonymous caller can never reclaim. A record read back asrevokedis refused403 revoked, the same answer the LAP gate and the WebSocket admission give. Do not branch on403at this gate for the bundled store — it answers401there.
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 asunauthenticatedMaxLifetimeMs, so an arbitrary ID is still a plain404and 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 refused429,401,403or503on 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_sessionruns again. It also inherits the originalcreatedAt, so resurrection cannot renewunauthenticatedMaxLifetimeMs. Note the deliberate asymmetry withinitialize: 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/403on the same terms if not. A refused resurrect is no longer reported to the loser as a404: 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), a404here means the server does not remember this ID. - An explicit
DELETEis 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. ADELETEis the opposite: the client asked for the session to be destroyed, so its ID is forgotten rather than remembered, andterminateSession()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 (@examplefollowed by a code block) is left alone. - Arguments are quoted strings,
"…"or“…”(a curly opener must be closed by a curly closer). @examplealso 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, asend(…)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.