@llui/agent
v0.0.55
Published
LLui Agent — LAP server + browser client runtime for driving LLui apps from LLM clients
Readme
@llui/agent
Server and browser-client libraries for the LLui Agent Protocol (LAP).
What this buys you
Your app's users can install the llui-agent bridge into Claude Desktop once, paste a token you mint for them, and drive your LLui app from Claude. Same Msgs and State you're already using — Claude dispatches like a remote user.
Install
pnpm add @llui/agent @llui/effects ws
pnpm add -D @llui/vite-plugin # if not already presentEnable agent-metadata emission in 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()
// The router is Web-standards; adapt it:
app.use('/agent', async (req, res) => {
const webReq = expressToWebRequest(req) // adapter
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)Client
// @doc-skip — illustration uses `...` placeholders for handlers
import { mountApp } from '@llui/dom'
import { createAgentClient, agentConnect, agentConfirm, agentLog } 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()
// Chain client.effectHandler into your onEffect:
const onEffect = handleEffects<MyEffect | AgentEffect>()
.when('http', ...)
.else(client.effectHandler)App-side annotations
// @doc-skip — illustration uses `...` placeholders for init/update/view
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' }
export const App = component<State, Msg, Effect>({
name: 'App',
init: ...,
update: ...,
view: ...,
agentAffordances: (state) => [
{ type: 'nav', to: 'reports' },
...(state.user ? [{ type: 'signOut' }] : []),
],
agentDocs: {
purpose: 'Kanban for a 3-person design team.',
overview: 'Columns: To do / Doing / Done. Cards carry owner, due date, tags.',
cautions: ['Moving to Done locks edits — reopen first.'],
},
agentContext: (state) => ({
summary: `Viewing board "${state.boardName}", ${state.cards.length} cards visible.`,
hints: state.selectedCard
? ['Card focused; enter advances status.']
: ['Tab to list, arrow to select.'],
}),
})Annotations reference
| Tag | Semantics |
| ------------------- | -------------------------------------------------------------------- |
| @intent("...") | Human-readable label for Claude + confirmation UI + log |
| @alwaysAffordable | Surfaces to Claude even when no live UI binding is currently mounted |
| @agentOnly | No human UI binding exists; agent is the sole dispatcher |
| @requiresConfirm | Claude must propose; user approves before dispatch |
| @humanOnly | Claude cannot dispatch; not in list_actions |
Default (no tag). A 'shared' variant — both audiences can dispatch — is offered to the agent only when a live UI binding maps to it (e.g. an onClick is currently mounted). When the user navigates away and the corresponding subtree unmounts, the variant disappears from list_actions. This mirrors what the human can click. Tag @alwaysAffordable (or list the Msg from agentAffordances(state)) to keep an agent-driven Msg available regardless of UI state.
App state shape (host integration)
Wire your root state and Msg to include agent sub-slices:
type State = {
// ...your app state...
agent: {
connect: agentConnect.State
confirm: agentConfirm.State
log: agentLog.State
}
}
type Msg =
// ...your app msgs...
| { type: 'agent'; sub: 'connect'; msg: agentConnect.Msg }
| { type: 'agent'; sub: 'confirm'; msg: agentConfirm.Msg }
| { type: 'agent'; sub: 'log'; msg: agentLog.Msg }Delegate in update:
update: (state, msg) => {
if (msg.type === 'agent') {
if (msg.sub === 'connect') {
const [connect, effects] = agentConnect.update(state.agent.connect, msg.msg)
return [{ ...state, agent: { ...state.agent, connect } }, effects]
}
if (msg.sub === 'confirm') {
const [confirm, effects] = agentConfirm.update(state.agent.confirm, msg.msg)
return [{ ...state, agent: { ...state.agent, confirm } }, effects]
}
if (msg.sub === 'log') {
const [log, effects] = agentLog.update(state.agent.log, msg.msg)
return [{ ...state, agent: { ...state.agent, log } }, effects]
}
}
// ...your app logic...
}View wiring
Render agentConnect, agentConfirm, and agentLog anywhere in your view tree:
view: ({ send, branch, show }) => {
const connectParts = agentConnect.connect(
(s) => s.agent.connect,
(m) => send({ type: 'agent', sub: 'connect', msg: m }),
)
// `mintUrl` is optional — the agent effect handler derives it from
// `EffectHandlerHost.agentBasePath` (default `/agent`). Pass an
// explicit `{ mintUrl }` only when minting from a non-default path.
const confirmParts = agentConfirm.connect(
(s) => s.agent.confirm,
(m) => send({ type: 'agent', sub: 'confirm', msg: m }),
)
return [
// Renders the "Connect with Claude" button + token copy box + session list:
div(connectParts.root, [
button(connectParts.mintTrigger, ['Connect with Claude']),
...show({
when: (s) => s.agent.connect.pendingToken !== null,
render: () => [
pre(connectParts.pendingTokenBox),
button(connectParts.copyConnectSnippetButton, ['Copy']),
],
}),
]),
// Renders pending confirmation cards:
div(confirmParts.root),
]
}Entry points
@llui/agent/protocol— all LAP types, WS frame types, token types, audit types.@llui/agent/server—createLluiAgentServer,InMemoryTokenStore,consoleAuditSink, interfaces.@llui/agent/client—createAgentClient,agentConnect,agentConfirm,agentLog,AgentEffect.
See the Agent Protocol doc for the full wire protocol and security model.
Custom serialization (codecs)
JSON natively supports string | number | boolean | null | array | object. Component messages and state often carry values that don't round-trip through JSON: Date, Blob, File, Map, Set, BigInt. The agent ships a codec convention that lets these values cross the LAP boundary cleanly without forcing every component to invent its own envelope.
Wire format. A non-JSON-safe runtime value travels as a tagged object:
{ "__codec": "iso-date", "wire": "2026-04-25T12:00:00.000Z" }The runtime walks every value crossing the LAP boundary symmetrically:
- Outgoing (
stateAftersnapshots, action arguments): the encoder looks up a codec whosematchesRuntimeclaims the value and replaces it with the tagged shape. - Incoming (
msgpayloads dispatched by the agent): the decoder detects the tagged shape and substitutes the runtime value beforeupdate()runs.
Reducers never see the tagged form. By the time update() is called, real Date / Blob / etc. are in place.
Default codecs. makeDefaultCodecs() ships with:
| Codec name | Encodes | Wire form |
| -------------- | ------- | ------------------ |
| iso-date | Date | ISO 8601 string |
| epoch-millis | Date | epoch milliseconds |
By default iso-date claims Date values. epoch-millis is registered but its matchesRuntime returns false so it doesn't shadow iso-date on encode — it's still available for explicit decode and for consumers who register a millis-first registry.
Authoring. Tag the variant's JSDoc with both @intent and @codec("<name>"):
type DateInputMsg = {
/**
* @intent("Set the parsed date directly")
* @codec("iso-date")
*/
type: 'setValue'
value: Date | null
}The @codec tag is documentation for human readers and the (eventual) schema generator. The runtime encode/decode is registry-driven and doesn't need per-field metadata at runtime.
Custom codecs. Pass a registry to createAgentClient:
// @doc-skip — illustration uses placeholder encode/decode bodies
import { CodecRegistry, isoDateCodec } from '@llui/agent/codecs'
const codecs = new CodecRegistry()
codecs.register(isoDateCodec)
codecs.register({
name: 'base64-blob',
matchesRuntime: (v) => v instanceof Blob,
encode: async (b) => ({ name: b.name, type: b.type, base64: '...' }),
decode: (wire) => new Blob([], { type: wire.type }),
})
createAgentClient({ ..., codecs })File/Blob codecs are not in the default registry — handling is environment-specific (browser File API vs. Node Buffer vs. workers) and the encoded form is large enough that consumers should opt in deliberately.
Cloudflare-vite dual paths (dev only)
When a project ships @cloudflare/vite-plugin, that plugin proxies every
non-/cdn-cgi/* request to the worker — which shadows the canonical
/agent/* LAP routes. To keep agent flows working in dev, @llui/vite-plugin
registers the LAP middleware at both /agent/* and /cdn-cgi/agent/*,
and the client opts in by passing agentBasePath: '/cdn-cgi/agent'.
This dual-path lives in the dev middleware only. Production deployments
serve LAP routes from your own server (Express, Hono, the Cloudflare Worker
in @llui/agent/server/cloudflare, etc.); whichever base path you mount
there is what the client should target. The /cdn-cgi/* shim does not
exist in production and you do not need it — pick one canonical path
(/agent or whatever you mount), point agentBasePath at it, and you're
done.
