@luckydraw/cumulus 0.31.66 → 1.0.0

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