# Building a Persistent Web-App Agent with Cumulus

How to embed a persistent AI agent into any web app using the cumulus gateway — an agent that can **drive the app's UI**, **answer questions about the app and its data**, and **remember every conversation per visitor**, with no fork, no second gateway, and no per-app backend beyond a static file server.

**Start here:** [`examples/web-app-agent/`](../examples/web-app-agent/) is a complete, runnable version of everything below — a small app with a working agent, ~700 lines you can copy. Run that first, then use this document to understand what each piece is doing and why.

Two implementations run live on this recipe: **Kalendeer** (`kalendeer.soapko.com`), which the starter kit is extracted from, and **Pursuit** (`pursuit.soapko.com`), the original. Where they differ, prefer Kalendeer: it delivers the scoped key through a session-gated endpoint rather than injecting it into public HTML (§3.1), and it mints 16-hex device ids rather than 8 (§4.1). Both differences are security-relevant, not stylistic.

> **Versions:** requires cumulus gateway `>= 0.31.41` (bridge, namespaces, capability-by-name enforcement, config prefix-fallback all landed in the 0.31.38–0.31.41 series, tasks 097/098).

---

## 1. Architecture at a glance

```
 Browser tab (your app)                        Thundercat gateway (cumulus)
┌───────────────────────────────┐             ┌──────────────────────────────────┐
│ your app UI                   │             │  one thread per visitor:         │
│  ├─ command registry          │   wss       │   myapp-<deviceId>               │
│  │   (window.MyAppAgent)      │  /bridge    │   ├─ full history + RAG          │
│  ├─ BridgeClient  ────────────┼────────────▶│   ├─ per-thread config           │
│  ├─ agent panel (chat UI)     │   https     │   │   (inherited from            │
│  │   POST /api/thread/…  ─────┼────────────▶│   │    myapp.config.json)        │
│  └─ selection / right-click   │   SSE       │   └─ Claude subprocess per turn  │
│     feedback capture          │             │        └─ MCP shim ──────────────┼──┐
└───────────────────────────────┘             └──────────────────────────────────┘  │
                                                    ▲                               │
                                              POST /bridge/call ◀───────────────────┘
                                              (agent tool call → executes in the tab)
```

Three moving parts:

1. **The gateway** (already running) — owns conversation history, RAG retrieval, prompt assembly, and spawns Claude per turn. One config block per app.
2. **The app's front end** — registers a typed **command registry** (what the agent can see and do in the UI), mounts the **bridge client** (a WebSocket back to the gateway), and renders the **agent panel** (chat UI + feedback capture).
3. **An MCP shim** — a ~120-line stdio script the gateway spawns per turn. It fetches the app's command manifest and exposes each command as a tool the model can call; calls are forwarded to `POST /bridge/call`, which dispatches into the live browser tab.

The loop that makes the agent "drive the app": the model calls a tool → shim → `POST /bridge/call` → gateway pushes `call` over the tab's WebSocket → the tab executes it against the registry (through the app's own actions, so guards/routing/notifications all still work) → result flows back to the model.

### Key concepts

| Concept                            | What it is                                                                                                                                                    |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Namespace**                      | A config block (`namespaces[]`) that groups an app's threads (`myapp-*`), scopes its API key, and carries its per-app settings (proxy, MCP servers).          |
| **Base thread** (`myapp`)          | _Your_ management thread for the app — visible only to your admin key. Never used by visitors. Also the config template for visitor threads.                  |
| **Visitor threads** (`myapp-<id>`) | One per browser/device, minted client-side. Full persistent history + RAG each. Hidden from the default thread list.                                          |
| **Bridge**                         | The gateway↔tab WebSocket: tab registers its command manifest; gateway dispatches agent tool calls into the tab.                                              |
| **Command registry**               | The app-side catalog of typed commands (`{ name, description, params, risk, execute }`) — the single capability surface for both the UI and the agent.        |
| **Capability-by-name**             | The security model: a scoped key can only touch threads in its namespace, can enumerate nothing, and the random thread name itself is the per-visitor secret. |

---

## 2. Gateway setup (one config edit)

All gateway-side setup is a single edit to `~/.cumulus/gateway.config.json`; the gateway admin then reloads the gateway (if needed) to pick it up. No code changes, no publish.

> The snippets below are annotated for readability — the real config file is **strict JSON**: strip the comments and trailing commas when copying.

### 2.1 Enable the bridge (once, global)

```jsonc
{
  "bridge": { "enabled": true },
}
```

Default is off; with it off every bridge surface is inert.

### 2.2 Add the app's namespace

```jsonc
{
  "namespaces": [
    {
      "name": "myapp", // covers threads matching myapp-*
      "label": "My App",
      "apiKeys": ["sk-myapp-<random>"], // the app's OWN key — mint a fresh one

      // OPTIONAL: reverse-proxy selected paths to the app's backend through
      // the gateway origin (so the front end needs only one origin).
      "executorProxy": {
        "origin": "http://127.0.0.1:8097",
        "pathPrefixes": ["/state", "/journal"],
      },

      // OPTIONAL but required for "drive the app": the MCP shim that turns
      // the app's command manifest into model-callable tools. Spawned per
      // turn, ONLY for threads in this namespace. {thread} is substituted
      // with the actual thread name (myapp-<deviceId>) in args AND env.
      "extraMcpServers": {
        "myapp-tools": {
          "command": "node",
          "args": ["/path/to/myapp/mcp-shim.js"],
          "env": {
            // 8080 is the fresh-install default — use your gateway's actual
            // top-level "port" here (jq .port ~/.cumulus/gateway.config.json).
            "GATEWAY_ORIGIN": "http://127.0.0.1:8080",
            "GATEWAY_API_KEY": "sk-myapp-<same-scoped-key>",
            "BRIDGE_THREAD": "{thread}",
          },
        },
      },
    },
  ],
}
```

Namespace semantics (locked in task 097):

- `myapp` (the bare name) is **not** in the namespace — it's your management thread, owned by your admin key. The namespace covers `myapp-*` only.
- Longest prefix wins, so `myapp-demo` can be its own nested namespace under `myapp` later.
- The scoped key is **confined**: it can read/write only `myapp-*` threads, gets an empty list from every enumeration surface (`/api/threads`, `/api/agents`, dashboard), and is rejected (403) everywhere else.

### 2.3 Create the thread configs — two of them, and the second is the one people miss

Config is resolved by **prefix-fallback** (task 098): a turn strips one trailing `-segment` at a time and takes the longest match. Writes are always exact, so a visitor session can never mutate a config it inherited.

Your app has **two** thread configs, because it has two kinds of thread:

| File                  | Applies to                              | Typical shape             |
| --------------------- | --------------------------------------- | ------------------------- |
| `myapp.config.json`   | your own management thread, `myapp`     | strong model, high effort |
| `myapp-v.config.json` | **every visitor**, `myapp-v-<deviceId>` | small fast model          |

The `-v` layer is not decoration — it is the seam that lets those two differ. The server hands the browser `THREAD_ID = "myapp-v"` and `device-thread.js` appends the device id, so resolution for `myapp-v-a3f8c2d1` goes:

```
myapp-v-a3f8c2d1.config.json   (none — visitors never get their own)
myapp-v.config.json            <- every visitor turn
myapp.config.json              (only if the -v file is absent)
```

**Skip the `-v` file and every anonymous visitor runs your management thread's model.** That is the single most expensive omission in this guide, and it is invisible in development: with one tester the bill looks fine.

`~/.cumulus/threads/myapp.config.json`:

```json
{
  "projectDir": "/home/you/projects/myapp",
  "model": "claude",
  "effort": "high",
  "alwaysInclude": ["docs/myapp-system-prompt.md"]
}
```

`~/.cumulus/threads/myapp-v.config.json`:

```json
{
  "projectDir": "/home/you/projects/myapp",
  "model": "claude",
  "claudeModel": "claude-haiku-4-5",
  "effort": "medium",
  "alwaysInclude": ["docs/myapp-system-prompt.md"],
  "allowedTools": ["read_file", "search_content", "retrieve_content", "search_history"],
  "disallowedTools": ["AskUserQuestion"]
}
```

- `projectDir` — the working directory for the agent's turns (where `alwaysInclude` paths resolve).
- `alwaysInclude` — the app's **system prompt document**: what the app is, how to talk to its users, when to use which commands, tone. This is where the agent's product knowledge and persona live. Usually the same document for both threads.
- `model` / `effort` / `claudeModel` — the per-thread quality, latency and cost dial. `claudeModel` pins the specific Claude model; leave it out to follow the gateway default.
- `allowedTools` — **the only tools a visitor turn may use.** See below; this is the most important line in the visitor file.
- `disallowedTools` — applied on top of the allowlist, and it can only subtract. Keep `AskUserQuestion` here: there is no operator on the other end of a visitor turn, so a question hangs.

#### `read_file` yes, `Read` no — they are not interchangeable

The list above admits `read_file` and leaves out the built-in `Read`, and the omission is
load-bearing. `Read` is the Claude CLI's own tool; cumulus has no root hook into it, so
allowing it means a visitor can ask for **any file the gateway user can read** — including
`~/.cumulus/gateway.config.json`, which holds your admin API key and every provider
credential. `read_file` is cumulus's own tool, and while `Read` is denied it is **confined
to that thread's `projectDir`**.

Denying `Read` is what switches the confinement on, so the two lines work as a pair. If you
add `Read` back you give up the confinement entirely and the allowlist no longer bounds the
filesystem at all; widen `projectDir` instead.

#### `allowedTools` is deny-by-default, and that is the whole point

Omit it and the thread is unrestricted — it gets the same harness as your maintainer
thread, including shell, file writes, sub-agent spawning, and the ability to message your
other threads. Set it and **everything not named is refused, including tools a future
cumulus or Claude CLI release adds.**

That last clause is why an allowlist rather than a denylist. With a denylist, every newly
shipped tool is silently granted to every visitor of every deployed app until each author
notices and edits their list. One real app on this gateway needed **47 deny entries** to
reach a safe surface; the allowlist above is five.

Your app's **own MCP shim tools are not affected** — cumulus does not know their names, so
it cannot deny them. They stay available, which is what you want: they are the tools you
deliberately gave this namespace. The allowlist governs cumulus's and the CLI's tools.

Entries may be bare (`read_file`) or fully qualified (`mcp__cumulus-history__read_file`).
An entry that matches nothing is **inert, and inertness denies** — a typo fails closed, so
check the journal after a deploy rather than assuming silence means success.

Setting `allowedTools` also, deliberately, shrinks the system prompt: sections describing
tools the thread cannot reach (background work, scheduling, inter-agent messaging) are
dropped from that thread's prompt. Dead instructions are worse than absent ones — they cost
tokens every turn and invite the model to improvise a substitute for a tool it cannot call.

Both files ship as editable examples in the kit (`thread-config.example.json`, `thread-config.visitor.example.json`), with a one-command applier:

```bash
GATEWAY_ORIGIN=https://gw.example.com GATEWAY_ADMIN_KEY=sk-... \
  node agent/apply-thread-configs.mjs --namespace myapp
```

Use the **admin** key: a namespace covers `myapp-*`, so the app's scoped key can write `myapp-v` but is refused (403) on the bare `myapp` base thread. Note that the config API applies a whitelist — `projectDir`, `template`, `model`, `effort`, `claudeModel`, `contextLimit` — so `alwaysInclude`, `allowedTools` and `disallowedTools` must be added to the file on the gateway host. That is deliberate in each case: `alwaysInclude` plus `projectDir` would let a scoped key read an arbitrary file into its own prompt, and `allowedTools` is writable in the _widening_ direction, so a scoped key could relax its own restrictions. The applier reads each config back and names anything that did not stick, so the gap is visible rather than silent.

No gateway reload is needed — thread config is read per turn.

### 2.4 Verify

Verify with the scoped key:

```bash
# Confined: in-namespace works, everything else 403s, lists come back empty
curl -s -H "X-API-Key: sk-myapp-..." https://gw.example.com/api/thread/myapp-smoketest/history   # 200
curl -s -H "X-API-Key: sk-myapp-..." https://gw.example.com/api/thread/someother/history         # 403
curl -s -H "X-API-Key: sk-myapp-..." https://gw.example.com/api/threads                          # {"threads":[]}
```

### 2.5 The license key (this is the part that gates production)

Everything in this guide runs unlicensed — that is deliberate, so you can build
and evaluate the whole integration before anyone signs anything. What an
unlicensed gateway will not do is **carry a visitor population**: each configured
namespace may hold at most **5 distinct threads**. Minting the 6th is refused with
`402` and a contact address; existing threads keep working, and threads outside a
namespace (your own, the CLI, the TUI) are never limited.

Since every visitor gets their own thread (§4.1), that cap is invisible during
development and hits on roughly your sixth real user. Budget for it before launch,
not after.

```json
{ "licenseKey": "cumulus-lic-v1...." }
```

Add it to `gateway.config.json` and reload. Keys are verified offline — nothing is
transmitted, and an air-gapped gateway works fine — and are issued per whole-number
release, so a `1.x` key covers every `1.x.y`. The gateway logs its licence state at
startup and hourly, and the admin UI shows it under **Settings → License**.

Commercial licensing: **ops@luckydrawdesign.com**. See [`LICENSE`](../LICENSE).

---

## 3. The app backend: serving layer + shim

### 3.1 Serve the agent config from a session-gated endpoint

The front end is configured by a single `window.__AGENT_CONFIG__` object. Never hardcode gateway details or keys into the app bundle — and prefer handing them out **only to an authenticated session**, as Kalendeer does (`examples/web-app-agent/server.js`):

```js
if (req.method === 'GET' && p === '/api/agent-config') {
  if (!sessionOf(req)) return json(res, 401, { error: 'not signed in' });
  if (!API_KEY) return json(res, 404, { error: 'agent not configured' });
  return json(res, 200, {
    GATEWAY_URL: GATEWAY_ORIGIN,
    BRIDGE_URL: GATEWAY_ORIGIN.replace(/^http/, 'ws') + '/bridge',
    THREAD_ID: 'myapp-v', // BASE name — the browser appends the per-device suffix
    API_KEY, // namespace-scoped, from env; never in the repo
  });
}
```

Give `GATEWAY_ORIGIN` **no default**, and refuse to start when a key is configured without it. Whatever is listening on the usual gateway port is usually a real gateway, so an app that defaults plus an operator who mistypes the variable is an app quietly pointed at a live one — a failure that looks like success. The kit's `server.js` also exits on near-miss names it does _not_ read (`AGENT_API_KEY`, `GATEWAY_URL`, `DEMO_PASSWORD`, …), naming the one it does.

The front end then calls `AgentStart()` once the config arrives, and `AgentStop()` on logout.

Notes:

- **Yes, the scoped key reaches the browser.** That is the design: it is worthless outside `myapp-*`, can enumerate nothing, and each visitor's thread name is its own secret. Ship only the namespace-scoped key — never an admin key.
- **Enumeration fails as `200` + empty, not `403`.** `/api/threads` and `/api/agents` answer `200 {"threads":[]}` to a scoped key even when the namespace holds hundreds of threads — a scoped caller already knows the one name it needs, and listing siblings would hand out every other visitor's capability. `403` is reserved for reaching _outside_ the namespace. Worth knowing if your client branches on status.
- **Gate it on a session anyway.** Pursuit takes the older route and injects the key into `<head>` at serve time, which puts it in public HTML, in view-source, and in any intermediary cache. It works, and the blast radius is bounded by the namespace — but a session-gated endpoint is strictly better and costs one route. Use it for new apps.
- In production, pass the key via a systemd drop-in (`Environment=MYAPP_API_KEY=...`), not a file in the repo.
- `THREAD_ID` is the **base name**; the browser appends the per-device suffix (§4.1). Minting the suffix client-side keeps the full thread name — the actual capability — from ever travelling server → client.
- When the endpoint 404s (no key configured), every agent module no-ops cleanly and the app runs with its agent features dark. Keep that path working; it's what local dev uses.

### 3.2 The MCP shim (~120 lines, copy Pursuit's)

The shim is a stdio MCP server the gateway spawns per turn. Pursuit's (`server/executor/src/mcp-shim.js`) is app-agnostic apart from its env defaults — the whole job:

1. **`tools/list`** → fetch the manifest and return it as MCP tool definitions.
   - Source: `GET {GATEWAY_ORIGIN}/bridge/manifest/{BRIDGE_THREAD}` (the gateway serves the tab's **live** registration, or the **persisted last-known** manifest when no tab is connected).
   - Optionally merge a headless manifest from your own backend (Pursuit merges its executor's `/manifest` first, headless-wins, so grounded reads work with no tab open).
   - MCP tool names can't contain dots: expose `search.query` as `search_query`, map back on call.
   - Prefix each description with the risk tier: `"[read] Run a filter query…"`.
2. **`tools/call`** → `POST {GATEWAY_ORIGIN}/bridge/call` with `{ thread, command, params }`, return the JSON result (`{ ok, summary, data?, affected? }`), setting `isError: !result.ok`.
   - If no tab is connected the gateway returns a graceful `{ ok:false, summary:'No active app session' }` — the model sees an honest failure, not a hang.

Because the manifest is fetched fresh every `tools/list`, **new front-end commands surface to the agent with zero backend changes** — ship a new registry entry in the app and the model can call it on the next turn.

### 3.3 Optional: executor proxy

If the app has its own backend the agent panel needs to reach (state reads, journals), list its path prefixes under `executorProxy` (§2.2) and the gateway will forward them — the browser only ever talks to one origin. Longest-prefix, segment-boundary matching; upstream status/content-type relayed; 502 when unreachable.

---

## 4. Front-end integration

Pursuit's agent layer is ~10 small files under `agent/`. Load order matters — classic scripts first (registry, identity, dock), ES modules after (bridge mount), React panel last:

```html
<!-- 1. classic scripts, in order -->
<script src="agent/device-thread.js"></script>
<!-- BEFORE any __AGENT_CONFIG__ consumer -->
<script src="agent/commands.js"></script>
<!-- registry: window.MyAppAgent -->
<script src="agent/dock.js"></script>
<script src="agent/chat-client.js"></script>
<script src="agent/selection.js"></script>
<!-- 2. ES module (deferred — always runs after classic scripts) -->
<script type="module" src="agent/bridge-mount.js"></script>
<!-- 3. the React panel mounts in its OWN root, sibling of the app's -->
```

### 4.1 Per-visitor thread identity (`device-thread.js`, 31 lines)

Rewrites the injected base `THREAD_ID` to a per-device name before anything else reads it:

```js
(function () {
  var cfg = window.__AGENT_CONFIG__;
  if (!cfg || !cfg.THREAD_ID) return; // local dev — no-op

  var KEY = 'myapp.deviceId';
  var id = null;
  try {
    id = localStorage.getItem(KEY);
  } catch (e) {}
  if (!id || !/^[0-9a-f]{8,}$/.test(id)) {
    // accept legacy widths
    var bytes = new Uint8Array(8); // 16 hex chars for new devices
    crypto.getRandomValues(bytes);
    id = Array.prototype.map
      .call(bytes, function (b) {
        return ('0' + b.toString(16)).slice(-2);
      })
      .join('');
    try {
      localStorage.setItem(KEY, id);
    } catch (e) {}
  }
  cfg.THREAD_ID = cfg.THREAD_ID + '-' + id; // myapp-<deviceId>
})();
```

- The thread name doubles as the visitor's capability (§6) — use **at least 16 hex chars (8 random bytes)** for new apps. (Pursuit predates this guidance with 8 hex; its validation regex is being loosened, not tightened, so legacy ids survive.)
- Every consumer (chat client, panel, bridge mount) reads `cfg.THREAD_ID` _after_ this runs — hence "first classic script."

### 4.2 The command registry (`commands.js`)

The single capability surface. The UI and the agent are two equal clients of it — commands go through the app's existing actions/engines only, so guards, router history, and notifications keep working when the agent drives.

> **Build the registry first, independent of the agent — this inversion is the load-bearing idea.** The registry is a plain global (`window.MyAppAgent`) that works with **no bridge, no gateway, and no network**; the bridge mount (§4.3) merely hands the client a two-member adapter (`{ manifest, execute }`) over it. There is no `registerTool` on the bridge client, and there should not be: an app whose capabilities live inside the agent transport can't be driven when the gateway is down, can't be unit-tested without it, and can't be reused by anything else. Write the registry as **your app's own public API**; the agent is simply its second client.

Frozen contracts:

```js
// command shape
{ name: 'search.query',
  description: 'Run a filter query headlessly… Does NOT change what the user sees — use search.show for that.',
  params: { /* JSON Schema */ },
  risk: 'read',                       // read | display | mutate | export
  execute(params) { return { summary, data, affected }; } }

// result shape (call() never throws — errors become { ok:false, summary })
{ ok: true, summary: '412 accounts match 2 filters.', data: {...}, affected: [...] }

// registry API (window.MyAppAgent)
MyAppAgent.register(def)   MyAppAgent.call(name, params)
MyAppAgent.list()          MyAppAgent.manifest()   // → [{ name, description, risk, input_schema }]
```

The gate is binary, and only one tier is on the gated side. **`export` never auto-runs** — the gateway forces a confirm round-trip (§5.3) regardless of caller flags. `read`, `display` and `mutate` all dispatch immediately; the tier is advisory, riding in the tool description the model sees (`[mutate] …`) so it can weigh the call, but nothing stops it. So the question when tiering a command is not "is this irreversible?" — it is **"must a human see this before it happens?"** If yes, it is `export`, whatever the verb is.

Two commands every app should register (the agent's eyes):

- **`app.describe`** _(read)_ — static knowledge: what each route/section is (with aliases), a glossary, and how-to recipes. This is how the agent answers "where do I…" / "what is…" questions accurately instead of guessing.
- **`app.describeView`** _(read)_ — what the user is looking at **right now**. Recomputed fresh on every call, never cached. This is the single most load-bearing command for the "answer questions about the app" half of the job, so its payload deserves real design. Pursuit's shape:

  ```js
  {
    route, page, entity,                  // where the user is
    filters: [ /* committed filters only — not half-typed ones */ ],
    activeSavedSearchId,
    selection:      { accounts: [key], contacts: [key], signals: [key] },
    selectionNamed: { accounts: [{ key, name }], … },  // ← see below
    detail,                               // open record slide-over, if any
    highlight,                            // what's spotlighted on screen
    worksheetId, agentId, instanceId, … , // page-scoped ids
    results: { count, visible: [{ key, name }] }   // only on list pages, top 20
  }
  ```

  Three design rules worth copying:
  - **Resolve ids to names** (`selectionNamed`) so the agent can say _"Maria Chen"_ instead of `A-001::mchen@…`. Raw keys alone make the agent sound like a database.
  - **Add page-conditional blocks** rather than one flat shape — Pursuit appends `inboxView` / `impactView` / `usageView` only on those routes, so the agent isn't blind on a screen the generic shape doesn't cover.
  - **Cap list payloads** (`visible` = first 20 with a true `count`) — the view snapshot rides along on every turn, so it must stay small.

Beyond those, register whatever the app can already do: `search.query` / `search.show`, `nav.goTo`, `records.get`, `worksheets.list`, mutations, exports. Write descriptions **for the model** — say when _not_ to use a command and where ids come from (Pursuit's descriptions are the benchmark: `"keys come from search.query / app.describeView rows"`).

### 4.3 Mounting the bridge (`bridge-mount.js`, ~70 lines)

The `BridgeClient` is **served by cumulus** (vendored at `src/gateway/bridge/client.ts`; Pursuit ships it as `agent/bridge-client/`) — mount it, don't modify it. It owns the socket lifecycle: registers `{ thread, apiKey, manifest }` on open, executes incoming `call`s against your registry, handles confirm round-trips, reconnects with exponential backoff (500ms → 15s).

```js
import { BridgeClient } from './bridge-client/client.js';

const cfg = window.__AGENT_CONFIG__;
const bridge = new BridgeClient({
  url: cfg.BRIDGE_URL, // wss://gw.example.com/bridge
  thread: cfg.THREAD_ID, // myapp-<deviceId>
  apiKey: cfg.API_KEY, // the scoped key
  registry: {
    manifest: MyAppAgent.manifest(),
    execute: (command, params) => Promise.resolve(MyAppAgent.call(command, params)),
  },
  // Recomputed on EVERY sendContext — never cached (contract).
  describeView: () => {
    const r = MyAppAgent.call('app.describeView');
    return r.ok ? r.data : { error: r.summary };
  },
  // Export-tier confirms surface as UI chips; without this hook they are
  // auto-DECLINED — export can never silently execute.
  onConfirmRequest: req =>
    window.dispatchEvent(new CustomEvent('myapp:agent-confirm', { detail: req })),
  onStateChange: s => console.info('[bridge] ' + s),
});
bridge.connect();
window.MyAppBridge = bridge; // panel calls sendContext() on each user turn
```

The gateway's registration handler checks that the presented key is entitled to that thread — a scoped key can only register tabs for its own namespace.

### 4.4 The chat client (`chat-client.js`, ~140 lines)

Plain `fetch` + SSE against the gateway's chat API — no library:

```
POST {GATEWAY_URL}/api/thread/{THREAD_ID}/message      body { message }, header X-API-Key
  → SSE stream:  token {text}  ·  segment {type,…}  ·  error  ·  done
GET  {GATEWAY_URL}/api/thread/{THREAD_ID}/history      → prior messages (reload survival)
```

Expose a tiny surface for the panel — `send(message, { onToken, onSegment, onError, onDone }) → { cancel() }` and `history()` — and keep a stub emitter behind it for offline dev.

### 4.5 The agent panel UX (`panel.jsx` + `panel.css` + `dock.js`)

The patterns Karl called out, as Pursuit implements them:

**Own React root, immortal across navigation.** The panel renders into `#agent-panel-root`, a _sibling_ of the app's root — route changes can never unmount it. Open state, position, size, collapse, draft text, and message list all survive navigation; geometry persists in `localStorage` (`ps-agent-panel-v1`) so it also survives reloads.

**Minimized home bar, pinned bottom-center.** The resting state is a slim input bar at the bottom of the page, width `min(560px, 100vw − 32px)`. Typing into it (or clicking) expands the panel.

**Expands upward from the bar.** The expanded window re-anchors bottom-centered, 16px off the floor, keeping the bar's width and the user's last height (capped at ~72% of viewport) — it reads as the bar _growing upward_, not a new window appearing.

**Floating window ergonomics.** Movable and resizable with two clamping modes: a loose clamp while dragging (you can park it near an edge but a grab-strip always stays on screen) and a strict re-fit on mount/viewport-resize (a rect saved on a big monitor can't start offscreen on a laptop — if it would be >half offscreen it resets to the default slot).

**Dockable into pages.** `dock.js` is a 60-line framework-agnostic registry: any page can mount an element and call `MyAppDock.register(id, el)`; the panel then portals its body into that slot instead of floating. One dock at a time; the user's explicit undock preference is remembered per slot id; leaving the page returns the panel to floating.

**In-message record chips.** Assistant markdown renders `[[entity:key|Label]]` refs as navigable chips that open the record **through the command registry** (`detail.open` / `worksheets.open` / `nav.goTo` per entity) — so answers link straight into the app and navigation still goes through the app's own action layer. Teach the convention in your system-prompt doc and the agent will emit refs unprompted.

Two parsing traps, both real (Pursuit's `markdown.jsx` handles them and a naive renderer will not):

- **Inside a GFM table cell the ref's pipe is escaped** — the agent emits `[[accounts:A-001\|Los Angeles USD]]` so the literal `|` doesn't split the cell. Tolerate the optional backslash and strip trailing escapes off the key, or you look up `"A-001\"` and navigation silently fails.
- **Split table cells on _unescaped_ pipes only**, for the same reason.

Inline parse order matters too: code → chip → link → bold → italic (`**` before `*`).

**Not a mobile design.** State plainly what this is: `panel.css` contains **zero `@media` queries**. Small screens are handled by the same viewport-fit clamping math as large ones (`min(560px, 100vw − 32px)`, height capped to a fraction of viewport), which keeps the panel usable but is not a designed mobile experience. If you need real mobile — full-screen sheet, keyboard-aware layout, touch drag — that is your work to add, not something inherited by copying these files.

**Streaming + activity.** The panel renders `token` events as streaming text and `segment` events (thinking / tool_use / tool_result) as activity indicators, so tool-heavy turns show liveness rather than a dead spinner.

**Fresh context on every turn.** On each user send, the panel calls `MyAppBridge.sendContext(window.__agentSelection)` — piggybacking a fresh `describeView` (and any pending selection context) onto the turn. Gateway-side this arrives as an ephemeral CONTEXT block in the prompt: the agent always knows what screen the user is on _right now_, without it being persisted as fake history.

### 4.6 Selection & right-click feedback (`selection.js`, ~300 lines)

Two entry points, one composer popup:

1. **Text selection** — anywhere (app page _or_ chat window). On pointer **release** (never mid-drag, and never stealing focus — the selection stays live and copyable), a small composer opens near the selection.
2. **Right-click on any element** — highlights the block (outline class), captures it as context, opens the same composer at the cursor. Target picking prefers a record-bearing element (`[data-agent-ref]`), then a semantic block (`td, li, p, h2, article, button…`), then the nearest reasonably-sized `div` — never the whole page.

The composer is a plain DOM popup (no framework): a textarea (`"Ask about this, or leave feedback…"`), a context hint (`<div> · 3 records`), and a split button — **Send** (submit to the agent now) or **Queue Feedback** (attach as a chip on the panel composer, comment becomes the draft). Enter sends; Escape closes.

What gets captured (the `context` object):

```js
{ text,                                   // the selected text, if any
  refs: [{ entity, key, field? }],        // parsed from data-agent-ref attributes
  element: { tag, id, classes, agentRef, testid, screen },   // right-click only
  view }                                  // fresh app.describeView snapshot
```

**No screenshot is captured** — worth stating, because "right-click for feedback" implies a screen grab to most readers. There is no canvas capture, no `toDataURL`, no image of any kind. The context is entirely textual: the element's `innerText` (truncated at 2,000 chars), the record refs parsed from `data-agent-ref`, a small element descriptor, and a fresh `app.describeView`. That is deliberate — it is cheaper, it is legible in the prompt, and it stays accurate because `describeView` is recomputed rather than pictured. It also means **the agent's understanding of "this thing here" is only as good as your `data-agent-ref` coverage and your `describeView` payload**, not something the browser supplies for free.

Delivery is decoupled via an event — `window.dispatchEvent(new CustomEvent('myapp:agent-compose', { detail: { mode, comment, context } }))` — the panel owns sending it to the gateway. The latest context also mirrors to `window.__agentSelection`, so a plain panel send carries it implicitly.

**The `data-agent-ref` convention** is what makes captured feedback _addressable_: any element rendering a record carries `data-agent-ref="accounts:ACME-01"` (optionally `/field:owner`). Ref collection walks ancestors and contained elements of the selection/target, so "this row is wrong" arrives at the agent with the exact records attached.

### 4.6b Rich blocks (`blex-mount.js`, ~110 lines)

The gateway instructs **every** thread to emit `~~~blex:TYPE` fences for tabular data, status boards, metrics, charts and diagrams. That rule is in the global includes and `mergeConfigs` is union-only, so a thread cannot opt out of it. The consequence is sharp and easy to miss: **a panel with no blex renderer shows the visitor raw JSON**, and it does so for exactly the content the model was told to present richly. Two independent halves of the system, each correct, never introduced.

Serve both halves — `blex.min.js` and `blex-render.js` — **from your own origin, resolved out of the installed cumulus package**, exactly as the kit already does for the bridge client (`server.js` → `/agent/blex/*`). Do not vendor them: `blex-render.js` is cumulus's own renderer, shared with the standalone chat widget, so a copy in your tree forks the seam contract and drifts the first time either side moves.

> **Do not `<script src="${GATEWAY_ORIGIN}/blex.min.js">`.** That recipe is correct only when your app is on a _different_ origin from the gateway. The common production shape is the opposite: `GATEWAY_ORIGIN` is your own hostname and an edge (Caddy, Cloudflare) routes just `/bridge*` and `/api/thread/*` through to the gateway. Your hostname has no `/blex.min.js`, so the load 404s and the panel degrades silently to plain text. Serving from your own origin is correct in **both** deployments, which is why the kit does it unconditionally.

**Diagrams need one extra tag: an import map.** `~~~blex:mermaid` is the one block type whose renderer loads a library at render time, and it does so with a **bare** module specifier (`await import("mermaid")`). A browser has exactly one mechanism for resolving a bare specifier — an import map — so without it the load fails and blex paints the literal string `Mermaid render error` where the fence used to be. Copy the tag from the kit's `index.html`:

```html
<script type="importmap">
  { "imports": { "mermaid": "/agent/blex/mermaid-esm.js" } }
</script>
```

The library it points at is the vendored mermaid bundle, served out of the installed cumulus package by the same `/agent/blex/*` route as the rest — not vendored into your tree, and not cross-origin. It is fetched only when a mermaid block actually renders (≈1MB gzipped), so pages without diagrams pay nothing.

If you leave the tag out, nothing breaks and you get no error box: `blex-render.js` checks whether the document declares the mapping and, if not, leaves the fence as readable text. That check is on the **document**, not on your adapter, so it protects a surface whose adapter allows everything. Diagram colours are derived from the blex card's own background (`--blex-bg`), so they follow your theme automatically; set `window.__CUMULUS_MERMAID_THEME` to a mermaid theme name only to override it.

**Correct origin cache headers are not a defence.** Measured in both directions on this project's own edge, and independently reproduced on a second one: the origin answers `/widget.js` with `Cache-Control: no-cache, must-revalidate` and the browser is handed `max-age=14400`. A CDN-class edge rewrites by **file extension**, regardless of what the origin said. So the `?v=` stamp is not belt-and-braces over your headers — behind such an edge it is the _only_ mechanism you have, and a reader who concludes "my origin already sends no-cache" will skip the one thing that would have worked. HTML is the exception (`no-cache`, `cf-cache-status: DYNAMIC`), which is exactly why an HTML-level stamp works at all — and why everything fetched _after_ the HTML is the hole.

Three cache traps — the first two measured at a real Cloudflare edge, and each one makes a _correct_ deploy look broken:

- **404s are cached too.** If you probe the route before it exists, the edge caches the 404 for its default TTL (measured: `max-age=14400` with `cf-cache-status: HIT`, overriding the origin's `no-cache`) — so a correct deploy keeps serving "no library" for four hours. This is nastier than stale content because it reads as "my route isn't registered", sending you to re-debug working code. After adding a route the edge has already seen 404, purge or cache-bust before concluding anything about the route.
- **An HTML stamp only reaches what HTML requests.** A `?v=` on a `<script src>` versions that file and nothing the file goes on to fetch by itself — so a loader that pulls its own dependencies at runtime has to propagate the version token, or the parent is versioned and its children are not. The kit's `server.js` stamps every `src`/`href` it can see in the served HTML, and — because `panel.css` and the two blex scripts are attached from _inside_ JavaScript — also fills a `window.__AGENT_ASSET_V` map that those loaders consult via `window.agentAsset(url)`. **Do not lift the token off `document.currentScript.src`.** It is the obvious shortcut and it has already shipped broken in a real adopter: that token describes _your_ build, but the library it stamps comes out of the _installed cumulus package_, so a cumulus upgrade changes the bytes while your build — and therefore the URL — stands still, and browsers keep the old library indefinitely. A server-published map gives each file its **own** hash, so the upgrade busts it even though the loader's own bytes didn't move. If you test this, assert that the library URL's token moves when the **package** file changes, not when your build does; a test that rebuilds the app passes against the broken version. (This is a general rule, not a blex problem: `blex.min.js` fetches nothing by URL. Measured on `@luckydraw/blex@0.1.16` — its only dynamic `import()` is the bare `"mermaid"` specifier above, `Chart.js v4.5.1` is inlined, and `blex-chart.min.js` is an opt-in companion global that nothing requests.)
- **Import-map values _are_ stampable; static `import` specifiers are not.** The mermaid module URL lives in JSON inside a `<script type="importmap">`, which the server rewrites along with every `src`/`href` — so that library is versioned like any other, and it needs no runtime token map (there is only one file, so there is no loader→dependency edge at all). Static specifiers are the case that stays uncovered: `bridge-mount.js` imports `client.js`, which imports `protocol.js`; both are fetched bare. They ship from the cumulus package and change only on upgrade, and the kit serves them `no-cache, must-revalidate` — which, per the paragraph above, a CDN-class edge will override anyway. Treat these as genuinely unstamped: excluding `/agent/` from your CDN is the fix, not a precaution.

(One caveat if you probe with `curl -I`: the gateway answers `HEAD` on static assets with `401` while `GET` returns `200` with `Access-Control-Allow-Origin: *`. Probe with `GET`; the asset is not auth-gated.)

Drive it with an adapter. The required trio is `allowType` / `getInput` / `getSendButton`; `addChip` and `persist` are optional, **and leaving them out is what makes a surface render-only** — there is no mode flag to set.

```js
window.CumulusBlexRender.renderOnlyAdapter({
  getInput: () => document.querySelector('[data-testid="agent-input"]'),
  getSendButton: () => document.querySelector('[data-testid="agent-send"]'),
});
```

`renderOnlyAdapter` denies `confirm`, `poll`, `form` and `diff`. The test the allow set is derived from is: _a type may render iff every affordance it draws either completes locally or is purely visual._ Selection in `table`/`code`/`file-tree`/`timeline`/`image` is a highlight, so a no-op is invisible. `terminal`/`svg` draw labelled buttons but the clipboard write and the object-URL download both run **before** the emit — the emit is a receipt, not the mechanism. `diff` fails the test: Apply/Reject have no local half, so on a render-only surface they are guaranteed dead buttons that read as "click to apply" — worse than not rendering, because a dead button looks alive in a way raw JSON does not.

Two implementation traps:

- **Extract fences before your code-block pass**, not after. Otherwise a fence someone is _showing_ inside a ``` block is eaten by the extractor and vanishes from the block that exists to display it.
- **Do not implement deny with `unregisterBlockType`.** The registry is module-global, so unregistering `confirm` removes it for every surface sharing that module instance. Deny is a per-render gate. (Nor can you route denials to a library fallback renderer — the global bundle exports no such thing; it exists only on the ESM surface. Leaving the container unclaimed shows the raw fence, which is the honest outcome and costs nothing.)

Nothing is lost by not rendering `confirm`: export-tier confirms arrive over the **bridge** as a native, audited chip (§4.3), never through message content.

### 4.7 Testability (house rules)

Per the global standards: every interactive element gets a `data-testid` (`agent-panel-*`, `agent-fb-pop|input|send|caret|queue`), the message list carries `data-loading`/`aria-busy` while streaming, and `window.__PUPPET_TEST_MODE__` sets `data-test-mode` to kill animations.

---

## 5. Runtime flows (what actually happens)

### 5.1 Answering a question

1. Visitor types into the panel → panel calls `MyAppBridge.sendContext(selection)` then `POST /api/thread/myapp-<id>/message`.
2. Gateway assembles the prompt: system prompt doc (`alwaysInclude`), RAG-retrieved history _from this visitor's own thread_, recent conversation, and the ephemeral CONTEXT block (current view + selection).
3. Claude answers — calling `app_describe` / `search_query` / `records_get` through the shim when it needs live data — and streams back over SSE.

### 5.2 Driving the app

1. Model calls e.g. `search_show` (a `display`-tier tool) → shim → `POST /bridge/call { thread, command: 'search.show', params }`.
2. Gateway pushes `call` down the tab's `/bridge` WebSocket; the `BridgeClient` executes it against the registry; the app's own action layer updates the screen.
3. `{ ok, summary, data?, affected? }` returns up the same path to the model. Timeout: 10s per call; no tab connected → graceful `ok:false`.

### 5.3 Export-tier confirm (human in the loop)

1. Model calls an `export` command → the gateway **never dispatches it directly**; it sends `confirm-request` to the tab instead.
2. `onConfirmRequest` fires → the panel renders a confirm chip with the summary; the user clicks Accept (executes, result flows back) or Decline (refusal reported). No handler wired = auto-decline. Confirm timeout: 120s.

### 5.4 What else the agent can do

Every gateway thread also gets cumulus's standard tools: persistent RAG over its own history and stored content (`search_history`, `read_file`, …), inter-agent messaging (`send_to_agent` — e.g. a visitor session escalating to your `myapp` management thread), push notifications (`notify_user`), and email if configured. `allowedTools` decides what the agent _can_ use; the system prompt doc decides what it _should_.

### 5.5 How retrieval behaves on a visitor surface (it is not like a dev thread)

Worth understanding before you debug a strange answer, because visitor threads sit at the
opposite end of cumulus's retrieval design from the threads it was tuned on.

Cumulus replaces the model's context every turn: recent conversation, plus whatever
semantic + keyword search pulls out of that thread's own history and content store. On a
maintainer thread that works well — the store is full of prose about the same subject the
next question will be about, so the query lands _on_ the corpus.

A visitor thread inverts both sides:

- **The store is mostly JSON.** Its content is tool results — `records_get` payloads,
  `app_describe` snapshots — not prose. Little of it reads like a sentence.
- **The queries are English questions** about your product, often about things no prior turn
  ever touched.

So an **off-manifold query — one that matches nothing in the store — is routine here, not
exceptional.** That matters because retrieval has a relevance floor, not a hard cutoff: when
nothing scores well, whatever survives the floor is what gets packed. On a visitor thread
that can be a document with no topical relationship to the question at all, simply because
it is the least-bad match available.

This was measured, not theorised: a topic-free operational document seeded into every store
was retrieved on **2 of 24** visitor turns on a live app — including one turn where it
outranked 24 genuine tool results — and on one of those turns the model acted on it,
inventing an HTTP call that did not exist. The document has since been removed from
visitor threads (it is only seeded into threads that could actually run it), but the
mechanism is general and will apply to anything else in the store.

Practical consequences:

- **Put product knowledge in `alwaysInclude`, not in the store.** `alwaysInclude` is
  unconditional — it is in every prompt regardless of what retrieval scores. Anything the
  agent must always know belongs there. Retrieval is a bonus, not a guarantee.
- **Say so in the system prompt doc.** Instruct the agent to answer from its tools and the
  system prompt, and to call a tool rather than infer from retrieved fragments. A visitor
  thread should reach for `records_get`, not for whatever came back from search.
- **Expect a cold first turn.** A brand-new visitor thread has an almost empty store, so its
  first turns retrieve very little (or the only thing present). Do not tune retrieval on the
  first exchange of a fresh session.
- **The store still earns its keep within a session** — it is what makes turn 12 remember
  turn 3 for that visitor. The caution is about cross-topic recall, not about memory.

---

## 6. Security model (capability-by-name)

Locked in task 097 P7 and verified live:

| Property                             | Enforcement                                                                                                                                                   |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Scoped key confined to its namespace | 403 on any thread outside `myapp-*`, on every REST/WS/bridge surface, including bridge tab registration                                                       |
| No enumeration                       | Scoped keys get empty results from `/api/threads`, `/api/agents`, WS thread lists, and the dashboard — there is no surface that lists visitor thread names    |
| Thread name = per-visitor capability | Names are `myapp-<random hex>`; knowing a name is what grants access to that conversation. Use ≥16 hex chars of `crypto.getRandomValues` entropy for new apps |
| Base thread belongs to the owner     | `myapp` itself is outside the namespace — the app's key can't read your management thread                                                                     |
| Visitor configs can't touch the base | Config prefix-fallback is **read-only**; writes stay exact (task 098)                                                                                         |
| Export can't auto-run                | Confirm path enforced gateway-side regardless of caller flags                                                                                                 |

Operational corollaries:

- Never ship an admin key to a browser. One scoped key per app.
- Don't log request URIs on the public edge (thread names appear in paths like `/bridge/manifest/:thread`). The default Caddy setup here has no access log; keep it that way or accept the exposure knowingly.
- Key rotation = edit `namespaces[].apiKeys`, have the admin reload the gateway, and redeploy the serving layer's env.

---

## 7. Standing up your next app — checklist

**Gateway (5 minutes):**

1. Mint a key: `sk-<app>-$(openssl rand -hex 16)`.
2. Add the `namespaces[]` block (§2.2); ensure `bridge.enabled: true`. Before asking for a reload, validate the edited file against the installed loader — note `loadGatewayConfig` is **async**, so await it:

   ```bash
   node -e 'import(process.argv[1] + "/@luckydraw/cumulus/dist/gateway/config.js")
     .then(m => m.loadGatewayConfig(process.env.HOME + "/.cumulus/gateway.config.json"))
     .then(() => console.log("config OK"), e => { console.error(e.message); process.exit(1); })' "$(npm root -g)"
   ```

3. Write **both** thread configs — `<app>.config.json` (yours) and `<app>-v.config.json` (every visitor, cheap model) — plus the system-prompt doc they include. `node agent/apply-thread-configs.mjs` covers the API-settable fields; §2.3 says which ones it cannot.
4. Ask the gateway admin to reload the gateway (if needed), then run the §2.4 probes.

**App backend:** 5. Serve `__AGENT_CONFIG__` from a session-gated route (key from env, not the repo) — §3.1. 6. Copy `examples/web-app-agent/agent/mcp-shim.js`; point its env at your gateway + key; wire it in `extraMcpServers` with `BRIDGE_THREAD: "{thread}"`.

**Front end:** 7. Copy `examples/web-app-agent/public/agent/` wholesale — `device-thread.js` (rename the localStorage key), `bridge-mount.js`, `chat-client.js`, `panel.js`, `panel.css` — and serve cumulus's `dist/gateway/bridge/{client,protocol}.js` at `/agent/bridge-client/` rather than vendoring a copy. 8. Write `commands.js` for _your_ app: `app.describe`, `app.describeView`, then your reads/displays/mutates/exports. Put `data-agent-ref` on record-bearing elements, `data-testid` on interactive ones. 9. Check script load order (§4) and the no-`__AGENT_CONFIG__` no-op path for local dev.

**Verify end-to-end:** 10. Open the app → console shows `[device-thread] thread: <app>-<id>` and `[bridge] open`. 11. Ask the panel a question that needs live data — confirm a `[read]` tool call round-trips. 12. Ask it to change the screen — confirm a `display` command drives the UI. 13. Trigger an `export` — confirm the chip appears and Decline suppresses execution. 14. Right-click an element, send feedback — confirm the agent receives element + refs + view context.

**Before launch:** 15. Add the `licenseKey` (§2.5) and reload. Unlicensed, the
namespace stops minting new visitor threads at 5 — a limit you will not hit in
development and will hit on your first real day.

---

## 8. Reference: file map

Every path below exists in the shipped package — this is the runnable kit, not a description of someone else's repo.

| Layer     | File                                                    | Role                                                      |
| --------- | ------------------------------------------------------- | --------------------------------------------------------- |
| Gateway   | `~/.cumulus/gateway.config.json`                        | namespace, scoped key, `executorProxy`, `extraMcpServers` |
| Gateway   | `examples/web-app-agent/gateway.config.example.json`    | the fragment to merge into it                             |
| Gateway   | `~/.cumulus/threads/myapp.config.json`                  | YOUR management thread: strong model                      |
| Gateway   | `~/.cumulus/threads/myapp-v.config.json`                | EVERY visitor turn: cheap model, prompt, cwd (§2.3)       |
| Gateway   | `examples/web-app-agent/thread-config*.example.json`    | both of the above, as editable examples                   |
| Gateway   | `examples/web-app-agent/agent/apply-thread-configs.mjs` | one-command applier for the API-settable fields           |
| Gateway   | `dist/gateway/bridge/{protocol,gateway,client}.js`      | the bridge itself — cumulus-owned, never forked           |
| Serving   | `examples/web-app-agent/server.js`                      | session-gated `/api/agent-config`                         |
| Shim      | `examples/web-app-agent/agent/mcp-shim.js`              | manifest → MCP tools; calls → `/bridge/call`              |
| Front end | `public/agent/device-thread.js`                         | per-visitor thread identity (16-hex)                      |
| Front end | `public/agent/commands.js`                              | **the command registry — the file you write**             |
| Front end | `public/agent/bridge-mount.js`                          | wires the cumulus browser client to your registry         |
| Front end | `public/agent/chat-client.js`                           | SSE chat against `/api/thread/:name/message`              |
| Front end | `public/agent/panel.js`, `panel.css`                    | chat window + home bar                                    |
| Front end | `public/app.js` (`window.HostApp`)                      | the adapter commands act through — never the DOM          |

Two pieces described in this guide are **not** in the starter kit, to keep it small: the selection/right-click feedback composer (§4.6) and a rich markdown renderer with entity chips. Both are additive — add them once the core loop works.
