@aibranch/llm-meta-widget 0.10.1 → 0.11.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.
package/README.md CHANGED
@@ -12,13 +12,13 @@ npm for any host, and in a Rails gem whose helper serves it for you.
12
12
 
13
13
  **No** Devise, DB migrations, ChatManager, or PromptNavigator. The gem adds only `rails >= 8.0` as a runtime dep — so hosts that haven't bumped to 8.1 can adopt it without a Rails upgrade — and hosts that do not use Rails do not need the gem.
14
14
 
15
- ## What you need first
15
+ ## Requirement
16
16
 
17
- The widget is a browser front end: it needs something to answer the chat. That
18
- can be **[llm_meta_server](https://github.com/pubannotation)** — a hub that
19
- holds provider credentials and streams responses back — or **an Ollama**,
20
- which the widget talks to directly with no server component of ours in
21
- between.
17
+ The widget is a browser front end: it needs something to answer the chat. One
18
+ option is **[llm_meta_server](https://github.com/pubannotation)**, a hub that
19
+ holds provider credentials and streams responses back. The other is **an
20
+ Ollama**, which the widget talks to directly, with no server component of ours
21
+ in between.
22
22
 
23
23
  Those are separate from where tools come from. A hub also registers MCP tools
24
24
  (Class 1 below), and you can point at one for tools while a local Ollama
@@ -32,29 +32,39 @@ step, no Node at runtime.
32
32
  |---|---|---|
33
33
  | A browser with custom elements + ES modules | any current | always |
34
34
  | An llm_meta_server **or** an Ollama | any | always — something has to answer the chat |
35
- | Ruby | >= 3.2 | only if you install the gem |
36
- | Rails | >= 8.0 (8.1 not required) | only if you install the gem |
37
35
 
38
- ## Two ways to add the widget — choose one
36
+ Nothing is required of your server: the widget runs in the browser, and your
37
+ page only has to send the file and write one tag. The Rails gem is one
38
+ convenient way to do that, not a requirement — it needs Ruby >= 3.2 and
39
+ Rails >= 8.0, and only if you choose it.
39
40
 
40
- You do **not** need both. Pick the row that matches your app, then read only
41
- that section.
41
+ ## Installation — choose one
42
42
 
43
- | Your app | What you add | What you write |
43
+ You need only one. Find the row whose condition matches your app, then read only
44
+ that option's section.
45
+
46
+ | Option | Use it when | What you do |
44
47
  |---|---|---|
45
- | **Rails** | the gem, one line in your `Gemfile` | one helper call in a view. The helper writes the `<llm-meta-widget>` tag for you, so you never write that tag yourself |
46
- | **Anything else** — Python, Go, PHP, plain HTML… | one `<script>` tag from the CDN, or an npm dependency you bundle | the `<llm-meta-widget>` tag yourself |
48
+ | **gem** | your app is Rails | add one line to your `Gemfile`, then make one helper call in a view. The helper writes the `<llm-meta-widget>` tag for you, so you never write that tag yourself |
49
+ | **npm** | your app already builds JavaScript — Vite, webpack, esbuild, Next.js… | install the package, import it once at your entry point, and write the `<llm-meta-widget>` tag yourself |
50
+ | **CDN** | neither of the above applies — Python, Go, PHP, plain HTML… | add one `<script>` tag and write the `<llm-meta-widget>` tag yourself. No install, no build step |
51
+
52
+ All three load the same file and run the same widget. The first two pin the
53
+ version for you — Bundler for the gem, your lockfile for npm — which is why they
54
+ come first here. The CDN pins only a range written into a URL, so the last row
55
+ trades that check for needing nothing installed.
47
56
 
48
- Both ways load the same file and run the same widget.
57
+ ## Option 1. gem (when your app is Rails)
49
58
 
50
- ## Rails apps: install the gem
59
+ **Not using Rails?** Skip to Option 2 if your app builds JavaScript, or to
60
+ Option 3 if it does not.
51
61
 
52
62
  ```ruby
53
63
  # Gemfile
54
- gem "llm_meta_widget", "~> 0.7"
64
+ gem "llm_meta_widget", "~> 0.11.1"
55
65
  ```
56
66
 
57
- ```
67
+ ```bash
58
68
  bundle install
59
69
  ```
60
70
 
@@ -83,7 +93,7 @@ simpler way to deliver and configure the widget:
83
93
  - **The defaults are set for you**, so your view names only the options you
84
94
  want to change.
85
95
 
86
- ## Rails apps: a minimal working example
96
+ ### A minimal working example for Rails apps
87
97
 
88
98
  Put this on any view — a fresh `pages/demo.html.erb` is fine:
89
99
 
@@ -91,75 +101,19 @@ Put this on any view — a fresh `pages/demo.html.erb` is fine:
91
101
  <h1>Demo</h1>
92
102
 
93
103
  <%= llm_meta_widget(llm_url: "https://your-meta-server.example",
94
- model: "qwen3-6-35b-fast",
104
+ model: "gpt-oss-120b",
95
105
  greeting: "Hi — ask me anything about this page.") %>
96
106
  ```
97
107
 
98
108
  Start the app, open that page, and click the round toggle at the bottom right.
99
- **Success looks like:** a panel opens showing your greeting, you type "hello",
100
- the role label turns into a spinning gear reading "Working…", and the reply
101
- streams in.
102
-
103
- If the panel opens but nothing comes back, it is almost always CORS. The hub
104
- must list your app's origin in its `CORS_ORIGINS` environment variable for
105
- the `/api/*` resource, because the widget calls the hub **from the visitor's
106
- browser**, not from your server. Open the browser console: a blocked request
107
- says so explicitly.
108
-
109
- Two more things worth knowing before you go further:
110
-
111
- - `llm_url` must be reachable from your visitors' browsers, not just from
112
- your server. `localhost` works only while you are the visitor — which is
113
- fine when each visitor runs their own Ollama.
114
- - The model name is the hub's name for it (`GET /api/llms` lists them), not
115
- the provider's.
109
+ What success looks like, and what to check when nothing comes back, is in
110
+ "When it does not answer" below — the causes are the same for every route.
116
111
 
117
112
  Once that works, the widget can talk, but it cannot *do* anything yet. To let the
118
113
  LLM act on your page or call your own services, declare tools using any of the
119
114
  **three tool classes** below.
120
115
 
121
- ## Non-Rails hosts: write the element yourself
122
-
123
- **If your app is Rails, you can skip this section.** The gem already does
124
- everything described here: its helper writes this tag for you and its engine
125
- sends this file. Read on if your app is not Rails — or if you use Rails but
126
- prefer the CDN to the gem, which the `element_path:` option allows.
127
-
128
- The widget is a custom element in a single self-contained ES module, so a host
129
- that is not Rails needs no gem, no template engine and no asset pipeline — two
130
- lines of HTML:
131
-
132
- ```html
133
- <script type="module" src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.8"></script>
134
- <llm-meta-widget llm-url="https://your-meta-server.example"
135
- model="qwen3-6-35b-fast"
136
- greeting="Hi — ask me anything about this page."></llm-meta-widget>
137
- ```
138
-
139
- This is the same configuration as the Rails example above, written in HTML
140
- instead of ERB. Both produce the same widget, so the advice in that section
141
- — CORS, a reachable `llm_url`, the hub's name for the model — applies here
142
- too.
143
-
144
- **`@0.8` is a version range, and it stops at 0.8.x on purpose.** You receive bug
145
- fixes without editing your page, and a release that breaks your page cannot
146
- arrive on its own.
147
-
148
- Moving to 0.9 is therefore a change you make yourself. Read
149
- [CHANGELOG.md](CHANGELOG.md) before you do, because this is where what your page
150
- must provide can change. For example, 0.8.0 changed the shape of
151
- `window.aiState`. A page that used the new shape while still loading `@0.7`
152
- would have shown an error string in place of every value, with no other warning.
153
-
154
- A Rails app is protected from that mismatch, because the `Gemfile` will not
155
- resolve a widget version the page cannot use. A page that loads the widget from
156
- the CDN has no such check, so the version you write in that URL is the only
157
- protection you have.
158
-
159
- Nothing else is needed: the stylesheets and the markdown renderer are bundled
160
- in, and the element injects its own styles. The host serves no CSS and no JS.
161
-
162
- ### If you have a JS build pipeline, install it instead
116
+ ## Option 2. npm (if you bundle your JavaScript)
163
117
 
164
118
  Vite, webpack, esbuild, Next.js, SvelteKit, Astro — if your app already builds
165
119
  JavaScript, take the widget as a dependency rather than a script tag:
@@ -174,8 +128,8 @@ npm install @aibranch/llm-meta-widget
174
128
  import "@aibranch/llm-meta-widget";
175
129
  ```
176
130
 
177
- Then write the `<llm-meta-widget>` tag exactly as above — same attributes, same
178
- behaviour.
131
+ Then write the `<llm-meta-widget>` tag yourself — see "Writing the element
132
+ yourself" below for the tag and its attributes.
179
133
 
180
134
  **Why do this, when the script tag is only one line?** Because your build then
181
135
  checks the version for you. The version is pinned in your lockfile and installed by `npm ci`
@@ -185,15 +139,57 @@ that nothing checks. That matters most at a breaking release: 0.8.0 changed the
185
139
  shape of `window.aiState`, and a lockfile makes the upgrade a deliberate, visible
186
140
  step instead of a URL someone edits.
187
141
 
188
- The only cost is that this path needs a build pipeline. The CDN path needs none,
189
- which is why it is listed first.
142
+ The only cost is that this path needs a build pipeline. If your app does not
143
+ build JavaScript, the CDN route below needs none.
190
144
 
191
- Four ways to get that one file, in descending order of convenience:
145
+ ## Option 3. CDN
192
146
 
193
- - **the CDN**, as above — `@aibranch/llm-meta-widget` on npm, no path needed
194
- because the package's `main` is the bundle;
195
- - **npm install plus your own bundler**, as above — the same package, resolved
196
- through `exports` and pinned by your lockfile;
147
+ This is the shortest route and it works anywhere — Python, Go, PHP, plain
148
+ HTML, or Rails if you would rather not add the gem (the `element_path:` option
149
+ allows that). It needs no install, no build step and no Node at runtime.
150
+
151
+ The widget is a custom element in a single self-contained ES module, so the
152
+ whole integration is two lines of HTML:
153
+
154
+ ```html
155
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.11"></script>
156
+ <llm-meta-widget llm-url="https://your-meta-server.example"
157
+ model="gpt-oss-120b"
158
+ greeting="Hi — ask me anything about this page."></llm-meta-widget>
159
+ ```
160
+
161
+ **`@0.11` is a version range, and it stops at 0.11.x on purpose.** You receive
162
+ bug fixes without editing your page, and a release that breaks your page cannot
163
+ arrive on its own.
164
+
165
+ Moving to 0.12 is therefore a change you make yourself. Read
166
+ [CHANGELOG.md](CHANGELOG.md) before you do, because this is where what your page
167
+ must provide can change. For example, 0.10.0 changed how the widget finds a page
168
+ card: by media type rather than by element id. A page that declared its card the
169
+ new way while still loading `@0.9` lost every tool, prompt and resource it had
170
+ declared. Nothing failed, anywhere. The CHANGELOG entry for 0.10.0 describes it.
171
+
172
+ A Rails app is protected from that mismatch, because the `Gemfile` will not
173
+ resolve a widget version the page cannot use. A page that loads the widget from
174
+ the CDN has no such check, so the version you write in that URL is the only
175
+ protection you have.
176
+
177
+ Nothing else is needed: the stylesheets and the markdown renderer are bundled
178
+ in, and the element injects its own styles. The host serves no CSS and no JS.
179
+
180
+ ## Writing the element yourself
181
+
182
+ Both the npm and CDN routes end the same way: your page writes one
183
+ `<llm-meta-widget>` tag. This section is the reference for that tag, whichever
184
+ route brought you here. (Rails users can skip it — the helper writes the tag.)
185
+
186
+ The tag needs one file: the bundled custom element. Four ways to get it, in
187
+ descending order of convenience:
188
+
189
+ - **the CDN** — `@aibranch/llm-meta-widget` on npm, no path needed because the
190
+ package's `main` is the bundle;
191
+ - **npm install plus your own bundler** — the same package, resolved through
192
+ `exports` and pinned by your lockfile;
197
193
  - **the gem**, which serves the identical file at
198
194
  `/llm_meta_widget_assets/llm-meta-widget.js` for Rails hosts, and is what the
199
195
  `llm_meta_widget` helper points at;
@@ -202,9 +198,8 @@ Four ways to get that one file, in descending order of convenience:
202
198
 
203
199
  Going without the gem removes the Rails parts: the helper and the partial. It
204
200
  does not change what your page itself must provide, because those parts belong to
205
- the page, not to Rails. You declare the `#ai-actions` JSON block, `window.aiState`
206
- and `window.aiActions` exactly as a Rails page does — see the three tool classes
207
- below.
201
+ the page, not to Rails. You declare the page card and its handler maps exactly as
202
+ a Rails page does — see the three tool classes below.
208
203
 
209
204
  **Attributes** match the helper's keyword options one to one. Three of them are
210
205
  easy to get wrong:
@@ -235,21 +230,110 @@ result is committed and shipped in the gem, because a gem cannot run a build
235
230
  step on install — and `npm test` fails if the committed bundle has drifted from
236
231
  its sources.
237
232
 
233
+ ## When it does not answer
234
+
235
+ **Success looks like this:** the panel opens showing your greeting, you type
236
+ "hello", the role label turns into a spinning gear reading "Working…", and the
237
+ reply streams in.
238
+
239
+ If the panel opens but nothing comes back, it is almost always CORS. The hub
240
+ must list your app's origin in its `CORS_ORIGINS` environment variable for
241
+ the `/api/*` resource, because the widget calls the hub **from the visitor's
242
+ browser**, not from your server. Open the browser console: a blocked request
243
+ says so explicitly.
244
+
245
+ Two more things worth knowing before you go further:
246
+
247
+ - `llm_url` must be reachable from your visitors' browsers, not just from
248
+ your server. `localhost` works only while you are the visitor — which is
249
+ fine when each visitor runs their own Ollama.
250
+ - The model name is the hub's name for it (`GET /api/llms` lists them), not
251
+ the provider's.
252
+
238
253
  ## Three tool classes
239
254
 
240
255
  The widget classifies every tool_call the LLM emits by name and dispatches to one of three execution paths. Each class has a different declaration, different visibility to the LLM, and different execution semantics.
241
256
 
242
- ### Class 3 — Page-embedded actions (`window.aiActions`)
257
+ ### Class 3 — Page-embedded primitives (the page card)
243
258
 
244
259
  Declared inline on the same view as the widget. Runs as JavaScript in the browser — no HTTP hop, no server involvement. Best for actions that mutate the page's own state ("click this button", "add to selection", "scroll to X").
245
260
 
246
- **Declaration** — three script blocks on the view:
261
+ The same block declares the page's other primitives too: resources, which are what the model may read from the page, and prompts, which are templates the visitor can run. So everything the page offers is in one place, and each kind has one handler map.
262
+
263
+ **Declaration — the page card.** The page declares what it offers in one block
264
+ of JSON, found by its media type, and implements it in one handler map per
265
+ primitive type:
266
+
267
+ ```html
268
+ <script type="application/mcp-static-primitives+json">
269
+ {
270
+ "name": "org.example/your-page",
271
+ "remotes": [ { "type": "page-global", "global": "mcp" } ],
272
+ "_meta": { "io.modelcontextprotocol/static-primitives": {
273
+ "tools": [
274
+ { "name": "add_dictionaries",
275
+ "description": "Add the named dictionaries to the current selection.",
276
+ "inputSchema": {
277
+ "type": "object",
278
+ "properties": { "names": { "type": "array", "items": { "type": "string" } } },
279
+ "required": [ "names" ] } }
280
+ ],
281
+ "resources": [
282
+ { "uri": "page:selected_dictionaries",
283
+ "name": "Selected dictionaries",
284
+ "description": "the dictionaries the user has chosen for annotation",
285
+ "volatility": "volatile" }
286
+ ]
287
+ } }
288
+ }
289
+ </script>
290
+
291
+ <script>
292
+ // The card named this global; each map inside it is keyed by the identifier
293
+ // its own MCP operation takes — a tool by name, a resource by uri.
294
+ window.mcp = {
295
+ tools: { add_dictionaries: function (args) { /* mutate the page */ } },
296
+ resources: { "page:selected_dictionaries": function () { return getSelected(); } }
297
+ };
298
+ </script>
299
+ ```
300
+
301
+ A tool's schema may be spelled `inputSchema`, as MCP spells it, or
302
+ `input_schema`. Both are accepted.
303
+
304
+ `remotes` is how a consumer other than this widget finds your handlers, so the
305
+ global is declared rather than assumed. Leave it out and the widget reads
306
+ `window.aiActions`, `window.aiPrompts` and `window.aiResources` instead — see
307
+ "Say where your handlers live".
308
+
309
+ A page may declare `prompts` in the same block. See [The page card](#the-page-card)
310
+ for prompts, for the size and volatility hints on a resource, and for what
311
+ happens when a document holds several blocks.
312
+
313
+ **What the LLM sees.** Declared tools are passed to the meta-server as
314
+ `local_tools` for every turn. Every declared resource is read and rendered into
315
+ the `Current page state:` block of the system prompt, one line per resource,
316
+ with its description beside its value:
317
+
318
+ ```
319
+ Current page state:
320
+ - Selected dictionaries (the dictionaries the user has chosen for annotation): ["uberon"]
321
+ ```
322
+
323
+ So the LLM can answer from page state directly instead of tool-calling for a
324
+ lookup, and it knows what each value *means* rather than guessing from a key.
325
+ A resource marked `"volatility": "volatile"` is re-read every turn, which is
326
+ what a form field needs: a value read once at boot is already stale by the time
327
+ anyone sends a message.
328
+
329
+ **The older channels — `#ai-actions` and `window.aiState`.** These came before
330
+ the page card and still work. A page that declares no card, or a card with no
331
+ `tools`, falls back to them. Use them if you have them already; use the card for
332
+ a new page, because it is the only channel that can also declare prompts and
333
+ resources.
247
334
 
248
335
  ```html
249
- <!-- (a) Tool schemas the LLM sees. JSON Schema vocabulary — in practice
250
- type, properties, required, description, items, enum. No $schema is
251
- declared and nothing here validates one: the object is forwarded to
252
- the provider as-is, and each provider accepts its own subset. -->
336
+ <!-- Tool schemas. Note input_schema, not inputSchema, in this channel. -->
253
337
  <script type="application/json" id="ai-actions">
254
338
  [
255
339
  { "name": "add_dictionaries",
@@ -257,28 +341,22 @@ Declared inline on the same view as the widget. Runs as JavaScript in the browse
257
341
  "input_schema": {
258
342
  "type": "object",
259
343
  "properties": { "names": { "type": "array", "items": { "type": "string" } } },
260
- "required": ["names"]
344
+ "required": [ "names" ]
261
345
  } }
262
346
  ]
263
347
  </script>
264
348
 
265
- <!-- (b) State readers folded into the LLM's system prompt EVERY turn, -->
266
- <!-- so the LLM can answer from page state without a tool_call. -->
267
- <!-- Each reader declares what it returns, like every other channel. -->
349
+ <!-- State readers, folded into the system prompt every turn. -->
268
350
  <script>
269
351
  window.aiState = {
270
352
  text: {
271
353
  description: "the text the user wants to annotate",
272
354
  read: function() { return $("#text").val() || ""; }
273
- },
274
- selected_dictionaries: {
275
- description: "the dictionaries the user has chosen for annotation",
276
- read: function() { return getSelected(); }
277
355
  }
278
356
  };
279
357
  </script>
280
358
 
281
- <!-- (c) Action implementations invoked when the LLM emits a matching tool_call. -->
359
+ <!-- Action implementations. The card uses this same map. -->
282
360
  <script>
283
361
  window.aiActions = {
284
362
  add_dictionaries: function(args) { /* mutate the page here */ }
@@ -286,26 +364,28 @@ Declared inline on the same view as the widget. Runs as JavaScript in the browse
286
364
  </script>
287
365
  ```
288
366
 
289
- **What the LLM sees.** The `ai-actions` JSON is passed to the meta-server as `local_tools` for every turn. Alongside it, every reader in `window.aiState` is invoked (each turn) and rendered into a `Current page state:` block appended to the system prompt, one line per reader, with its description beside its value:
367
+ Where both a card and these blocks exist, the card's `tools` replace the
368
+ `#ai-actions` block. They are not merged. Two sources for one list would leave a
369
+ reader guessing which one the page meant. For the same reason, an explicit
370
+ `window.aiState` entry is used in place of a page resource of the same name.
290
371
 
291
- ```
292
- Current page state:
293
- - text (the text the user wants to annotate): "The stomach was examined."
294
- - selected_dictionaries (the dictionaries the user has chosen for annotation): ["uberon"]
295
- ```
296
-
297
- So the LLM can answer from state directly instead of tool-calling for lookups, and knows what each value *means* rather than guessing from the key.
298
-
299
- **Breaking change in 0.8.0.** A reader must be `{ description, read }`. The bare-function form — `text: function() {…}` — is removed, with no fallback. A key that still uses it is reported to the console by name, with the shape it should have, and skipped; the page's other readers keep working. Descriptions are not decoration: every other channel the widget declares to the model already carries one (Class 3 tool schemas, Class 2 `.well-known` tools, Class 1 hub tools, resources), and state readers were the exception.
372
+ **Breaking change in 0.8.0.** An `aiState` reader must be
373
+ `{ description, read }`. The bare-function form — `text: function() {…}` — is
374
+ removed, with no fallback. A key that still uses it is reported to the console by
375
+ name, with the shape it should have, and skipped; the page's other readers keep
376
+ working. Descriptions are not decoration: every other channel the widget
377
+ declares to the model already carries one, and state readers were the exception.
300
378
 
301
379
  **When the tool_call fires.** During the turn, as soon as the LLM emits the
302
- call. Since 0.4.0 the action's outcome — `{ok: true, applied: "<name>"}`, or
303
- the error message if it raised — is fed back to the LLM as a tool result, and
304
- the turn continues. Before 0.4.0 these were fire-and-forget, which meant a
305
- turn whose only calls were page actions ended there: any task shaped *change
306
- the page, then do something with it* was cut off after the write. The change
307
- also means the LLM can see that an action failed and correct itself, instead
308
- of the failure being visible only to the visitor.
380
+ call. Since 0.4.0 the action's result is given back to the model and the turn
381
+ continues. The result is `{ok: true, applied: "<name>"}`, or the error message
382
+ if the action raised one.
383
+
384
+ Before 0.4.0 the widget did not wait for that result. A turn whose only calls
385
+ were page actions therefore ended at the write, and a task of the form *change
386
+ the page, then do something with it* stopped half way. The model can now also
387
+ see that an action failed and correct itself, instead of the failure being
388
+ visible only to the visitor.
309
389
 
310
390
  Budget for it: every page action now costs a round, so a flow that writes twice
311
391
  and then calls a tool needs roughly five or six. See `max_rounds:` below.
@@ -337,11 +417,11 @@ Declared once per host origin via a well-known manifest. Widget fetches the mani
337
417
 
338
418
  **Auto-discovery.** By default the widget fetches `<same-origin>/.well-known/mcp.json` at boot. To override, pass `well_known_urls:` to the helper: an explicit array (e.g. `["https://other.example/.well-known/mcp.json"]`) or `[]` to disable entirely.
339
419
 
340
- **What the LLM sees.** Every tool listed in every discovered manifest is exposed as a callable. Names must be unique across manifests (if a name collides with a Class 1 or Class 3 tool, Class 3 wins, then Class 2, then Class 1).
420
+ **What the LLM sees.** Every tool listed in every discovered manifest is offered to the model. Names must be unique across manifests. If two classes declare the same name, the widget uses Class 3 first, then Class 2, then Class 1.
341
421
 
342
- **When the tool_call fires.** Synchronously during the turn — widget POSTs `{ jsonrpc, method: "tools/call", params: { name, arguments } }` to the tool's URL, awaits the JSON-RPC response, and feeds the result back to the LLM as tool output for the next round. Standard MCP loop.
422
+ **When the tool_call fires.** During the turn. The widget POSTs `{ jsonrpc, method: "tools/call", params: { name, arguments } }` to the tool's URL and waits for the JSON-RPC response. It then gives the result to the model as tool output for the next round. This is the standard MCP loop.
343
423
 
344
- **Error path.** HTTP 4xx/5xx, network failure, or an MCP JSON-RPC error → widget marks that dispatch as errored; visitor sees a red chip in the message footer; the LLM's next round sees the error as the tool's return value (so it can explain what went wrong instead of pretending it worked).
424
+ **Error path.** An HTTP 4xx or 5xx, a network failure, or an MCP JSON-RPC error all count as a failed call. The visitor sees a red chip in the message footer. The model receives the error as the tool's return value, so it can explain what went wrong instead of pretending the call worked.
345
425
 
346
426
  ### Class 1 — Hub-registered MCP (via the meta-server)
347
427
 
@@ -351,15 +431,15 @@ Declared out-of-band on the meta-server (via the hub's admin UI or `/user/:id/mc
351
431
 
352
432
  **What the LLM sees.** Only tools the visitor has **enabled via the tool picker** (see "Level-1 pickers" below) — nothing is auto-selected, the visitor opts in per session. A page can set the starting selection instead, with `remote_tools_schema_id:`: those tools arrive already ticked, and the visitor can still untick them.
353
433
 
354
- **When the tool_call fires.** Synchronously during the turn — widget POSTs to the hub's `/api/llm_api_keys/:uuid/models/:name/single_llm_calls` endpoint with `tool_ids: [...]`; the hub proxies to each MCP server and streams results back through SSE.
434
+ **When the tool_call fires.** During the turn. The widget POSTs to the hub's `/api/llm_api_keys/:uuid/models/:name/single_llm_calls` endpoint with `tool_ids: [...]`. The hub calls each MCP server and streams the results back over SSE.
355
435
 
356
- **Error path.** Hub-side errors (rate limit, timeout, MCP server unavailable, upstream failure) arrive as SSE `event: error` frames with typed codes (`mcp_unavailable`, `timeout`, `rate_limit`, …); the widget surfaces them in the message bubble with a per-code prefix.
436
+ **Error path.** Errors from the hub arrive as SSE `event: error` frames, each with a code such as `mcp_unavailable`, `timeout` or `rate_limit`. They cover a rate limit, a timeout, an MCP server that is unavailable, and a failure further upstream. The widget shows each one in the message bubble, with a prefix for its code.
357
437
 
358
438
  ## Level-1 pickers
359
439
 
360
440
  By default the widget renders two pickers below the input textarea:
361
441
 
362
- - **Model dropdown** — populated from the hub's `GET /api/llms` (anon path returns Ollama-only, since the widget's LLM calls use `api_key_uuid: "ollama-local"`).
442
+ - **Model dropdown** — populated from the hub's `GET /api/llms`. The anonymous path returns every model the hub serves free, which may include hosted ones, not only a local Ollama.
363
443
  - **Tool picker** — populated from the hub's `GET /api/mcp_servers` (anon path returns `public_to_anonymous: true` servers). Two-level UX: server bulk-toggle + individual tool checkboxes on expand.
364
444
 
365
445
  Both pickers require **CORS**, the same way the chat itself does: the hub must list your page's origin in its `CORS_ORIGINS` environment variable for the `/api/*` resource. Without that the fetches are blocked silently and both pickers simply stay empty.
@@ -370,10 +450,10 @@ To adjust picker behavior at the helper call site:
370
450
  <%= llm_meta_widget(
371
451
  llm_url: "https://your-meta-server.example",
372
452
  tool_hub_url: "https://your-meta-server.example",
373
- model: "qwen3-6-35b-fast", # initial selection
453
+ model: "gpt-oss-120b", # initial selection
374
454
  enable_model_picker: true, # false → omit picker, use fixed `model:`
375
455
  enable_tool_picker: true, # false → omit picker, no Class-1 tools
376
- models: nil, # nil = all anon models; ["qwen3-6-35b-fast", …] = allowlist
456
+ models: nil, # nil = all anon models; ["gpt-oss-120b", …] = allowlist
377
457
  hub_tools: nil # nil = all anon-public MCPs; ["togomcp", …] = allowlist by server name
378
458
  ) %>
379
459
  ```
@@ -401,11 +481,11 @@ translate directly.
401
481
  | `model:` | required | Initial model (also the fallback when picker is disabled) |
402
482
  | `api_key_uuid:` | `"ollama-local"` | Hub API-key uuid to invoke |
403
483
  | `element_path:` | `"/llm_meta_widget_assets/llm-meta-widget.js"` | The bundled element the page loads. Served by the gem's engine; override to load it from elsewhere |
404
- | `actions_schema_id:` | `"ai-actions"` | DOM id of the Class-3 schema block |
484
+ | `actions_schema_id:` | `"ai-actions"` | DOM id of the older Class-3 schema block, used when the page declares no card tools |
405
485
  | `page_card_id:` | `"ai-page"` | **Fallback only.** A card is normally found by its media type (see "The page card"); this id is read when the page has no block with that type. Its tools replace the `actions_schema_id` block |
406
486
  | `prompts_global:` | `"aiPrompts"` | Global object holding page prompt handlers, keyed by prompt name |
407
487
  | `resources_global:` | `"aiResources"` | Global object holding page resource readers, keyed by resource URI |
408
- | `state_global:` | `"aiState"` | Global window object holding Class-3 state readers, each `{ description, read }` (see the breaking change in 0.8.0) |
488
+ | `state_global:` | `"aiState"` | Global holding the older Class-3 state readers, each `{ description, read }` (see the breaking change in 0.8.0). A page card declares state as resources instead |
409
489
  | `actions_global:` | `"aiActions"` | Global window object holding Class-3 implementations |
410
490
  | `remote_tools_schema_id:` | `"remote-mcp-tools"` | DOM id of a JSON block listing Class-1 tools (`{id, name, description, input_schema}`) to start with. They seed the picker rather than bypassing it — shown ticked, and the visitor may untick them. Read once, at boot |
411
491
  | `well_known_urls:` | `nil` | `nil` = auto-discover same-origin; explicit array = fetch those; `[]` = disable |
@@ -419,34 +499,49 @@ translate directly.
419
499
  ## The page card
420
500
 
421
501
  Since 0.9.0 a page can declare what it offers in one block of JSON, instead of
422
- using a separate channel for each kind of thing. The block uses the same envelope
423
- an MCP server puts in its Server Card, so the page and the server describe
424
- themselves the same way.
502
+ using a separate channel for each kind of thing. **That block is a Server Card** —
503
+ the same document an MCP server publishes — whose transport is the page itself.
504
+ So the page and the server describe themselves in one format, and one parser
505
+ reads both.
506
+
507
+ Since 0.10.0 the block is found by its media type. Since 0.11.0 a page can also
508
+ name the global that holds its handlers. Both are below.
425
509
 
426
510
  ```html
427
511
  <script type="application/mcp-static-primitives+json">
428
- { "_meta": { "io.modelcontextprotocol/static-primitives": {
512
+ {
513
+ "name": "org.example/your-page",
514
+ "remotes": [ { "type": "page-global", "global": "mcp" } ],
515
+ "_meta": { "io.modelcontextprotocol/static-primitives": {
429
516
  "tools": [ { "name": "set_text", "description": "…", "inputSchema": { } } ],
430
517
  "prompts": [ { "name": "annotate", "description": "Annotate this page" } ],
431
518
  "resources": [ { "uri": "page:text", "name": "Text", "description": "the text to annotate",
432
519
  "volatility": "volatile" } ]
433
- } } }
520
+ } }
521
+ }
434
522
  </script>
435
523
  ```
436
524
 
437
- The card says what exists. Three global objects say what it does, and each one is
438
- keyed by the identifier that the matching MCP operation takes:
525
+ `name` identifies the card, as it does for a server. `remotes` says where the
526
+ handlers live — see "Say where your handlers live" below. Only the `_meta`
527
+ block is required by this widget; the rest is what makes the card readable by
528
+ anything else.
529
+
530
+ The card says what exists. Three maps say what it does, and each one is keyed by
531
+ the identifier that the matching MCP operation takes:
439
532
 
440
533
  | declared in the card | implemented in | keyed by |
441
534
  |---|---|---|
442
- | `tools` | `window.aiActions` | tool `name`, as `tools/call` takes |
443
- | `prompts` | `window.aiPrompts` | prompt `name`, as `prompts/get` takes |
444
- | `resources` | `window.aiResources` | resource `uri`, as `resources/read` takes |
535
+ | `tools` | `tools` under your declared global, else `window.aiActions` | tool `name`, as `tools/call` takes |
536
+ | `prompts` | `prompts` under it, else `window.aiPrompts` | prompt `name`, as `prompts/get` takes |
537
+ | `resources` | `resources` under it, else `window.aiResources` | resource `uri`, as `resources/read` takes |
445
538
 
446
539
  ```js
447
- window.aiActions = { set_text: (args) => { /* change the page */ } }
448
- window.aiPrompts = { annotate: (args) => "Annotate this: " + args.text }
449
- window.aiResources = { "page:text": () => document.querySelector("#text").value }
540
+ window.mcp = {
541
+ tools: { set_text: (args) => { /* change the page */ } },
542
+ prompts: { annotate: (args) => "Annotate this: " + args.text },
543
+ resources: { "page:text": () => document.querySelector("#text").value }
544
+ }
450
545
  ```
451
546
 
452
547
  A prompt handler may return a plain string or the full `messages` array that
@@ -466,6 +561,37 @@ fetch from the web by itself — which page resources are not. The form is
466
561
  `page:<name>` with no path, because a page has only one namespace. Expect this to
467
562
  change.
468
563
 
564
+ **Say where your handlers live.** A card can declare the global that holds
565
+ them, the way a Server Card declares its endpoint. Then a consumer that has
566
+ never heard of this widget can read your card, find your handlers and call
567
+ them:
568
+
569
+ ```html
570
+ <script type="application/mcp-static-primitives+json">
571
+ { "name": "org.example/your-page",
572
+ "remotes": [ { "type": "page-global", "global": "mcp" } ],
573
+ "_meta": { "io.modelcontextprotocol/static-primitives": { "tools": [ ... ] } } }
574
+ </script>
575
+ <script>
576
+ window.mcp = {
577
+ tools: { set_text: function (args) { } },
578
+ prompts: { annotate: function (args) { return "..." } },
579
+ resources: { "page:text": function () { return "..." } }
580
+ };
581
+ </script>
582
+ ```
583
+
584
+ The name is yours to choose, and it is declared rather than assumed: this code
585
+ runs on your page, where a short fixed name such as `mcp` could collide with
586
+ something of your own.
587
+
588
+ Without that declaration the widget reads `window.aiActions`,
589
+ `window.aiPrompts` and `window.aiResources`, as before. Those names are known
590
+ only to this widget, and they can be renamed per page with the options below,
591
+ so no other consumer can rely on them. Each kind falls back on its own, so you
592
+ can move tools into the declared global and leave prompts and resources where
593
+ they are.
594
+
469
595
  **The card is found by its media type.** A client matches
470
596
  `script[type="application/mcp-static-primitives+json"]`, so the element needs no
471
597
  `id`. This is how a client that was never told about your page can still read
@@ -491,15 +617,15 @@ loses the conversation. The transcript, the record of which tools ran, and the
491
617
  model's own context are kept in `sessionStorage`: per tab, gone when the tab
492
618
  closes, never sent anywhere.
493
619
 
494
- The key is the page's **path**, not its full URL, so a form submit that returns
495
- to the same page with a new query string keeps the thread, while moving to a
496
- different page starts a fresh one. Nothing to configure.
620
+ The key is the page's **path**, not its full URL. A form submit that returns to
621
+ the same page with a new query string therefore keeps the same conversation.
622
+ Moving to a different page starts a new one. There is nothing to configure.
497
623
 
498
624
  Two consequences worth knowing. An action of yours that navigates is now safe
499
625
  to declare — before this, offering one meant offering to wipe the visitor's
500
- chat. And if the navigation lands on an error page that does not render the
501
- widget, the conversation is still in storage but there is no panel to show it
502
- until the visitor returns to a page that has one.
626
+ chat. And if the navigation lands on an error page that renders no widget, the
627
+ conversation is still in storage. There is no panel to show it until the visitor
628
+ returns to a page that has one.
503
629
 
504
630
  ## Choosing what answers the chat, and whose tools to offer
505
631
 
@@ -510,10 +636,10 @@ does is what its call site says.
510
636
  ```erb
511
637
  <%# a hub answers the chat; its registered tools are offered too %>
512
638
  <% hub = "https://your-meta-server.example" %>
513
- <%= llm_meta_widget(llm_url: hub, tool_hub_url: hub, model: "qwen3-6-35b-fast") %>
639
+ <%= llm_meta_widget(llm_url: hub, tool_hub_url: hub, model: "gpt-oss-120b") %>
514
640
 
515
641
  <%# a hub answers the chat; no hub-registered tools %>
516
- <%= llm_meta_widget(llm_url: hub, model: "qwen3-6-35b-fast") %>
642
+ <%= llm_meta_widget(llm_url: hub, model: "gpt-oss-120b") %>
517
643
 
518
644
  <%# the visitor's own Ollama answers; a hub still supplies its tools %>
519
645
  <%= llm_meta_widget(llm_url: "http://localhost:11434", llm_provider: :ollama,
@@ -524,12 +650,14 @@ does is what its call site says.
524
650
  model: "qwen3.8:27b") %>
525
651
  ```
526
652
 
527
- **The Ollama path.** The widget POSTs to `{llm_url}/api/chat` and reads
528
- Ollama's NDJSON stream; tool schemas go over as OpenAI-shaped functions and
529
- `tool_calls` come back, so Class 2 and Class 3 tools work exactly as they do
530
- against a hub. The model picker lists `{llm_url}/api/tags`. Ollama must be
531
- told to accept your page's origin — `OLLAMA_ORIGINS=https://your-site.example`
532
- — which is the same CORS story as the hub, configured elsewhere.
653
+ **The Ollama path.** The widget POSTs to `{llm_url}/api/chat` and reads Ollama's
654
+ NDJSON stream. Tool schemas are sent in OpenAI's shape, and `tool_calls` come
655
+ back the same way, so Class 2 and Class 3 tools work exactly as they do against
656
+ a hub. The model picker lists `{llm_url}/api/tags`.
657
+
658
+ Ollama must be told to accept your page's origin:
659
+ `OLLAMA_ORIGINS=https://your-site.example`. This is the same CORS requirement
660
+ as the hub has, set in a different place.
533
661
 
534
662
  What you give up without a `tool_hub_url` is Class 1 only: tools registered on
535
663
  somebody's hub. That is not "no tools" — page actions and your own
@@ -543,9 +671,9 @@ no retention policy to write.
543
671
  ## Declaring resources and prompts (static-primitives extension)
544
672
 
545
673
  If you operate the MCP endpoint behind your `.well-known/mcp.json`, you can
546
- also offer **prompts** (one-click starting points, shown as buttons above the
547
- input) and **resources** (reference data the widget can put in the model's
548
- context). The widget reads two optional fields the
674
+ offer two more things. **Prompts** are one-click starting points, shown as
675
+ buttons above the input. **Resources** are reference data the widget can put in
676
+ the model's context. The widget reads two optional fields the
549
677
  `io.modelcontextprotocol/static-primitives` extension defines on a
550
678
  `resources/list` entry:
551
679
 
@@ -554,9 +682,9 @@ context). The widget reads two optional fields the
554
682
  "uri": "yourhost://catalog",
555
683
  "name": "Catalog",
556
684
  "mimeType": "application/json",
685
+ "size": 1298,
557
686
  "_meta": {
558
687
  "io.modelcontextprotocol/static-primitives": {
559
- "sizeBytes": 1298,
560
688
  "volatility": "stable",
561
689
  "autoAttach": true
562
690
  }
@@ -564,10 +692,14 @@ context). The widget reads two optional fields the
564
692
  }
565
693
  ```
566
694
 
567
- - **`sizeBytes`** — the byte length of the payload `resources/read` returns.
568
- The widget decides whether it can afford the resource *before* fetching it;
569
- over its budget (8 KB), the bytes never cross the wire and the LLM is left
570
- to ask for what it needs through your tools instead.
695
+ - **`size`** — the byte length of the payload `resources/read` returns. It is the
696
+ base MCP `Resource` field, not an extension field, so it sits at the top level
697
+ of the entry. Earlier versions of the extension carried `sizeBytes` inside
698
+ `_meta`; the widget still reads that when `size` is absent, but it will be
699
+ removed, so emit `size`.
700
+ The widget decides whether it can afford the resource *before* fetching it. If
701
+ the resource is over its budget of 8 KB, the bytes never cross the wire, and
702
+ the LLM asks for what it needs through your tools instead.
571
703
  - **`volatility`** — `"stable"` (read once, re-used) or `"volatile"` (re-read
572
704
  on every turn that uses it).
573
705
  - **`autoAttach`** — may the widget put this in the model's context without
@@ -578,9 +710,9 @@ that predates the extension: the resource is fetched, trimmed to budget and
578
710
  carried in the model's context.
579
711
 
580
712
  A prompt is offered as a button labelled by its `title`. Declare its arguments
581
- as **optional** and fill them from page state where you can: an argument the
582
- visitor must supply by hand makes the button useless to the visitor who needed
583
- it most, since they must already know your form to press it.
713
+ as **optional**, and fill them from page state where you can. An argument the
714
+ visitor must type in makes the button useless to the visitor who needed it most:
715
+ to press it, they would already have to know your form.
584
716
 
585
717
  ## Further reading
586
718
 
@@ -590,8 +722,9 @@ it most, since they must already know your form to press it.
590
722
  current draft shape.
591
723
  - **MCP itself** — <https://modelcontextprotocol.io>
592
724
  - **A worked adopter** — PubDictionaries' annotation page
593
- (<https://pubdictionaries.org/text_annotation>) runs this widget with all
594
- three tool classes plus prompts and resources. Its
725
+ (<https://pubdictionaries.org/text_annotation>) runs this widget with all three
726
+ tool classes, and declares its own tools, prompts and resources in one page
727
+ card. Its
595
728
  `app/views/annotation/text_annotation.html.erb` and `app/controllers/mcp_controller.rb`
596
729
  are the fullest example available.
597
730
  - **Shipping the element** — `docs/custom-element-distribution.md` is the design note
@@ -561,8 +561,50 @@ function parseSseFrame(raw) {
561
561
  }
562
562
  var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
563
563
  var PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json";
564
+ var PAGE_GLOBAL_TRANSPORT = "page-global";
565
+ function declaredPageGlobal(card) {
566
+ for (const remote of card && card.remotes || []) {
567
+ if (!remote || remote.type !== PAGE_GLOBAL_TRANSPORT) continue;
568
+ if (typeof remote.global === "string" && remote.global.trim() !== "") return remote.global.trim();
569
+ }
570
+ return null;
571
+ }
572
+ function pageHandlers({ card, scope = {}, legacy = {}, warn = console.warn } = {}) {
573
+ const name = declaredPageGlobal(card);
574
+ const declared = name ? scope[name] : null;
575
+ if (name && !declared) {
576
+ warn(`[llm-meta-widget] the page card declares global "${name}", but window.${name} is not defined \u2014 is the script that defines it loaded, and did it run?`);
577
+ }
578
+ const isMap = (v) => !!v && typeof v === "object" && !Array.isArray(v);
579
+ const pick = (kind, legacyName) => {
580
+ const fromGlobal = declared ? declared[kind] : void 0;
581
+ if (isMap(fromGlobal)) return fromGlobal;
582
+ if (declared && fromGlobal !== void 0 && !isMap(fromGlobal)) {
583
+ warn(`[llm-meta-widget] window.${name}.${kind} is ${Array.isArray(fromGlobal) ? "an array" : typeof fromGlobal}; it must be an object keyed by ${kind === "resources" ? "uri" : "name"}`);
584
+ }
585
+ return legacyName && scope[legacyName] || {};
586
+ };
587
+ return {
588
+ global: name,
589
+ tools: pick("tools", legacy.tools),
590
+ prompts: pick("prompts", legacy.prompts),
591
+ resources: pick("resources", legacy.resources)
592
+ };
593
+ }
564
594
  function mergeStaticPrimitivesCards(cards, warn = console.warn) {
565
- const merged = { tools: [], prompts: [], resources: [] };
595
+ const merged = { remotes: [], tools: [], prompts: [], resources: [] };
596
+ for (const card of cards || []) {
597
+ for (const remote of card && card.remotes || []) {
598
+ const clash = merged.remotes.find((r) => r.type === remote.type && r.global !== remote.global);
599
+ if (clash) {
600
+ warn(`[llm-meta-widget] two blocks declare different ${remote.type} globals ("${clash.global}" and "${remote.global}"); using "${clash.global}"`);
601
+ continue;
602
+ }
603
+ if (!merged.remotes.some((r) => r.type === remote.type && r.global === remote.global)) {
604
+ merged.remotes.push(remote);
605
+ }
606
+ }
607
+ }
566
608
  const keyOf = { tools: "name", prompts: "name", resources: "uri" };
567
609
  for (const kind of ["tools", "prompts", "resources"]) {
568
610
  const key = keyOf[kind];
@@ -580,7 +622,7 @@ function mergeStaticPrimitivesCards(cards, warn = console.warn) {
580
622
  return merged;
581
623
  }
582
624
  function readStaticPrimitivesCard(text, warn = console.warn) {
583
- const empty = { tools: [], prompts: [], resources: [] };
625
+ const empty = { remotes: [], tools: [], prompts: [], resources: [] };
584
626
  if (typeof text !== "string" || text.trim() === "") return empty;
585
627
  let doc;
586
628
  try {
@@ -600,6 +642,8 @@ function readStaticPrimitivesCard(text, warn = console.warn) {
600
642
  return ok;
601
643
  });
602
644
  return {
645
+ // The whole card's transport, so what it declares survives the parse.
646
+ remotes: Array.isArray(doc.remotes) ? doc.remotes : [],
603
647
  tools: named(ext.tools, "tool"),
604
648
  prompts: named(ext.prompts, "prompt"),
605
649
  // Resources are addressed by uri, not name — resources/read takes the uri.
@@ -3919,6 +3963,12 @@ function boot(cfg) {
3919
3963
  model: MODEL
3920
3964
  });
3921
3965
  }
3966
+ var lastPersist = 0;
3967
+ function persistSoon() {
3968
+ if (Date.now() - lastPersist < 400) return;
3969
+ lastPersist = Date.now();
3970
+ persistConversation();
3971
+ }
3922
3972
  var typedCards = document.querySelectorAll('script[type="' + PAGE_CARD_MEDIA_TYPE + '"]');
3923
3973
  var pageCard;
3924
3974
  if (typedCards.length) {
@@ -3931,6 +3981,11 @@ function boot(cfg) {
3931
3981
  var pageCardEl = document.getElementById(PAGE_CARD_ID);
3932
3982
  pageCard = readStaticPrimitivesCard(pageCardEl ? pageCardEl.textContent : null);
3933
3983
  }
3984
+ var handlers = pageHandlers({
3985
+ card: pageCard,
3986
+ scope: window,
3987
+ legacy: { tools: ACTIONS_GLOBAL, prompts: PROMPTS_GLOBAL, resources: RESOURCES_GLOBAL }
3988
+ });
3934
3989
  var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
3935
3990
  var localTools = pageCard.tools.length ? pageCard.tools : actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
3936
3991
  var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
@@ -4207,7 +4262,7 @@ function boot(cfg) {
4207
4262
  function argState() {
4208
4263
  return promptArgState({
4209
4264
  entries: pageCard.resources,
4210
- resources: window[RESOURCES_GLOBAL] || {},
4265
+ resources: handlers.resources,
4211
4266
  state: window[STATE_GLOBAL] || {}
4212
4267
  });
4213
4268
  }
@@ -4307,7 +4362,7 @@ function boot(cfg) {
4307
4362
  }
4308
4363
  button.disabled = true;
4309
4364
  try {
4310
- var result = prompt.source === "page" ? await getPagePrompt({ name: prompt.name, args, prompts: window[PROMPTS_GLOBAL] }) : await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args });
4365
+ var result = prompt.source === "page" ? await getPagePrompt({ name: prompt.name, args, prompts: handlers.prompts }) : await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args });
4311
4366
  var text = promptMessagesToText(result);
4312
4367
  if (!text) {
4313
4368
  appendTurn("error", "The server returned an empty prompt.");
@@ -4523,7 +4578,7 @@ function boot(cfg) {
4523
4578
  resourceContext = turn.cached;
4524
4579
  var page = await pageResourceLinesForTurn({
4525
4580
  plans: pageResourcePlans,
4526
- resources: window[RESOURCES_GLOBAL],
4581
+ resources: handlers.resources,
4527
4582
  budgetBytes: RESOURCE_BUDGET_BYTES,
4528
4583
  cache: pageResourceCache
4529
4584
  });
@@ -4576,6 +4631,10 @@ function boot(cfg) {
4576
4631
  var messages = [{ role: "system", content: currentSystemPrompt(resourceLines) }].concat(conversation.map(function(t) {
4577
4632
  return { role: t.role, content: t.content };
4578
4633
  })).concat([{ role: "user", content: userText }]);
4634
+ conversation.push({ role: "user", content: userText });
4635
+ var liveTurn = { role: "assistant", content: "", tools: [] };
4636
+ conversation.push(liveTurn);
4637
+ persistConversation();
4579
4638
  var result = await runChatLoop({
4580
4639
  baseUrl: LLM_BASE,
4581
4640
  toolHubUrl: TOOL_HUB_BASE,
@@ -4585,15 +4644,18 @@ function boot(cfg) {
4585
4644
  localTools,
4586
4645
  remoteTools,
4587
4646
  hostWideTools,
4588
- aiActions: window[ACTIONS_GLOBAL] || {},
4647
+ aiActions: handlers.tools,
4589
4648
  maxRounds: MAX_ROUNDS,
4590
4649
  provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
4591
4650
  signal: currentAbort.signal,
4592
4651
  onToolCall: function(toolCall) {
4593
4652
  announceToolCall(toolCall);
4653
+ persistConversation();
4594
4654
  },
4595
4655
  onToolDispatched: function(outcome) {
4596
4656
  resolveToolCall(outcome);
4657
+ liveTurn.tools = liveTurn.tools.concat([outcome.error ? { name: outcome.toolCall.name, error: true } : { name: outcome.toolCall.name }]);
4658
+ persistConversation();
4597
4659
  },
4598
4660
  onPhase: function(name) {
4599
4661
  if (name === "responding") markDone(assistantBody.roleLabel);
@@ -4614,18 +4676,14 @@ function boot(cfg) {
4614
4676
  collapseThinkingBlock();
4615
4677
  assistantMarkdown += delta;
4616
4678
  renderMarkdownInto(assistantBody, assistantMarkdown);
4679
+ liveTurn.content = assistantMarkdown;
4680
+ persistSoon();
4617
4681
  historyEl.scrollTop = historyEl.scrollHeight;
4618
4682
  }
4619
4683
  });
4620
- conversation.push({ role: "user", content: userText });
4621
- conversation.push({
4622
- role: "assistant",
4623
- content: result.content,
4624
- // Kept so a restored transcript still shows what ran — losing
4625
- // that record is what made a navigating action unbearable.
4626
- tools: result.dispatched.map(function(d) {
4627
- return d.error ? { name: d.toolCall.name, error: true } : { name: d.toolCall.name };
4628
- })
4684
+ liveTurn.content = result.content;
4685
+ liveTurn.tools = result.dispatched.map(function(d) {
4686
+ return d.error ? { name: d.toolCall.name, error: true } : { name: d.toolCall.name };
4629
4687
  });
4630
4688
  persistConversation();
4631
4689
  result.dispatched.forEach(function(d) {
@@ -884,6 +884,62 @@ export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives
884
884
  // look cannot discover anything. SEP-0000, "HTML-delivery mode".
885
885
  export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
886
886
 
887
+ // The transport an HTML-delivered Server Card declares: a JavaScript global
888
+ // under which the handler maps live. A Server Card always states its endpoint
889
+ // rather than relying on a convention, and a page card states its global the
890
+ // same way — so a consumer that has never heard of this widget can parse the
891
+ // card, find the handlers and dispatch. The three legacy globals
892
+ // (aiActions/aiPrompts/aiResources) are names only this widget knows, and are
893
+ // configurable per page, so nothing outside could rely on them.
894
+ export const PAGE_GLOBAL_TRANSPORT = "page-global"
895
+
896
+ export function declaredPageGlobal(card) {
897
+ for (const remote of (card && card.remotes) || []) {
898
+ if (!remote || remote.type !== PAGE_GLOBAL_TRANSPORT) continue
899
+ if (typeof remote.global === "string" && remote.global.trim() !== "") return remote.global.trim()
900
+ }
901
+ return null
902
+ }
903
+
904
+ // Resolves the three handler maps. A declared global wins; each map falls back
905
+ // to its legacy global independently, so a page part-way through moving over
906
+ // keeps working instead of losing the primitives it has not moved yet.
907
+ export function pageHandlers({ card, scope = {}, legacy = {}, warn = console.warn } = {}) {
908
+ const name = declaredPageGlobal(card)
909
+ const declared = name ? scope[name] : null
910
+
911
+ // A card that names a global the page never defines is almost always a load
912
+ // order problem, or a script that threw before it got there. Falling back in
913
+ // silence hides that: the page then behaves as if it had declared nothing,
914
+ // and the first symptom is a prompt or tool that "has no handler".
915
+ if (name && !declared) {
916
+ warn(`[llm-meta-widget] the page card declares global "${name}", but window.${name} ` +
917
+ "is not defined — is the script that defines it loaded, and did it run?")
918
+ }
919
+
920
+ const isMap = (v) => !!v && typeof v === "object" && !Array.isArray(v)
921
+ const pick = (kind, legacyName) => {
922
+ // `declared ? … : undefined`, not `declared && …`: the second yields null
923
+ // when nothing is declared, and `typeof null === "object"`, so the check
924
+ // below then warned about "window.null.tools" on every page with no card.
925
+ const fromGlobal = declared ? declared[kind] : undefined
926
+ if (isMap(fromGlobal)) return fromGlobal
927
+ // An array is the mistake worth naming: it passes a typeof check, so it
928
+ // would be used as a map and answer every lookup with undefined.
929
+ if (declared && fromGlobal !== undefined && !isMap(fromGlobal)) {
930
+ warn(`[llm-meta-widget] window.${name}.${kind} is ${Array.isArray(fromGlobal) ? "an array" : typeof fromGlobal}; ` +
931
+ `it must be an object keyed by ${kind === "resources" ? "uri" : "name"}`)
932
+ }
933
+ return (legacyName && scope[legacyName]) || {}
934
+ }
935
+ return {
936
+ global: name,
937
+ tools: pick("tools", legacy.tools),
938
+ prompts: pick("prompts", legacy.prompts),
939
+ resources: pick("resources", legacy.resources)
940
+ }
941
+ }
942
+
887
943
  // Several blocks may appear in one document, so that a page assembled from
888
944
  // components can let each component declare its own primitives. The arrays
889
945
  // concatenate by primitive type.
@@ -894,7 +950,23 @@ export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
894
950
  // Picking the first would make which component wins depend on DOM order, which
895
951
  // is not something a page author controls when the components are partials.
896
952
  export function mergeStaticPrimitivesCards(cards, warn = console.warn) {
897
- const merged = { tools: [], prompts: [], resources: [] }
953
+ const merged = { remotes: [], tools: [], prompts: [], resources: [] }
954
+ // Each block is a full Server Card and may declare a transport. The first
955
+ // one wins; a second block naming a DIFFERENT global is an authoring error,
956
+ // because only one of them can be dispatched to.
957
+ for (const card of cards || []) {
958
+ for (const remote of (card && card.remotes) || []) {
959
+ const clash = merged.remotes.find((r) => r.type === remote.type && r.global !== remote.global)
960
+ if (clash) {
961
+ warn(`[llm-meta-widget] two blocks declare different ${remote.type} globals ` +
962
+ `("${clash.global}" and "${remote.global}"); using "${clash.global}"`)
963
+ continue
964
+ }
965
+ if (!merged.remotes.some((r) => r.type === remote.type && r.global === remote.global)) {
966
+ merged.remotes.push(remote)
967
+ }
968
+ }
969
+ }
898
970
  const keyOf = { tools: "name", prompts: "name", resources: "uri" }
899
971
 
900
972
  for (const kind of [ "tools", "prompts", "resources" ]) {
@@ -917,7 +989,7 @@ export function mergeStaticPrimitivesCards(cards, warn = console.warn) {
917
989
  }
918
990
 
919
991
  export function readStaticPrimitivesCard(text, warn = console.warn) {
920
- const empty = { tools: [], prompts: [], resources: [] }
992
+ const empty = { remotes: [], tools: [], prompts: [], resources: [] }
921
993
  if (typeof text !== "string" || text.trim() === "") return empty
922
994
 
923
995
  let doc
@@ -941,6 +1013,8 @@ export function readStaticPrimitivesCard(text, warn = console.warn) {
941
1013
  })
942
1014
 
943
1015
  return {
1016
+ // The whole card's transport, so what it declares survives the parse.
1017
+ remotes: Array.isArray(doc.remotes) ? doc.remotes : [],
944
1018
  tools: named(ext.tools, "tool"),
945
1019
  prompts: named(ext.prompts, "prompt"),
946
1020
  // Resources are addressed by uri, not name — resources/read takes the uri.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibranch/llm-meta-widget",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "//version": "Must stay in step with lib/llm_meta_widget/version.rb. Two independently versioned copies of the same client is the drift docs/custom-element-distribution.md warns about: bump both, release both.",
5
5
  "description": "Embeddable browser chat widget: a custom element that discovers tools at page, origin and hub scope and dispatches each tool call to the right one. Two lines of HTML on any host, Rails or not.",
6
6
  "keywords": [