@luckydraw/cumulus 0.31.66 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +12 -556
  2. package/LICENSE +150 -0
  3. package/README.md +27 -8
  4. package/dist/gateway/adapters/webchat.d.ts +15 -0
  5. package/dist/gateway/adapters/webchat.d.ts.map +1 -1
  6. package/dist/gateway/adapters/webchat.js +78 -5
  7. package/dist/gateway/adapters/webchat.js.map +1 -1
  8. package/dist/gateway/config.d.ts +17 -2
  9. package/dist/gateway/config.d.ts.map +1 -1
  10. package/dist/gateway/config.js +10 -3
  11. package/dist/gateway/config.js.map +1 -1
  12. package/dist/gateway/daemon.d.ts +3 -1
  13. package/dist/gateway/daemon.d.ts.map +1 -1
  14. package/dist/gateway/daemon.js +128 -39
  15. package/dist/gateway/daemon.js.map +1 -1
  16. package/dist/gateway/namespaces.d.ts +34 -0
  17. package/dist/gateway/namespaces.d.ts.map +1 -1
  18. package/dist/gateway/namespaces.js +58 -0
  19. package/dist/gateway/namespaces.js.map +1 -1
  20. package/dist/gateway/server.d.ts +8 -0
  21. package/dist/gateway/server.d.ts.map +1 -1
  22. package/dist/gateway/server.js +150 -41
  23. package/dist/gateway/server.js.map +1 -1
  24. package/dist/gateway/setup.d.ts +32 -0
  25. package/dist/gateway/setup.d.ts.map +1 -1
  26. package/dist/gateway/setup.js +23 -3
  27. package/dist/gateway/setup.js.map +1 -1
  28. package/dist/gateway/static/blex-render.js +341 -0
  29. package/dist/gateway/static/chat.html +1 -0
  30. package/dist/gateway/static/widget.js +1009 -738
  31. package/dist/lib/gateway.d.ts +30 -8
  32. package/dist/lib/gateway.d.ts.map +1 -1
  33. package/dist/lib/gateway.js +36 -11
  34. package/dist/lib/gateway.js.map +1 -1
  35. package/dist/lib/history.d.ts +22 -0
  36. package/dist/lib/history.d.ts.map +1 -1
  37. package/dist/lib/history.js +59 -21
  38. package/dist/lib/history.js.map +1 -1
  39. package/dist/lib/huggingface-provider.d.ts.map +1 -1
  40. package/dist/lib/huggingface-provider.js +11 -3
  41. package/dist/lib/huggingface-provider.js.map +1 -1
  42. package/dist/lib/license.d.ts +76 -0
  43. package/dist/lib/license.d.ts.map +1 -0
  44. package/dist/lib/license.js +141 -0
  45. package/dist/lib/license.js.map +1 -0
  46. package/docs/agentic-harness-primer.md +283 -0
  47. package/docs/conditional-continuation.md +167 -0
  48. package/docs/web-app-agent-guide.md +559 -0
  49. package/examples/web-app-agent/README.md +334 -0
  50. package/examples/web-app-agent/agent/mcp-shim.js +105 -0
  51. package/examples/web-app-agent/gateway.config.example.json +70 -0
  52. package/examples/web-app-agent/package.json +13 -0
  53. package/examples/web-app-agent/public/agent/blex-mount.js +136 -0
  54. package/examples/web-app-agent/public/agent/bridge-mount.js +91 -0
  55. package/examples/web-app-agent/public/agent/chat-client.js +104 -0
  56. package/examples/web-app-agent/public/agent/commands.js +256 -0
  57. package/examples/web-app-agent/public/agent/device-thread.js +48 -0
  58. package/examples/web-app-agent/public/agent/panel.css +113 -0
  59. package/examples/web-app-agent/public/agent/panel.js +392 -0
  60. package/examples/web-app-agent/public/app.js +250 -0
  61. package/examples/web-app-agent/public/index.html +126 -0
  62. package/examples/web-app-agent/server.js +379 -0
  63. package/package.json +7 -3
@@ -0,0 +1,559 @@
1
+ # Building a Persistent Web-App Agent with Cumulus
2
+
3
+ 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.
4
+
5
+ **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.
6
+
7
+ 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.
8
+
9
+ > **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).
10
+
11
+ ---
12
+
13
+ ## 1. Architecture at a glance
14
+
15
+ ```
16
+ Browser tab (your app) Thundercat gateway (cumulus)
17
+ ┌───────────────────────────────┐ ┌──────────────────────────────────┐
18
+ │ your app UI │ │ one thread per visitor: │
19
+ │ ├─ command registry │ wss │ myapp-<deviceId> │
20
+ │ │ (window.MyAppAgent) │ /bridge │ ├─ full history + RAG │
21
+ │ ├─ BridgeClient ────────────┼────────────▶│ ├─ per-thread config │
22
+ │ ├─ agent panel (chat UI) │ https │ │ (inherited from │
23
+ │ │ POST /api/thread/… ─────┼────────────▶│ │ myapp.config.json) │
24
+ │ └─ selection / right-click │ SSE │ └─ Claude subprocess per turn │
25
+ │ feedback capture │ │ └─ MCP shim ──────────────┼──┐
26
+ └───────────────────────────────┘ └──────────────────────────────────┘ │
27
+ ▲ │
28
+ POST /bridge/call ◀───────────────────┘
29
+ (agent tool call → executes in the tab)
30
+ ```
31
+
32
+ Three moving parts:
33
+
34
+ 1. **The gateway** (already running) — owns conversation history, RAG retrieval, prompt assembly, and spawns Claude per turn. One config block per app.
35
+ 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).
36
+ 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.
37
+
38
+ 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.
39
+
40
+ ### Key concepts
41
+
42
+ | Concept | What it is |
43
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | **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). |
45
+ | **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. |
46
+ | **Visitor threads** (`myapp-<id>`) | One per browser/device, minted client-side. Full persistent history + RAG each. Hidden from the default thread list. |
47
+ | **Bridge** | The gateway↔tab WebSocket: tab registers its command manifest; gateway dispatches agent tool calls into the tab. |
48
+ | **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. |
49
+ | **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. |
50
+
51
+ ---
52
+
53
+ ## 2. Gateway setup (one config edit)
54
+
55
+ 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.
56
+
57
+ > The snippets below are annotated for readability — the real config file is **strict JSON**: strip the comments and trailing commas when copying.
58
+
59
+ ### 2.1 Enable the bridge (once, global)
60
+
61
+ ```jsonc
62
+ {
63
+ "bridge": { "enabled": true },
64
+ }
65
+ ```
66
+
67
+ Default is off; with it off every bridge surface is inert.
68
+
69
+ ### 2.2 Add the app's namespace
70
+
71
+ ```jsonc
72
+ {
73
+ "namespaces": [
74
+ {
75
+ "name": "myapp", // covers threads matching myapp-*
76
+ "label": "My App",
77
+ "apiKeys": ["sk-myapp-<random>"], // the app's OWN key — mint a fresh one
78
+
79
+ // OPTIONAL: reverse-proxy selected paths to the app's backend through
80
+ // the gateway origin (so the front end needs only one origin).
81
+ "executorProxy": {
82
+ "origin": "http://127.0.0.1:8097",
83
+ "pathPrefixes": ["/state", "/journal"],
84
+ },
85
+
86
+ // OPTIONAL but required for "drive the app": the MCP shim that turns
87
+ // the app's command manifest into model-callable tools. Spawned per
88
+ // turn, ONLY for threads in this namespace. {thread} is substituted
89
+ // with the actual thread name (myapp-<deviceId>) in args AND env.
90
+ "extraMcpServers": {
91
+ "myapp-tools": {
92
+ "command": "node",
93
+ "args": ["/path/to/myapp/mcp-shim.js"],
94
+ "env": {
95
+ // 8080 is the fresh-install default — use your gateway's actual
96
+ // top-level "port" here (jq .port ~/.cumulus/gateway.config.json).
97
+ "GATEWAY_ORIGIN": "http://127.0.0.1:8080",
98
+ "GATEWAY_API_KEY": "sk-myapp-<same-scoped-key>",
99
+ "BRIDGE_THREAD": "{thread}",
100
+ },
101
+ },
102
+ },
103
+ },
104
+ ],
105
+ }
106
+ ```
107
+
108
+ Namespace semantics (locked in task 097):
109
+
110
+ - `myapp` (the bare name) is **not** in the namespace — it's your management thread, owned by your admin key. The namespace covers `myapp-*` only.
111
+ - Longest prefix wins, so `myapp-demo` can be its own nested namespace under `myapp` later.
112
+ - 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.
113
+
114
+ ### 2.3 Create the base thread config
115
+
116
+ Visitor threads inherit their config from the base by **prefix-fallback** (task 098): a turn on `myapp-a3f8c2d1` that has no exact `myapp-a3f8c2d1.config.json` reads `myapp.config.json` instead. Writes stay exact, so a visitor session can never mutate the base.
117
+
118
+ `~/.cumulus/threads/myapp.config.json` (real Pursuit example):
119
+
120
+ ```json
121
+ {
122
+ "projectDir": "/home/you/projects/myapp",
123
+ "model": "claude",
124
+ "effort": "high",
125
+ "alwaysInclude": ["docs/myapp-system-prompt.md"]
126
+ }
127
+ ```
128
+
129
+ - `projectDir` — the working directory for the agent's turns (where `alwaysInclude` paths resolve).
130
+ - `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.
131
+ - `model` / `effort` — per-app quality/latency dial.
132
+
133
+ ### 2.4 Verify
134
+
135
+ Verify with the scoped key:
136
+
137
+ ```bash
138
+ # Confined: in-namespace works, everything else 403s, lists come back empty
139
+ curl -s -H "X-API-Key: sk-myapp-..." https://gw.example.com/api/thread/myapp-smoketest/history # 200
140
+ curl -s -H "X-API-Key: sk-myapp-..." https://gw.example.com/api/thread/someother/history # 403
141
+ curl -s -H "X-API-Key: sk-myapp-..." https://gw.example.com/api/threads # {"threads":[]}
142
+ ```
143
+
144
+ ### 2.5 The license key (this is the part that gates production)
145
+
146
+ Everything in this guide runs unlicensed — that is deliberate, so you can build
147
+ and evaluate the whole integration before anyone signs anything. What an
148
+ unlicensed gateway will not do is **carry a visitor population**: each configured
149
+ namespace may hold at most **5 distinct threads**. Minting the 6th is refused with
150
+ `402` and a contact address; existing threads keep working, and threads outside a
151
+ namespace (your own, the CLI, the TUI) are never limited.
152
+
153
+ Since every visitor gets their own thread (§4.1), that cap is invisible during
154
+ development and hits on roughly your sixth real user. Budget for it before launch,
155
+ not after.
156
+
157
+ ```json
158
+ { "licenseKey": "cumulus-lic-v1...." }
159
+ ```
160
+
161
+ Add it to `gateway.config.json` and reload. Keys are verified offline — nothing is
162
+ transmitted, and an air-gapped gateway works fine — and are issued per whole-number
163
+ release, so a `1.x` key covers every `1.x.y`. The gateway logs its licence state at
164
+ startup and hourly, and the admin UI shows it under **Settings → License**.
165
+
166
+ Commercial licensing: **ops@luckydrawdesign.com**. See [`LICENSE`](../LICENSE).
167
+
168
+ ---
169
+
170
+ ## 3. The app backend: serving layer + shim
171
+
172
+ ### 3.1 Serve the agent config from a session-gated endpoint
173
+
174
+ 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`):
175
+
176
+ ```js
177
+ if (req.method === 'GET' && p === '/api/agent-config') {
178
+ if (!sessionOf(req)) return json(res, 401, { error: 'not signed in' });
179
+ if (!API_KEY) return json(res, 404, { error: 'agent not configured' });
180
+ return json(res, 200, {
181
+ GATEWAY_URL: GATEWAY_ORIGIN,
182
+ BRIDGE_URL: GATEWAY_ORIGIN.replace(/^http/, 'ws') + '/bridge',
183
+ THREAD_ID: 'myapp-v', // BASE name — the browser appends the per-device suffix
184
+ API_KEY, // namespace-scoped, from env; never in the repo
185
+ });
186
+ }
187
+ ```
188
+
189
+ 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.
190
+
191
+ The front end then calls `AgentStart()` once the config arrives, and `AgentStop()` on logout.
192
+
193
+ Notes:
194
+
195
+ - **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.
196
+ - **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.
197
+ - **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.
198
+ - In production, pass the key via a systemd drop-in (`Environment=MYAPP_API_KEY=...`), not a file in the repo.
199
+ - `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.
200
+ - 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.
201
+
202
+ ### 3.2 The MCP shim (~120 lines, copy Pursuit's)
203
+
204
+ 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:
205
+
206
+ 1. **`tools/list`** → fetch the manifest and return it as MCP tool definitions.
207
+ - 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).
208
+ - 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).
209
+ - MCP tool names can't contain dots: expose `search.query` as `search_query`, map back on call.
210
+ - Prefix each description with the risk tier: `"[read] Run a filter query…"`.
211
+ 2. **`tools/call`** → `POST {GATEWAY_ORIGIN}/bridge/call` with `{ thread, command, params }`, return the JSON result (`{ ok, summary, data?, affected? }`), setting `isError: !result.ok`.
212
+ - 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.
213
+
214
+ 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.
215
+
216
+ ### 3.3 Optional: executor proxy
217
+
218
+ 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.
219
+
220
+ ---
221
+
222
+ ## 4. Front-end integration
223
+
224
+ 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:
225
+
226
+ ```html
227
+ <!-- 1. classic scripts, in order -->
228
+ <script src="agent/device-thread.js"></script>
229
+ <!-- BEFORE any __AGENT_CONFIG__ consumer -->
230
+ <script src="agent/commands.js"></script>
231
+ <!-- registry: window.MyAppAgent -->
232
+ <script src="agent/dock.js"></script>
233
+ <script src="agent/chat-client.js"></script>
234
+ <script src="agent/selection.js"></script>
235
+ <!-- 2. ES module (deferred — always runs after classic scripts) -->
236
+ <script type="module" src="agent/bridge-mount.js"></script>
237
+ <!-- 3. the React panel mounts in its OWN root, sibling of the app's -->
238
+ ```
239
+
240
+ ### 4.1 Per-visitor thread identity (`device-thread.js`, 31 lines)
241
+
242
+ Rewrites the injected base `THREAD_ID` to a per-device name before anything else reads it:
243
+
244
+ ```js
245
+ (function () {
246
+ var cfg = window.__AGENT_CONFIG__;
247
+ if (!cfg || !cfg.THREAD_ID) return; // local dev — no-op
248
+
249
+ var KEY = 'myapp.deviceId';
250
+ var id = null;
251
+ try {
252
+ id = localStorage.getItem(KEY);
253
+ } catch (e) {}
254
+ if (!id || !/^[0-9a-f]{8,}$/.test(id)) {
255
+ // accept legacy widths
256
+ var bytes = new Uint8Array(8); // 16 hex chars for new devices
257
+ crypto.getRandomValues(bytes);
258
+ id = Array.prototype.map
259
+ .call(bytes, function (b) {
260
+ return ('0' + b.toString(16)).slice(-2);
261
+ })
262
+ .join('');
263
+ try {
264
+ localStorage.setItem(KEY, id);
265
+ } catch (e) {}
266
+ }
267
+ cfg.THREAD_ID = cfg.THREAD_ID + '-' + id; // myapp-<deviceId>
268
+ })();
269
+ ```
270
+
271
+ - 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.)
272
+ - Every consumer (chat client, panel, bridge mount) reads `cfg.THREAD_ID` _after_ this runs — hence "first classic script."
273
+
274
+ ### 4.2 The command registry (`commands.js`)
275
+
276
+ 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.
277
+
278
+ > **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.
279
+
280
+ Frozen contracts:
281
+
282
+ ```js
283
+ // command shape
284
+ { name: 'search.query',
285
+ description: 'Run a filter query headlessly… Does NOT change what the user sees — use search.show for that.',
286
+ params: { /* JSON Schema */ },
287
+ risk: 'read', // read | display | mutate | export
288
+ execute(params) { return { summary, data, affected }; } }
289
+
290
+ // result shape (call() never throws — errors become { ok:false, summary })
291
+ { ok: true, summary: '412 accounts match 2 filters.', data: {...}, affected: [...] }
292
+
293
+ // registry API (window.MyAppAgent)
294
+ MyAppAgent.register(def) MyAppAgent.call(name, params)
295
+ MyAppAgent.list() MyAppAgent.manifest() // → [{ name, description, risk, input_schema }]
296
+ ```
297
+
298
+ 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.
299
+
300
+ Two commands every app should register (the agent's eyes):
301
+
302
+ - **`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.
303
+ - **`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:
304
+
305
+ ```js
306
+ {
307
+ route, page, entity, // where the user is
308
+ filters: [ /* committed filters only — not half-typed ones */ ],
309
+ activeSavedSearchId,
310
+ selection: { accounts: [key], contacts: [key], signals: [key] },
311
+ selectionNamed: { accounts: [{ key, name }], … }, // ← see below
312
+ detail, // open record slide-over, if any
313
+ highlight, // what's spotlighted on screen
314
+ worksheetId, agentId, instanceId, … , // page-scoped ids
315
+ results: { count, visible: [{ key, name }] } // only on list pages, top 20
316
+ }
317
+ ```
318
+
319
+ Three design rules worth copying:
320
+ - **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.
321
+ - **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.
322
+ - **Cap list payloads** (`visible` = first 20 with a true `count`) — the view snapshot rides along on every turn, so it must stay small.
323
+
324
+ 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"`).
325
+
326
+ ### 4.3 Mounting the bridge (`bridge-mount.js`, ~70 lines)
327
+
328
+ 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).
329
+
330
+ ```js
331
+ import { BridgeClient } from './bridge-client/client.js';
332
+
333
+ const cfg = window.__AGENT_CONFIG__;
334
+ const bridge = new BridgeClient({
335
+ url: cfg.BRIDGE_URL, // wss://gw.example.com/bridge
336
+ thread: cfg.THREAD_ID, // myapp-<deviceId>
337
+ apiKey: cfg.API_KEY, // the scoped key
338
+ registry: {
339
+ manifest: MyAppAgent.manifest(),
340
+ execute: (command, params) => Promise.resolve(MyAppAgent.call(command, params)),
341
+ },
342
+ // Recomputed on EVERY sendContext — never cached (contract).
343
+ describeView: () => {
344
+ const r = MyAppAgent.call('app.describeView');
345
+ return r.ok ? r.data : { error: r.summary };
346
+ },
347
+ // Export-tier confirms surface as UI chips; without this hook they are
348
+ // auto-DECLINED — export can never silently execute.
349
+ onConfirmRequest: req =>
350
+ window.dispatchEvent(new CustomEvent('myapp:agent-confirm', { detail: req })),
351
+ onStateChange: s => console.info('[bridge] ' + s),
352
+ });
353
+ bridge.connect();
354
+ window.MyAppBridge = bridge; // panel calls sendContext() on each user turn
355
+ ```
356
+
357
+ 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.
358
+
359
+ ### 4.4 The chat client (`chat-client.js`, ~140 lines)
360
+
361
+ Plain `fetch` + SSE against the gateway's chat API — no library:
362
+
363
+ ```
364
+ POST {GATEWAY_URL}/api/thread/{THREAD_ID}/message body { message }, header X-API-Key
365
+ → SSE stream: token {text} · segment {type,…} · error · done
366
+ GET {GATEWAY_URL}/api/thread/{THREAD_ID}/history → prior messages (reload survival)
367
+ ```
368
+
369
+ 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.
370
+
371
+ ### 4.5 The agent panel UX (`panel.jsx` + `panel.css` + `dock.js`)
372
+
373
+ The patterns Karl called out, as Pursuit implements them:
374
+
375
+ **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.
376
+
377
+ **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.
378
+
379
+ **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.
380
+
381
+ **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).
382
+
383
+ **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.
384
+
385
+ **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.
386
+
387
+ Two parsing traps, both real (Pursuit's `markdown.jsx` handles them and a naive renderer will not):
388
+
389
+ - **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.
390
+ - **Split table cells on _unescaped_ pipes only**, for the same reason.
391
+
392
+ Inline parse order matters too: code → chip → link → bold → italic (`**` before `*`).
393
+
394
+ **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.
395
+
396
+ **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.
397
+
398
+ **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.
399
+
400
+ ### 4.6 Selection & right-click feedback (`selection.js`, ~300 lines)
401
+
402
+ Two entry points, one composer popup:
403
+
404
+ 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.
405
+ 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.
406
+
407
+ 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.
408
+
409
+ What gets captured (the `context` object):
410
+
411
+ ```js
412
+ { text, // the selected text, if any
413
+ refs: [{ entity, key, field? }], // parsed from data-agent-ref attributes
414
+ element: { tag, id, classes, agentRef, testid, screen }, // right-click only
415
+ view } // fresh app.describeView snapshot
416
+ ```
417
+
418
+ **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.
419
+
420
+ 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.
421
+
422
+ **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.
423
+
424
+ ### 4.6b Rich blocks (`blex-mount.js`, ~110 lines)
425
+
426
+ 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.
427
+
428
+ 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.
429
+
430
+ > **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.
431
+
432
+ **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.
433
+
434
+ Three cache traps — the first two measured at a real Cloudflare edge, and each one makes a _correct_ deploy look broken:
435
+
436
+ - **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.
437
+ - **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. Measured on `@luckydraw/blex@0.1.16` — its only dynamic `import()` is the bare `"mermaid"` specifier discussed in §4.6b, `Chart.js v4.5.1` is inlined, and `blex-chart.min.js` is an opt-in companion global that nothing requests.)
438
+ - **Static ESM `import` specifiers can't be stamped this way.** `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.
439
+
440
+ (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.)
441
+
442
+ 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.
443
+
444
+ ```js
445
+ window.CumulusBlexRender.renderOnlyAdapter({
446
+ getInput: () => document.querySelector('[data-testid="agent-input"]'),
447
+ getSendButton: () => document.querySelector('[data-testid="agent-send"]'),
448
+ });
449
+ ```
450
+
451
+ `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.
452
+
453
+ Two implementation traps:
454
+
455
+ - **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.
456
+ - **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.)
457
+
458
+ 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.
459
+
460
+ ### 4.7 Testability (house rules)
461
+
462
+ 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.
463
+
464
+ ---
465
+
466
+ ## 5. Runtime flows (what actually happens)
467
+
468
+ ### 5.1 Answering a question
469
+
470
+ 1. Visitor types into the panel → panel calls `MyAppBridge.sendContext(selection)` then `POST /api/thread/myapp-<id>/message`.
471
+ 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).
472
+ 3. Claude answers — calling `app_describe` / `search_query` / `records_get` through the shim when it needs live data — and streams back over SSE.
473
+
474
+ ### 5.2 Driving the app
475
+
476
+ 1. Model calls e.g. `search_show` (a `display`-tier tool) → shim → `POST /bridge/call { thread, command: 'search.show', params }`.
477
+ 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.
478
+ 3. `{ ok, summary, data?, affected? }` returns up the same path to the model. Timeout: 10s per call; no tab connected → graceful `ok:false`.
479
+
480
+ ### 5.3 Export-tier confirm (human in the loop)
481
+
482
+ 1. Model calls an `export` command → the gateway **never dispatches it directly**; it sends `confirm-request` to the tab instead.
483
+ 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.
484
+
485
+ ### 5.4 What else the agent can do
486
+
487
+ 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. The system prompt doc decides what the agent should actually use.
488
+
489
+ ---
490
+
491
+ ## 6. Security model (capability-by-name)
492
+
493
+ Locked in task 097 P7 and verified live:
494
+
495
+ | Property | Enforcement |
496
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
497
+ | Scoped key confined to its namespace | 403 on any thread outside `myapp-*`, on every REST/WS/bridge surface, including bridge tab registration |
498
+ | 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 |
499
+ | 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 |
500
+ | Base thread belongs to the owner | `myapp` itself is outside the namespace — the app's key can't read your management thread |
501
+ | Visitor configs can't touch the base | Config prefix-fallback is **read-only**; writes stay exact (task 098) |
502
+ | Export can't auto-run | Confirm path enforced gateway-side regardless of caller flags |
503
+
504
+ Operational corollaries:
505
+
506
+ - Never ship an admin key to a browser. One scoped key per app.
507
+ - 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.
508
+ - Key rotation = edit `namespaces[].apiKeys`, have the admin reload the gateway, and redeploy the serving layer's env.
509
+
510
+ ---
511
+
512
+ ## 7. Standing up your next app — checklist
513
+
514
+ **Gateway (5 minutes):**
515
+
516
+ 1. Mint a key: `sk-<app>-$(openssl rand -hex 16)`.
517
+ 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:
518
+
519
+ ```bash
520
+ node -e 'import(process.argv[1] + "/@luckydraw/cumulus/dist/gateway/config.js")
521
+ .then(m => m.loadGatewayConfig(process.env.HOME + "/.cumulus/gateway.config.json"))
522
+ .then(() => console.log("config OK"), e => { console.error(e.message); process.exit(1); })' "$(npm root -g)"
523
+ ```
524
+
525
+ 3. Write `<app>.config.json` (projectDir, model, effort, `alwaysInclude` system-prompt doc) and the system-prompt doc itself.
526
+ 4. Ask the gateway admin to reload the gateway (if needed), then run the §2.4 probes.
527
+
528
+ **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}"`.
529
+
530
+ **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.
531
+
532
+ **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.
533
+
534
+ **Before launch:** 15. Add the `licenseKey` (§2.5) and reload. Unlicensed, the
535
+ namespace stops minting new visitor threads at 5 — a limit you will not hit in
536
+ development and will hit on your first real day.
537
+
538
+ ---
539
+
540
+ ## 8. Reference: file map
541
+
542
+ Every path below exists in the shipped package — this is the runnable kit, not a description of someone else's repo.
543
+
544
+ | Layer | File | Role |
545
+ | --------- | ---------------------------------------------------- | --------------------------------------------------------- |
546
+ | Gateway | `~/.cumulus/gateway.config.json` | namespace, scoped key, `executorProxy`, `extraMcpServers` |
547
+ | Gateway | `examples/web-app-agent/gateway.config.example.json` | the fragment to merge into it |
548
+ | Gateway | `~/.cumulus/threads/myapp-v.config.json` | base config visitor threads inherit (model, prompt, cwd) |
549
+ | Gateway | `dist/gateway/bridge/{protocol,gateway,client}.js` | the bridge itself — cumulus-owned, never forked |
550
+ | Serving | `examples/web-app-agent/server.js` | session-gated `/api/agent-config` |
551
+ | Shim | `examples/web-app-agent/agent/mcp-shim.js` | manifest → MCP tools; calls → `/bridge/call` |
552
+ | Front end | `public/agent/device-thread.js` | per-visitor thread identity (16-hex) |
553
+ | Front end | `public/agent/commands.js` | **the command registry — the file you write** |
554
+ | Front end | `public/agent/bridge-mount.js` | wires the cumulus browser client to your registry |
555
+ | Front end | `public/agent/chat-client.js` | SSE chat against `/api/thread/:name/message` |
556
+ | Front end | `public/agent/panel.js`, `panel.css` | chat window + home bar |
557
+ | Front end | `public/app.js` (`window.HostApp`) | the adapter commands act through — never the DOM |
558
+
559
+ 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.