# Web-App Agent — runnable starter kit

A working web app with a persistent cumulus agent that can **see the screen** and
**drive the app**. Every file here runs. Copy the `agent/` directories into your
own app, replace one file, and you have the same thing.

The full narrative version is [`docs/web-app-agent-guide.md`](../../docs/web-app-agent-guide.md).
This is the code that guide describes.

---

## What you get

|                                         |                                                                                              |
| --------------------------------------- | -------------------------------------------------------------------------------------------- |
| **A persistent thread per visitor**     | The conversation outlives the tab, the session, and the deploy. Reload and it's still there. |
| **The agent sees the live screen**      | A fresh `describeView` rides along with every turn — never a cached snapshot.                |
| **The agent drives the app**            | Through a registry you define, calling your app's own actions — not the DOM.                 |
| **A human gate on irreversible things** | `risk: "export"` commands stop for a confirm chip. The model cannot bypass it.               |
| **Capability-scoped access**            | The app's key touches only its own namespace, and lists nothing at all — even its own.       |

## Run it

**If you installed cumulus from npm** (the usual case — the kit ships inside the
package, already built):

```bash
cd "$(npm root -g)/@luckydraw/cumulus/examples/web-app-agent"
GATEWAY_API_KEY=sk-demoapp-REPLACE-ME GATEWAY_ORIGIN=http://127.0.0.1:8080 node server.js
# -> http://127.0.0.1:8199   password: demo   (override with APP_PASSWORD)
```

**If you have the cumulus git repo**, build it once first — the browser bridge
client is served straight out of `dist/`, since this kit vendors no copy of it:

```bash
npm install && npm run build
cd examples/web-app-agent
GATEWAY_API_KEY=sk-demoapp-REPLACE-ME GATEWAY_ORIGIN=http://127.0.0.1:8080 node server.js
```

Copy the whole directory somewhere writable before you start editing it — a kit
inside `node_modules` is replaced on the next upgrade.

`8080` is the default port of a _fresh_ install, not a guarantee about your
machine. Check the real one before you copy anything verbatim:

```bash
jq .port ~/.cumulus/gateway.config.json     # or: cumulus-gateway config get port
```

Use that value in `GATEWAY_ORIGIN` here and in the shim's env. If some other
service owns 8080, a verbatim copy talks to it and fails obscurely.

Without `GATEWAY_API_KEY` the app still runs — it just has no assistant. That is
the correct degraded state, and it's worth keeping in your own app.

`GATEWAY_ORIGIN` has no default and is required whenever the key is set. That's
deliberate: a defaulting app plus a mistyped variable is an app that quietly
talks to whatever gateway happens to be listening on the usual port — a failure
that looks like success. The server also refuses to start on near-miss names
(`AGENT_API_KEY`, `GATEWAY_URL`, `DEMO_PASSWORD`, …), naming the variable it
actually reads.

For the assistant to actually answer you also need a gateway: merge
[`gateway.config.example.json`](gateway.config.example.json) into
`~/.cumulus/gateway.config.json`, then reload it
(`sudo systemctl reload cumulus-gateway`, or `cumulus-gateway reload` if you
manage it yourself — never `systemctl restart`, which kills in-flight turns).

## What's here

```
server.js                     your serving layer's one job: hand the scoped key
                              to authenticated sessions only
agent/mcp-shim.js             stdio MCP server the gateway spawns per turn;
                              turns your registry into the model's tools
gateway.config.example.json   the namespace, the scoped key, the shim
thread-config.example.json    YOUR management thread's config
thread-config.visitor.example.json  EVERY visitor's config — the cheap model
agent/apply-thread-configs.mjs      one command to push both to the gateway

public/index.html             the demo app
public/app.js                 the demo app + window.HostApp (the adapter)

public/agent/commands.js      ← THE FILE YOU WRITE. Your capability surface.
public/agent/device-thread.js per-visitor thread identity
public/agent/bridge-mount.js  wires the bridge client to your registry
public/agent/chat-client.js   speaks the gateway's chat API (SSE)
public/agent/blex-mount.js    rich blocks (tables, charts, diagrams) — optional
public/agent/panel.js         the chat UI
public/agent/panel.css        themed from six CSS variables
```

Copy all of `public/agent/` and `agent/mcp-shim.js` as-is. Then **rewrite
`commands.js`** against your own app and delete the demo's notes commands.
That's the whole port.

Three browser files are deliberately **not** in this tree — the `BridgeClient`,
the blex renderer and the blex library. `server.js` resolves all of them out of
the installed `@luckydraw/cumulus` package and serves them from **your own
origin** (`/agent/bridge-client/…`, `/agent/blex/…`), so they can't drift from
the gateway and can't depend on how your edge is routed. Don't fork them, and
don't load them cross-origin from `GATEWAY_ORIGIN` — that only works if your app
is on a different hostname than the gateway; if it isn't (the common shape: your
hostname, with the edge routing only `/bridge*` and `/api/thread/*` through), the
cross-origin URL 404s and blex degrades to plain text with no obvious cause.

### Rich blocks

The gateway instructs every thread to emit `~~~blex` fences for tables, status
boards, metrics, charts and diagrams — it's a global rule and a thread can't opt
out. So a panel with no renderer shows the visitor **raw JSON**. That isn't a
gateway bug; it's a missing half, and it's what `blex-mount.js` supplies.

It loads two scripts from your own origin (`/agent/blex/…`, served out of the
installed cumulus package — see above) at mount time, and degrades to plain
markdown if either is unavailable. Nothing else in the kit depends on it.

**Diagrams need one extra tag.** `~~~blex:mermaid` is the only block type whose
renderer loads a library at render time, using a **bare** module specifier — and
an import map is the one thing a browser can resolve that with. `index.html`
carries it; keep it when you copy:

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

The vendored mermaid bundle is served by the same `/agent/blex/*` route, and only
fetched when a diagram actually renders. Drop the tag and nothing breaks — the
renderer checks the document for the mapping and leaves mermaid fences as
readable text rather than painting an error box. Diagram colours follow the blex
card's own background, so they match whatever you theme blex to.

The panel is **render-only**: `confirm`, `poll`, `form` and `diff` are not
rendered, because their buttons have no local half here and a dead "Apply"
button reads as live in a way raw JSON does not. Denied fences stay visible as
their original text. Nothing is lost — confirms arrive over the _bridge_ as a
native audited chip, never through message content.

If your app has a build step that mirrors static assets into an output
directory, note that adopting this kit adds a whole **directory** (`agent/`) to
that output. A rollback that merge-copies a snapshot over the output tree
(`cp -r snapshot/. dist/`) cannot remove a directory the bad build added — the
agent assets survive the rollback and keep being served while the rest of the
app has no reference to them. A verification pass that checks referenced assets
(a version hash, a manifest) reports success — the orphan is unreferenced, so
nothing looks at it. Roll back by replacing the output directory, and verify
with `diff -rq` against the snapshot.

### Asset delivery (why every URL carries `?v=`)

`server.js` stamps each asset URL with a hash of its bytes and serves it
`no-cache, must-revalidate` — unless the request's `?v=` matches the current
hash, in which case it gets `immutable`. Copy this into your own server.

The reason is measured, not theoretical: a Cloudflare edge **overrides** an
origin's `no-cache` with `max-age=14400`. Without a stamp, an edit to
`commands.js` — the file you'll change most — can take four hours to reach a
browser, and each file expires on its own clock, so a visitor can end up holding
`panel.js` from one deploy and `commands.js` from another. A content-addressed
URL is the only part of this a cache policy can't override.

Two details worth keeping when you port it:

- **The hash is computed at serve time**, cached on mtime+size. So a deploy
  lands with no restart, and the hash always describes the bytes actually on
  disk — a build-time hash goes stale against anything edited afterwards, and
  the blex/bridge assets come out of the installed cumulus package, which
  changes on `npm i`, not on your build.
- **A stale or forged `?v=` must not get `immutable`.** Otherwise a wrong token
  pins today's bytes under a key that no longer describes them.

An HTML-level stamp can only reach URLs that appear in the markup. Three assets
here are loaded from _inside_ JavaScript — `panel.css` (a `<link>` built by
`bridge-mount`) and the two blex scripts (appended by `blex-mount`) — so
`index.html` also publishes a `window.__AGENT_ASSET_V` map that the server fills
in, and those loaders call `window.agentAsset(url)` to look themselves up.
General rule: **a loader that fetches its own dependencies has to propagate the
version token**, because nothing upstream can see that URL.

Known limit: `bridge-mount.js` reaches `client.js` through a static ESM
`import`, and `client.js` imports `protocol.js` in turn, so those two are
fetched unstamped. Both ship from the cumulus package and change only on
upgrade, and the server marks them `no-cache`, so the browser is correct — an
edge that overrides it is the exposure. Simplest answer: exclude `/agent/` from
your CDN.

And one trap that makes a _correct_ deploy look broken: **404s get cached too.**
If you probe a route before it exists, the edge caches the 404 for its default
TTL (measured: `max-age=14400`, `cf-cache-status: HIT`), so after you ship the
route it keeps serving "not found" for four hours — which reads as "my route is
wrong" and sends you off to re-debug working code. Purge before concluding
anything.

---

## The seven things that are easy to get wrong

### 1. The thread name is the capability

A scoped key cannot list threads — anywhere, including its own namespace. So
knowing a thread's name is what grants access to that conversation. Which means:

- Mint the per-visitor suffix **in the browser** (`device-thread.js`), so the
  full name never travels server → client where it could be logged or cached.
- Use **at least 16 hex characters** (64 bits). 8 is brute-forceable against a
  live gateway.
- Mint them with a **CSPRNG** — `crypto.getRandomValues(new Uint8Array(8))`, as
  `device-thread.js` does. The length is not the guarantee; the primitive is.
  `Math.random().toString(16)` yields an id that reads correct at 16 characters
  and is seeded from predictable state, so it fails the only test that matters.

`device-thread.js` re-mints whenever the stored id fails `/^[0-9a-f]{16,}$/`, so
an app that previously shipped short ids upgrades every visitor to 64 bits on
first load — no migration code. Know the cost before you rely on it: a new id is
a **new thread**, so the prior conversation becomes unreachable from that browser
(it stays on disk, addressable only by its old name). Widening entropy is
therefore a one-way cut-over for existing visitors, not a transparent fix.

### 2. Never ship the key in the page

`server.js` serves the scoped key from `GET /api/agent-config`, gated on a
session. If you inject it into static HTML instead, anyone who views source can
talk to your gateway as your app.

This is not hypothetical — it has shipped. An app inlined its key into a
`<script>` block in `index.html`; the key was live on the public origin and
authenticated successfully against the gateway. What let it survive review was a
comment elsewhere in the tree describing a _different_ file as the sensitive one
("dev only, excluded from builds"). The documented mitigation was aimed at a
file that wasn't the leak. If your repo has a note about where the key lives,
verify it against what the origin actually serves:

```sh
curl -s https://your-app.example.com/ | grep -i 'api_key\|sk-'
```

The blast radius is bounded by §1 and by the gateway's namespace enforcement: a
scoped key cannot enumerate threads, cannot read outside its namespace, and
cannot reach gateway settings. So an exposed key is worth exactly the thread
names an attacker can guess — which is why §1's 64 bits is load-bearing rather
than belt-and-braces. Exposing the key turns your device-id entropy into the
_only_ remaining barrier.

**How enumeration fails matters if your client branches on status.** The list
endpoints do not refuse a scoped key — they answer `200` with an empty list:

```
GET /api/threads   →  200  {"threads":[]}
GET /api/agents    →  200  {"agents":[]}
```

Empty is not "your namespace happens to be empty." It is empty even when the
namespace holds hundreds of live threads, because a scoped caller already knows
the one name it needs and listing siblings would hand out every other visitor's
capability. Reads outside the namespace are the ones that `403`. Don't write a
client that reads `200` as "I'm allowed to enumerate, there just isn't anything
here" — it will never see a thread, and no error will tell it why.

### 3. Visitor threads need their OWN model config

Your app has two kinds of thread and they should not run the same model:

| File                    | Applies to                             |
| ----------------------- | -------------------------------------- |
| `demoapp.config.json`   | your management thread — strong model  |
| `demoapp-v.config.json` | **every visitor** — small, fast, cheap |

The `-v` is what makes that possible. `server.js` sends `THREAD_ID: 'demoapp-v'`
and `device-thread.js` appends the device id, so a visitor lands on
`demoapp-v-<deviceId>`. Config resolves by stripping trailing segments and taking
the longest match:

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

**Omit the `-v` file and every anonymous visitor runs your management thread's
model.** It is invisible in development — with one tester the bill looks fine —
and it is the most expensive mistake in this kit.

Edit the two `thread-config*.example.json` files and push them:

```bash
GATEWAY_ORIGIN=http://127.0.0.1:8080 GATEWAY_ADMIN_KEY=sk-... \
  node agent/apply-thread-configs.mjs --namespace demoapp
```

The **admin** key, not the app's: a namespace covers `demoapp-*`, so the scoped
key can write `demoapp-v` but is refused on the bare `demoapp`. The config API
takes `projectDir`, `template`, `model`, `effort`, `claudeModel` and
`contextLimit`; `alwaysInclude`, `allowedTools` and `disallowedTools` have to be
added to the file on the gateway host. That is deliberate in both directions —
`alwaysInclude` plus `projectDir` would let a public scoped key read any file into
its own prompt, and `allowedTools` is writable in the _widening_ direction, so a
scoped key could relax its own restrictions. The applier reads each config back and
tells you exactly what didn't stick. No reload needed.

### 4. Visitor threads need an `allowedTools` allowlist

The model is the cost dial; `allowedTools` is the **capability** dial, and omitting
it is the other expensive mistake. A thread with no allowlist gets the full harness:
shell, file writes, sub-agent spawning, and the ability to message your other
threads — handed to anonymous traffic.

```json
"allowedTools": ["read_file", "search_content", "retrieve_content", "search_history"]
```

It is **deny-by-default**: anything not named is refused, _including tools a future
cumulus or Claude CLI release adds_. That is why it is an allowlist and not a
denylist — with a denylist, every newly shipped tool is silently granted to every
visitor of every deployed app until you notice. One real app needed **47 deny
entries** to reach a safe surface; the list above is five.

Two things people expect wrongly:

- **Your own shim tools are unaffected.** Cumulus doesn't know their names, so it
  can't deny them. They stay available — they're the tools you deliberately gave
  this namespace.
- **A typo denies.** An entry matching nothing is inert, and inertness is a denial,
  so a misspelled tool silently disappears. Check the journal after a deploy.
- **`read_file` and `Read` are not interchangeable.** `Read` is the Claude CLI's own
  tool and cumulus has no root hook into it — allow it and a visitor can ask for any
  file the gateway user can read, including `~/.cumulus/gateway.config.json` and the
  credentials in it. `read_file` is cumulus's own, and while `Read` is denied it is
  confined to that thread's `projectDir`. Denying `Read` is what switches the
  confinement on, so the two work as a pair; widen `projectDir` rather than adding
  `Read` back.

Setting it also shrinks that thread's system prompt: sections about tools it can't
reach (background work, scheduling, inter-agent messaging) are dropped, because dead
instructions cost tokens every turn and invite the model to improvise a substitute
for a tool it cannot call.

### 5. Commands act through an adapter, not the DOM

`window.HostApp` is the app's own actions exposed as functions. Commands call
those. That's why validation, persistence, and re-render work identically
whether a human or the model is driving — and why your commands survive a UI
rewrite.

### 6. Descriptions are the interface

The model decides what to call based entirely on the `description` string.
Write for a reader who cannot see your UI, and say what a command is _for_, not
just what it does. Compare:

```js
description: 'Sets the filter.'; // useless
description: 'Set the on-screen filter so the human sees a subset. ' +
  'Use it to SHOW someone something, not to look something up ' +
  'yourself — for that, use notes.list, which changes nothing.';
```

The second one prevents a whole class of annoying behaviour.

### 7. Pick the risk tier honestly

| tier      | meaning                                         | gated?                 |
| --------- | ----------------------------------------------- | ---------------------- |
| `read`    | answers a question, changes nothing             | no — runs immediately  |
| `display` | changes the view only, trivially undoable       | no — runs immediately  |
| `mutate`  | changes stored data, but recoverably            | no — runs immediately  |
| `export`  | must not happen without a human seeing it first | **yes — confirm chip** |

**The gate is binary and `export` is the only tier on the gated side.** The
gateway confirm-gates a call when its manifest entry says `risk: "export"`, full
stop — `read`, `display` and `mutate` all dispatch on arrival. Those three tiers
are advisory: the tier rides in the tool description the model sees
(`[mutate] …`) so it can weigh the call, but nothing stops it.

So the question when tiering is **not** "is this irreversible?" — it is _"must a
human see this before it happens?"_ If yes, it is `export`, whatever the verb
is. An app that wants every write confirmed puts every write at `export`; the
alternative is a command that reads as guarded and isn't, and the failure is
silent — it just runs.

Worth an explicit test: enumerate the registered manifest and fail on anything
outside an allowlist of genuinely read-only commands that is not `export`. A
command quietly retiered down becomes an ungated write while a suite that only
checks the chip's behaviour stays green.

---

## How a turn actually works

```
visitor types  →  panel.js sends the message to the gateway
                  AND pushes a fresh describeView over the bridge
                          ↓
gateway assembles the turn (history + RAG + your describeView)
                          ↓
model calls a tool  →  mcp-shim.js  →  POST /bridge/call
                          ↓
gateway → open WebSocket → the visitor's tab → your registry → HostApp
                          ↓
result travels back the same way; model composes an answer; SSE streams it
```

Two consequences worth internalising:

- **The tab must be open** for tool calls to work. With no tab, the gateway
  answers `{ ok: false }` and the model reports an honest failure instead of
  hanging.
- **The tool list is live.** Every `tools/list` re-fetches your manifest, so
  capabilities appear and disappear as your app changes — no gateway restart,
  no redeploy.

## Demo mode

An unlicensed gateway caps each namespace at **5 distinct visitor threads**.
Existing threads keep working; only minting a _new_ one is refused, with `402`
and a contact address. `chat-client.js` surfaces that as a readable message
rather than a generic failure.

That's plenty to evaluate with and hits immediately in production, which is the
point. See [`LICENSE`](../../LICENSE).

## Optional extras

Not in this kit, but in the guide:

- **Selection as context** — let a visitor highlight part of the page and ask
  about it (guide §4.6).
- **Executor proxy** — reverse-proxy your app's own API through the gateway so
  the tab talks to a single origin (`executorProxy` in the example config).
