# @llui/agent

Server and browser-client libraries for the LLui Agent Protocol (LAP). The authoritative wire-protocol definition lives in [`./src/protocol.ts`](./src/protocol.ts); see also the [Agents doc](https://llui.dev/agents).

## 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

```bash
pnpm add @llui/agent @llui/effects ws
pnpm add -D @llui/vite-plugin  # if not already present
```

Enable agent-metadata emission in `vite.config.ts`:

```ts
import llui from '@llui/vite-plugin'
export default { plugins: [llui({ agent: true })] }
```

## Server

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

const agent = createLluiAgentServer({
  // `identityResolver` receives a Web `Request`, not an express one — read the
  // cookie off the header (or use `defaultIdentityResolver` for the signed
  // -cookie flow this package ships).
  identityResolver: async (req) => parseUserIdCookie(req.headers.get('cookie')),
})

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

```ts
// @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

```ts
// @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:

```ts
import { agentConnect, agentConfirm, agentLog } from '@llui/agent/client'

type State = {
  // ...your app state...
  agent: {
    connect: agentConnect.AgentConnectState
    confirm: agentConfirm.AgentConfirmState
    log: agentLog.AgentLogState
  }
}

type Msg =
  // ...your app msgs...
  | { type: 'agent'; sub: 'connect'; msg: agentConnect.AgentConnectMsg }
  | { type: 'agent'; sub: 'confirm'; msg: agentConfirm.AgentConfirmMsg }
  | { type: 'agent'; sub: 'log'; msg: agentLog.AgentLogMsg }
```

Delegate in `update`:

```ts
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:

`show` (and `branch`) are module imports from `@llui/dom`, not view-bag members. The
view bag is `{ state, send }`, where `state` is a `Signal<State>` — narrow to the
agent sub-slices with `state.at(...)` and pass those signal handles to `connect`:

```ts
import { div, button, pre, show } from '@llui/dom'

view: ({ state, send }) => {
  const connectParts = agentConnect.connect(state.at('agent').at('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(state.at('agent').at('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(
        state.map((s) => s.agent.connect.pendingToken !== null),
        () => [
          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 [Agents doc](https://llui.dev/agents) 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:

```json
{ "__codec": "iso-date", "wire": "2026-04-25T12:00:00.000Z" }
```

The runtime walks every value crossing the LAP boundary symmetrically:

- **Outgoing** (`stateAfter` snapshots, action arguments): the encoder looks up a codec whose `matchesRuntime` claims the value and replaces it with the tagged shape.
- **Incoming** (`msg` payloads dispatched by the agent): the decoder detects the tagged shape and substitutes the runtime value before `update()` 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>")`:

```ts
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`:

```ts
// @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.

<!-- @doc-setup
// Reader-supplied adapters the server snippets name in prose. One declaration
// per group. Not rendered; read by `pnpm check:docs`.

declare function expressToWebRequest(req: unknown): Request

declare function parseUserIdCookie(header: string | null): Promise<string | null>
-->
