@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,334 @@
1
+ # Web-App Agent — runnable starter kit
2
+
3
+ A working web app with a persistent cumulus agent that can **see the screen** and
4
+ **drive the app**. Every file here runs. Copy the `agent/` directories into your
5
+ own app, replace one file, and you have the same thing.
6
+
7
+ The full narrative version is [`docs/web-app-agent-guide.md`](../../docs/web-app-agent-guide.md).
8
+ This is the code that guide describes.
9
+
10
+ ---
11
+
12
+ ## What you get
13
+
14
+ | | |
15
+ | --------------------------------------- | -------------------------------------------------------------------------------------------- |
16
+ | **A persistent thread per visitor** | The conversation outlives the tab, the session, and the deploy. Reload and it's still there. |
17
+ | **The agent sees the live screen** | A fresh `describeView` rides along with every turn — never a cached snapshot. |
18
+ | **The agent drives the app** | Through a registry you define, calling your app's own actions — not the DOM. |
19
+ | **A human gate on irreversible things** | `risk: "export"` commands stop for a confirm chip. The model cannot bypass it. |
20
+ | **Capability-scoped access** | The app's key touches only its own namespace, and lists nothing at all — even its own. |
21
+
22
+ ## Run it
23
+
24
+ **If you installed cumulus from npm** (the usual case — the kit ships inside the
25
+ package, already built):
26
+
27
+ ```bash
28
+ cd "$(npm root -g)/@luckydraw/cumulus/examples/web-app-agent"
29
+ GATEWAY_API_KEY=sk-demoapp-REPLACE-ME GATEWAY_ORIGIN=http://127.0.0.1:8080 node server.js
30
+ # -> http://127.0.0.1:8199 password: demo (override with APP_PASSWORD)
31
+ ```
32
+
33
+ **If you have the cumulus git repo**, build it once first — the browser bridge
34
+ client is served straight out of `dist/`, since this kit vendors no copy of it:
35
+
36
+ ```bash
37
+ npm install && npm run build
38
+ cd examples/web-app-agent
39
+ GATEWAY_API_KEY=sk-demoapp-REPLACE-ME GATEWAY_ORIGIN=http://127.0.0.1:8080 node server.js
40
+ ```
41
+
42
+ Copy the whole directory somewhere writable before you start editing it — a kit
43
+ inside `node_modules` is replaced on the next upgrade.
44
+
45
+ `8080` is the default port of a _fresh_ install, not a guarantee about your
46
+ machine. Check the real one before you copy anything verbatim:
47
+
48
+ ```bash
49
+ jq .port ~/.cumulus/gateway.config.json # or: cumulus-gateway config get port
50
+ ```
51
+
52
+ Use that value in `GATEWAY_ORIGIN` here and in the shim's env. If some other
53
+ service owns 8080, a verbatim copy talks to it and fails obscurely.
54
+
55
+ Without `GATEWAY_API_KEY` the app still runs — it just has no assistant. That is
56
+ the correct degraded state, and it's worth keeping in your own app.
57
+
58
+ `GATEWAY_ORIGIN` has no default and is required whenever the key is set. That's
59
+ deliberate: a defaulting app plus a mistyped variable is an app that quietly
60
+ talks to whatever gateway happens to be listening on the usual port — a failure
61
+ that looks like success. The server also refuses to start on near-miss names
62
+ (`AGENT_API_KEY`, `GATEWAY_URL`, `DEMO_PASSWORD`, …), naming the variable it
63
+ actually reads.
64
+
65
+ For the assistant to actually answer you also need a gateway: merge
66
+ [`gateway.config.example.json`](gateway.config.example.json) into
67
+ `~/.cumulus/gateway.config.json`, then reload it
68
+ (`sudo systemctl reload cumulus-gateway`, or `cumulus-gateway reload` if you
69
+ manage it yourself — never `systemctl restart`, which kills in-flight turns).
70
+
71
+ ## What's here
72
+
73
+ ```
74
+ server.js your serving layer's one job: hand the scoped key
75
+ to authenticated sessions only
76
+ agent/mcp-shim.js stdio MCP server the gateway spawns per turn;
77
+ turns your registry into the model's tools
78
+ gateway.config.example.json the namespace, the scoped key, the shim
79
+
80
+ public/index.html the demo app
81
+ public/app.js the demo app + window.HostApp (the adapter)
82
+
83
+ public/agent/commands.js ← THE FILE YOU WRITE. Your capability surface.
84
+ public/agent/device-thread.js per-visitor thread identity
85
+ public/agent/bridge-mount.js wires the bridge client to your registry
86
+ public/agent/chat-client.js speaks the gateway's chat API (SSE)
87
+ public/agent/blex-mount.js rich blocks (tables, charts, diagrams) — optional
88
+ public/agent/panel.js the chat UI
89
+ public/agent/panel.css themed from six CSS variables
90
+ ```
91
+
92
+ Copy all of `public/agent/` and `agent/mcp-shim.js` as-is. Then **rewrite
93
+ `commands.js`** against your own app and delete the demo's notes commands.
94
+ That's the whole port.
95
+
96
+ Three browser files are deliberately **not** in this tree — the `BridgeClient`,
97
+ the blex renderer and the blex library. `server.js` resolves all of them out of
98
+ the installed `@luckydraw/cumulus` package and serves them from **your own
99
+ origin** (`/agent/bridge-client/…`, `/agent/blex/…`), so they can't drift from
100
+ the gateway and can't depend on how your edge is routed. Don't fork them, and
101
+ don't load them cross-origin from `GATEWAY_ORIGIN` — that only works if your app
102
+ is on a different hostname than the gateway; if it isn't (the common shape: your
103
+ hostname, with the edge routing only `/bridge*` and `/api/thread/*` through), the
104
+ cross-origin URL 404s and blex degrades to plain text with no obvious cause.
105
+
106
+ ### Rich blocks
107
+
108
+ The gateway instructs every thread to emit `~~~blex` fences for tables, status
109
+ boards, metrics, charts and diagrams — it's a global rule and a thread can't opt
110
+ out. So a panel with no renderer shows the visitor **raw JSON**. That isn't a
111
+ gateway bug; it's a missing half, and it's what `blex-mount.js` supplies.
112
+
113
+ It loads two scripts from your own origin (`/agent/blex/…`, served out of the
114
+ installed cumulus package — see above) at mount time, and degrades to plain
115
+ markdown if either is unavailable. Nothing else in the kit depends on it.
116
+
117
+ The panel is **render-only**: `confirm`, `poll`, `form` and `diff` are not
118
+ rendered, because their buttons have no local half here and a dead "Apply"
119
+ button reads as live in a way raw JSON does not. Denied fences stay visible as
120
+ their original text. Nothing is lost — confirms arrive over the _bridge_ as a
121
+ native audited chip, never through message content.
122
+
123
+ If your app has a build step that mirrors static assets into an output
124
+ directory, note that adopting this kit adds a whole **directory** (`agent/`) to
125
+ that output. A rollback that merge-copies a snapshot over the output tree
126
+ (`cp -r snapshot/. dist/`) cannot remove a directory the bad build added — the
127
+ agent assets survive the rollback and keep being served while the rest of the
128
+ app has no reference to them. A verification pass that checks referenced assets
129
+ (a version hash, a manifest) reports success — the orphan is unreferenced, so
130
+ nothing looks at it. Roll back by replacing the output directory, and verify
131
+ with `diff -rq` against the snapshot.
132
+
133
+ ### Asset delivery (why every URL carries `?v=`)
134
+
135
+ `server.js` stamps each asset URL with a hash of its bytes and serves it
136
+ `no-cache, must-revalidate` — unless the request's `?v=` matches the current
137
+ hash, in which case it gets `immutable`. Copy this into your own server.
138
+
139
+ The reason is measured, not theoretical: a Cloudflare edge **overrides** an
140
+ origin's `no-cache` with `max-age=14400`. Without a stamp, an edit to
141
+ `commands.js` — the file you'll change most — can take four hours to reach a
142
+ browser, and each file expires on its own clock, so a visitor can end up holding
143
+ `panel.js` from one deploy and `commands.js` from another. A content-addressed
144
+ URL is the only part of this a cache policy can't override.
145
+
146
+ Two details worth keeping when you port it:
147
+
148
+ - **The hash is computed at serve time**, cached on mtime+size. So a deploy
149
+ lands with no restart, and the hash always describes the bytes actually on
150
+ disk — a build-time hash goes stale against anything edited afterwards, and
151
+ the blex/bridge assets come out of the installed cumulus package, which
152
+ changes on `npm i`, not on your build.
153
+ - **A stale or forged `?v=` must not get `immutable`.** Otherwise a wrong token
154
+ pins today's bytes under a key that no longer describes them.
155
+
156
+ An HTML-level stamp can only reach URLs that appear in the markup. Three assets
157
+ here are loaded from _inside_ JavaScript — `panel.css` (a `<link>` built by
158
+ `bridge-mount`) and the two blex scripts (appended by `blex-mount`) — so
159
+ `index.html` also publishes a `window.__AGENT_ASSET_V` map that the server fills
160
+ in, and those loaders call `window.agentAsset(url)` to look themselves up.
161
+ General rule: **a loader that fetches its own dependencies has to propagate the
162
+ version token**, because nothing upstream can see that URL.
163
+
164
+ Known limit: `bridge-mount.js` reaches `client.js` through a static ESM
165
+ `import`, and `client.js` imports `protocol.js` in turn, so those two are
166
+ fetched unstamped. Both ship from the cumulus package and change only on
167
+ upgrade, and the server marks them `no-cache`, so the browser is correct — an
168
+ edge that overrides it is the exposure. Simplest answer: exclude `/agent/` from
169
+ your CDN.
170
+
171
+ And one trap that makes a _correct_ deploy look broken: **404s get cached too.**
172
+ If you probe a route before it exists, the edge caches the 404 for its default
173
+ TTL (measured: `max-age=14400`, `cf-cache-status: HIT`), so after you ship the
174
+ route it keeps serving "not found" for four hours — which reads as "my route is
175
+ wrong" and sends you off to re-debug working code. Purge before concluding
176
+ anything.
177
+
178
+ ---
179
+
180
+ ## The five things that are easy to get wrong
181
+
182
+ ### 1. The thread name is the capability
183
+
184
+ A scoped key cannot list threads — anywhere, including its own namespace. So
185
+ knowing a thread's name is what grants access to that conversation. Which means:
186
+
187
+ - Mint the per-visitor suffix **in the browser** (`device-thread.js`), so the
188
+ full name never travels server → client where it could be logged or cached.
189
+ - Use **at least 16 hex characters** (64 bits). 8 is brute-forceable against a
190
+ live gateway.
191
+ - Mint them with a **CSPRNG** — `crypto.getRandomValues(new Uint8Array(8))`, as
192
+ `device-thread.js` does. The length is not the guarantee; the primitive is.
193
+ `Math.random().toString(16)` yields an id that reads correct at 16 characters
194
+ and is seeded from predictable state, so it fails the only test that matters.
195
+
196
+ `device-thread.js` re-mints whenever the stored id fails `/^[0-9a-f]{16,}$/`, so
197
+ an app that previously shipped short ids upgrades every visitor to 64 bits on
198
+ first load — no migration code. Know the cost before you rely on it: a new id is
199
+ a **new thread**, so the prior conversation becomes unreachable from that browser
200
+ (it stays on disk, addressable only by its old name). Widening entropy is
201
+ therefore a one-way cut-over for existing visitors, not a transparent fix.
202
+
203
+ ### 2. Never ship the key in the page
204
+
205
+ `server.js` serves the scoped key from `GET /api/agent-config`, gated on a
206
+ session. If you inject it into static HTML instead, anyone who views source can
207
+ talk to your gateway as your app.
208
+
209
+ This is not hypothetical — it has shipped. An app inlined its key into a
210
+ `<script>` block in `index.html`; the key was live on the public origin and
211
+ authenticated successfully against the gateway. What let it survive review was a
212
+ comment elsewhere in the tree describing a _different_ file as the sensitive one
213
+ ("dev only, excluded from builds"). The documented mitigation was aimed at a
214
+ file that wasn't the leak. If your repo has a note about where the key lives,
215
+ verify it against what the origin actually serves:
216
+
217
+ ```sh
218
+ curl -s https://your-app.example.com/ | grep -i 'api_key\|sk-'
219
+ ```
220
+
221
+ The blast radius is bounded by §1 and by the gateway's namespace enforcement: a
222
+ scoped key cannot enumerate threads, cannot read outside its namespace, and
223
+ cannot reach gateway settings. So an exposed key is worth exactly the thread
224
+ names an attacker can guess — which is why §1's 64 bits is load-bearing rather
225
+ than belt-and-braces. Exposing the key turns your device-id entropy into the
226
+ _only_ remaining barrier.
227
+
228
+ **How enumeration fails matters if your client branches on status.** The list
229
+ endpoints do not refuse a scoped key — they answer `200` with an empty list:
230
+
231
+ ```
232
+ GET /api/threads → 200 {"threads":[]}
233
+ GET /api/agents → 200 {"agents":[]}
234
+ ```
235
+
236
+ Empty is not "your namespace happens to be empty." It is empty even when the
237
+ namespace holds hundreds of live threads, because a scoped caller already knows
238
+ the one name it needs and listing siblings would hand out every other visitor's
239
+ capability. Reads outside the namespace are the ones that `403`. Don't write a
240
+ client that reads `200` as "I'm allowed to enumerate, there just isn't anything
241
+ here" — it will never see a thread, and no error will tell it why.
242
+
243
+ ### 3. Commands act through an adapter, not the DOM
244
+
245
+ `window.HostApp` is the app's own actions exposed as functions. Commands call
246
+ those. That's why validation, persistence, and re-render work identically
247
+ whether a human or the model is driving — and why your commands survive a UI
248
+ rewrite.
249
+
250
+ ### 4. Descriptions are the interface
251
+
252
+ The model decides what to call based entirely on the `description` string.
253
+ Write for a reader who cannot see your UI, and say what a command is _for_, not
254
+ just what it does. Compare:
255
+
256
+ ```js
257
+ description: 'Sets the filter.'; // useless
258
+ description: 'Set the on-screen filter so the human sees a subset. ' +
259
+ 'Use it to SHOW someone something, not to look something up ' +
260
+ 'yourself — for that, use notes.list, which changes nothing.';
261
+ ```
262
+
263
+ The second one prevents a whole class of annoying behaviour.
264
+
265
+ ### 5. Pick the risk tier honestly
266
+
267
+ | tier | meaning | gated? |
268
+ | --------- | ----------------------------------------------- | ---------------------- |
269
+ | `read` | answers a question, changes nothing | no — runs immediately |
270
+ | `display` | changes the view only, trivially undoable | no — runs immediately |
271
+ | `mutate` | changes stored data, but recoverably | no — runs immediately |
272
+ | `export` | must not happen without a human seeing it first | **yes — confirm chip** |
273
+
274
+ **The gate is binary and `export` is the only tier on the gated side.** The
275
+ gateway confirm-gates a call when its manifest entry says `risk: "export"`, full
276
+ stop — `read`, `display` and `mutate` all dispatch on arrival. Those three tiers
277
+ are advisory: the tier rides in the tool description the model sees
278
+ (`[mutate] …`) so it can weigh the call, but nothing stops it.
279
+
280
+ So the question when tiering is **not** "is this irreversible?" — it is _"must a
281
+ human see this before it happens?"_ If yes, it is `export`, whatever the verb
282
+ is. An app that wants every write confirmed puts every write at `export`; the
283
+ alternative is a command that reads as guarded and isn't, and the failure is
284
+ silent — it just runs.
285
+
286
+ Worth an explicit test: enumerate the registered manifest and fail on anything
287
+ outside an allowlist of genuinely read-only commands that is not `export`. A
288
+ command quietly retiered down becomes an ungated write while a suite that only
289
+ checks the chip's behaviour stays green.
290
+
291
+ ---
292
+
293
+ ## How a turn actually works
294
+
295
+ ```
296
+ visitor types → panel.js sends the message to the gateway
297
+ AND pushes a fresh describeView over the bridge
298
+
299
+ gateway assembles the turn (history + RAG + your describeView)
300
+
301
+ model calls a tool → mcp-shim.js → POST /bridge/call
302
+
303
+ gateway → open WebSocket → the visitor's tab → your registry → HostApp
304
+
305
+ result travels back the same way; model composes an answer; SSE streams it
306
+ ```
307
+
308
+ Two consequences worth internalising:
309
+
310
+ - **The tab must be open** for tool calls to work. With no tab, the gateway
311
+ answers `{ ok: false }` and the model reports an honest failure instead of
312
+ hanging.
313
+ - **The tool list is live.** Every `tools/list` re-fetches your manifest, so
314
+ capabilities appear and disappear as your app changes — no gateway restart,
315
+ no redeploy.
316
+
317
+ ## Demo mode
318
+
319
+ An unlicensed gateway caps each namespace at **5 distinct visitor threads**.
320
+ Existing threads keep working; only minting a _new_ one is refused, with `402`
321
+ and a contact address. `chat-client.js` surfaces that as a readable message
322
+ rather than a generic failure.
323
+
324
+ That's plenty to evaluate with and hits immediately in production, which is the
325
+ point. See [`LICENSE`](../../LICENSE).
326
+
327
+ ## Optional extras
328
+
329
+ Not in this kit, but in the guide:
330
+
331
+ - **Selection as context** — let a visitor highlight part of the page and ask
332
+ about it (guide §4.6).
333
+ - **Executor proxy** — reverse-proxy your app's own API through the gateway so
334
+ the tab talks to a single origin (`executorProxy` in the example config).
@@ -0,0 +1,105 @@
1
+ // Stdio MCP server that the cumulus gateway spawns once per turn, giving the
2
+ // model tools that execute inside the visitor's live browser tab.
3
+ //
4
+ // Register it on your namespace in gateway.config.json (see
5
+ // gateway.config.example.json). The gateway substitutes {thread} for the
6
+ // actual thread name, which is how one shim serves every visitor.
7
+ //
8
+ // Stateless by design: every tools/list re-fetches the tab's live manifest, so
9
+ // tools appear and disappear as the app's capabilities change — no restart, no
10
+ // redeploy. Every tools/call is forwarded to POST /bridge/call, which
11
+ // dispatches into the tab. With no tab connected the gateway answers
12
+ // { ok: false } and the model sees an honest failure rather than a hang.
13
+ //
14
+ // MCP tool names cannot contain dots, so registry command names are exposed
15
+ // with underscores (notes.create -> notes_create) and mapped back on call.
16
+ //
17
+ // Env (set by the gateway from your namespace config):
18
+ // GATEWAY_ORIGIN default http://127.0.0.1:8080 (this shim runs ON the
19
+ // gateway host, so the local gateway is the right guess —
20
+ // unlike server.js, where there is no safe default)
21
+ // GATEWAY_API_KEY a key scoped to this namespace
22
+ // BRIDGE_THREAD the thread being served — pass "{thread}" in the config
23
+
24
+ import readline from 'node:readline';
25
+
26
+ const GATEWAY_ORIGIN = process.env.GATEWAY_ORIGIN ?? 'http://127.0.0.1:8080';
27
+ const GATEWAY_API_KEY = process.env.GATEWAY_API_KEY ?? '';
28
+ const BRIDGE_THREAD = process.env.BRIDGE_THREAD ?? 'demoapp';
29
+ const SERVER_NAME = process.env.MCP_SERVER_NAME ?? 'demoapp-tools';
30
+ const PROTOCOL_VERSION = '2024-11-05';
31
+
32
+ const toMcpName = command => command.replaceAll('.', '_');
33
+ let nameToCommand = new Map();
34
+
35
+ async function fetchManifest() {
36
+ const res = await fetch(
37
+ `${GATEWAY_ORIGIN}/bridge/manifest/${encodeURIComponent(BRIDGE_THREAD)}`,
38
+ { headers: { 'X-API-Key': GATEWAY_API_KEY } }
39
+ );
40
+ const manifest = res.ok ? ((await res.json()).manifest ?? []) : [];
41
+ nameToCommand = new Map(manifest.map(c => [toMcpName(c.name), c.name]));
42
+ return manifest.map(c => ({
43
+ name: toMcpName(c.name),
44
+ // The risk tier rides in the description so the model can see which calls
45
+ // are reversible and which will stop for a human.
46
+ description: `[${c.risk ?? 'read'}] ${c.description ?? c.name}`,
47
+ inputSchema: c.input_schema ?? c.inputSchema ?? { type: 'object', properties: {} },
48
+ }));
49
+ }
50
+
51
+ async function callCommand(mcpName, args) {
52
+ if (!nameToCommand.has(mcpName)) await fetchManifest().catch(() => {});
53
+ const command = nameToCommand.get(mcpName) ?? mcpName.replaceAll('_', '.');
54
+ const res = await fetch(`${GATEWAY_ORIGIN}/bridge/call`, {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/json', 'X-API-Key': GATEWAY_API_KEY },
57
+ body: JSON.stringify({ thread: BRIDGE_THREAD, command, params: args ?? {} }),
58
+ });
59
+ if (!res.ok) throw new Error(`bridge call failed: ${res.status}`);
60
+ return res.json();
61
+ }
62
+
63
+ const handlers = {
64
+ initialize: async () => ({
65
+ protocolVersion: PROTOCOL_VERSION,
66
+ capabilities: { tools: {} },
67
+ serverInfo: { name: SERVER_NAME, version: '1.0.0' },
68
+ }),
69
+ 'tools/list': async () => ({ tools: await fetchManifest() }),
70
+ 'tools/call': async ({ name, arguments: args }) => {
71
+ const result = await callCommand(name, args);
72
+ return {
73
+ content: [{ type: 'text', text: JSON.stringify(result) }],
74
+ isError: !result.ok,
75
+ };
76
+ },
77
+ ping: async () => ({}),
78
+ };
79
+
80
+ const rl = readline.createInterface({ input: process.stdin });
81
+ rl.on('line', async line => {
82
+ if (!line.trim()) return;
83
+ let msg;
84
+ try {
85
+ msg = JSON.parse(line);
86
+ } catch {
87
+ return;
88
+ }
89
+ if (msg.id === undefined) return; // notification (e.g. notifications/initialized)
90
+
91
+ const handler = handlers[msg.method];
92
+ let response;
93
+ try {
94
+ if (!handler)
95
+ throw Object.assign(new Error(`Method not found: ${msg.method}`), { code: -32601 });
96
+ response = { jsonrpc: '2.0', id: msg.id, result: await handler(msg.params ?? {}) };
97
+ } catch (err) {
98
+ response = {
99
+ jsonrpc: '2.0',
100
+ id: msg.id,
101
+ error: { code: err.code ?? -32000, message: err?.message ?? String(err) },
102
+ };
103
+ }
104
+ process.stdout.write(JSON.stringify(response) + '\n');
105
+ });
@@ -0,0 +1,70 @@
1
+ {
2
+ "_comment": [
3
+ "Merge these keys into your ~/.cumulus/gateway.config.json — this file is a fragment, not a whole config.",
4
+ "Then: sudo systemctl reload cumulus-gateway (SIGHUP; never restart, it kills in-flight turns).",
5
+ "Replace sk-demoapp-REPLACE-ME with a long random string. It is the app's only credential.",
6
+ "PORT: 8080 below is the DEFAULT for a fresh install, not a promise about your box.",
7
+ "Read your own config's top-level \"port\" and use that in GATEWAY_ORIGIN — pointing the",
8
+ "shim at whatever else happens to own 8080 fails in a confusing, non-obvious way."
9
+ ],
10
+
11
+ "_bridge": [
12
+ "Top-level and GLOBAL — one setting for the whole gateway, not per-app. If it is",
13
+ "already true (another app enabled it), leave it alone; adding a second app is a",
14
+ "namespaces[] entry only. Setting it true again is harmless.",
15
+ "",
16
+ "There is one more optional key here: bridge.executorUrl (default",
17
+ "http://127.0.0.1:8091). It is ALSO global — one URL for the whole gateway, so a",
18
+ "second app cannot point it somewhere else. It is used for exactly one thing:",
19
+ "hydrating a visitor's text selection into full records via POST <executorUrl>/execute",
20
+ "before the turn runs. It soft-fails if unreachable, and it is UNRELATED to the",
21
+ "per-namespace executorProxy below — enabling that does not require this. Omit it",
22
+ "unless you have a headless executor serving /execute."
23
+ ],
24
+ "bridge": { "enabled": true },
25
+
26
+ "namespaces": [
27
+ {
28
+ "name": "demoapp",
29
+ "label": "Demo App",
30
+
31
+ "_apiKeys": [
32
+ "A key listed here is SCOPED: it can touch demoapp-* threads and nothing else,",
33
+ "and it never sees the base 'demoapp' thread (that one belongs to you, for",
34
+ "working on the app). It also lists NOTHING: /api/threads and /api/agents answer",
35
+ "200 with an empty array even when the namespace is full — not 403. Reads outside",
36
+ "the namespace are what 403. This is the key your serving layer hands to",
37
+ "logged-in sessions."
38
+ ],
39
+ "apiKeys": ["sk-demoapp-REPLACE-ME"],
40
+
41
+ "_extraMcpServers": [
42
+ "Spawned per turn for demoapp-* threads only. {thread} is substituted with the",
43
+ "calling thread's name, so one shim serves every visitor. Use an absolute path",
44
+ "to the shim — the gateway spawns it with the thread's own cwd."
45
+ ],
46
+ "extraMcpServers": {
47
+ "demoapp-tools": {
48
+ "command": "node",
49
+ "args": ["/absolute/path/to/examples/web-app-agent/agent/mcp-shim.js"],
50
+ "env": {
51
+ "GATEWAY_ORIGIN": "http://127.0.0.1:8080",
52
+ "GATEWAY_API_KEY": "sk-demoapp-REPLACE-ME",
53
+ "BRIDGE_THREAD": "{thread}",
54
+ "MCP_SERVER_NAME": "demoapp-tools"
55
+ }
56
+ }
57
+ },
58
+
59
+ "_executorProxy": [
60
+ "OPTIONAL. Only if your app has a backend the agent should reach directly.",
61
+ "Lets the tab talk to a single origin (the gateway) instead of two.",
62
+ "Delete this block if you don't need it — the demo does not."
63
+ ],
64
+ "executorProxy": {
65
+ "origin": "http://127.0.0.1:8199",
66
+ "pathPrefixes": ["/api/public"]
67
+ }
68
+ }
69
+ ]
70
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "cumulus-web-app-agent-example",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Runnable starter kit: a web app with a persistent cumulus agent that can see and drive it.",
7
+ "scripts": {
8
+ "start": "node server.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=20"
12
+ }
13
+ }
@@ -0,0 +1,136 @@
1
+ /* Blex mount — gives the panel rich blocks (tables, status, metrics, charts,
2
+ diagrams) instead of raw JSON.
3
+
4
+ WHY THIS FILE EXISTS AT ALL
5
+ The gateway instructs every thread to emit ~~~blex fences (it is a global
6
+ rule; a thread cannot opt out). Without a renderer on this side, the model
7
+ dutifully emits fences into a surface that has never heard of them, and the
8
+ user sees a wall of JSON. That is not a gateway bug — it is a missing half.
9
+
10
+ WHAT IS DELIBERATELY NOT HERE
11
+ No vendored copy of the renderer. Both scripts come out of the installed
12
+ cumulus package, served SAME-ORIGIN by this app's own /agent/blex/ route (see
13
+ server.js, and the load() comment below for why not cross-origin). Vendoring
14
+ would fork the seam contract and guarantee version drift the first time
15
+ either side moves.
16
+
17
+ RENDER-ONLY
18
+ This panel supplies only the required adapter members, so `confirm`, `poll`,
19
+ `form` and `diff` are not rendered — their affordances have no local half
20
+ here, and a dead "Apply" button reads as live in a way raw JSON does not.
21
+ Denied fences stay visible as their original text. See the allow-set rationale
22
+ in cumulus's blex-render.js.
23
+
24
+ Confirms come over the BRIDGE (a native, audited chip in panel.js), never
25
+ through message content — so nothing is lost by not rendering them.
26
+
27
+ window.AgentBlex = { load(cfg), ready(), adapter, extract(text), render(el, blocks) } */
28
+ (function () {
29
+ 'use strict';
30
+
31
+ var loading = null;
32
+
33
+ /* These two URLs exist only here, so the server's HTML stamp cannot reach
34
+ them — they carry their hashes via the map index.html publishes instead.
35
+ Falls through to the bare URL if the map is absent. */
36
+ function assetUrl(url) {
37
+ return typeof window.agentAsset === 'function' ? window.agentAsset(url) : url;
38
+ }
39
+
40
+ function loadScript(src) {
41
+ return new Promise(function (resolve, reject) {
42
+ var existing = document.querySelector('script[data-blex-src="' + src + '"]');
43
+ if (existing) return resolve();
44
+ var s = document.createElement('script');
45
+ s.src = src;
46
+ s.setAttribute('data-blex-src', src);
47
+ s.onload = function () {
48
+ resolve();
49
+ };
50
+ s.onerror = function () {
51
+ reject(new Error('failed to load ' + src));
52
+ };
53
+ document.head.appendChild(s);
54
+ });
55
+ }
56
+
57
+ /* Load both halves SAME-ORIGIN, from this app's own /agent/blex/ route, which
58
+ serves them straight out of the installed cumulus package (see server.js).
59
+ Deliberately NOT `GATEWAY_URL + '/blex.min.js'`: that only works when the app
60
+ is on a different origin from the gateway. In the common shape — your own
61
+ hostname, with an edge routing only `/bridge*` and `/api/thread/*` through —
62
+ the gateway origin has no such path and the load 404s into a silent
63
+ plain-text degrade. Same-origin is correct in both deployments, and it is
64
+ still not a vendored copy, so it cannot drift from the gateway.
65
+
66
+ Resolves either way: a failure here must degrade the panel, never break it. */
67
+ function load(cfg) {
68
+ if (loading) return loading;
69
+ if (!cfg) return Promise.resolve(false);
70
+ loading = loadScript(assetUrl('/agent/blex/blex.min.js'))
71
+ .then(function () {
72
+ return loadScript(assetUrl('/agent/blex/blex-render.js'));
73
+ })
74
+ .then(function () {
75
+ return ready();
76
+ })
77
+ .catch(function (err) {
78
+ // warn, not error: a build gate that fails on any console error should
79
+ // not go red because an optional enhancement was unavailable.
80
+ console.warn('[agent] blex unavailable, falling back to plain text:', err.message);
81
+ return false;
82
+ });
83
+ return loading;
84
+ }
85
+
86
+ function ready() {
87
+ return typeof window.CumulusBlexRender !== 'undefined' && typeof window.Blex !== 'undefined';
88
+ }
89
+
90
+ /* The three required members. addChip/persist are intentionally absent —
91
+ that absence IS render-only. */
92
+ function adapter() {
93
+ return window.CumulusBlexRender.renderOnlyAdapter({
94
+ getInput: function () {
95
+ return document.querySelector('[data-testid="agent-input"]');
96
+ },
97
+ getSendButton: function () {
98
+ return document.querySelector('[data-testid="agent-send"]');
99
+ },
100
+ });
101
+ }
102
+
103
+ function extract(text) {
104
+ if (!ready()) return { text: text, blocks: [] };
105
+ return window.CumulusBlexRender.extractBlocks(text);
106
+ }
107
+
108
+ function insertPlaceholders(html, blocks) {
109
+ if (!ready() || !blocks || !blocks.length) return html;
110
+ return window.CumulusBlexRender.insertPlaceholders(html, blocks, {});
111
+ }
112
+
113
+ function render(el, blocks) {
114
+ if (!ready() || !blocks || !blocks.length) return;
115
+ try {
116
+ window.CumulusBlexRender.render(el, blocks, adapter(), {});
117
+ } catch (err) {
118
+ console.warn('[agent] blex render failed:', err.message);
119
+ }
120
+ }
121
+
122
+ function destroy(el) {
123
+ if (!ready()) return;
124
+ window.CumulusBlexRender.destroy(el);
125
+ }
126
+
127
+ window.AgentBlex = {
128
+ load: load,
129
+ ready: ready,
130
+ adapter: adapter,
131
+ extract: extract,
132
+ insertPlaceholders: insertPlaceholders,
133
+ render: render,
134
+ destroy: destroy,
135
+ };
136
+ })();