@artooi/ag-ui-web-component 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +247 -1
  2. package/README.md +186 -6
  3. package/dist/ag-ui-web-component.bundle.js +50 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +55 -1
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +8 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/conversation_store.d.ts +43 -1
  10. package/dist/core/conversation_store.d.ts.map +1 -1
  11. package/dist/core/create_http_agent.d.ts +13 -0
  12. package/dist/core/create_http_agent.d.ts.map +1 -1
  13. package/dist/core/remote_conversation_store.d.ts +23 -1
  14. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  15. package/dist/core/utils.d.ts +28 -0
  16. package/dist/core/utils.d.ts.map +1 -1
  17. package/dist/index.js +564 -94
  18. package/dist/index.js.map +4 -4
  19. package/dist/tools/is_destructive.d.ts +8 -2
  20. package/dist/tools/is_destructive.d.ts.map +1 -1
  21. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  22. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  23. package/dist/ui/render_markdown.d.ts +23 -5
  24. package/dist/ui/render_markdown.d.ts.map +1 -1
  25. package/dist/ui/resize_handle.d.ts +5 -1
  26. package/dist/ui/resize_handle.d.ts.map +1 -1
  27. package/dist/ui/ui_strings.d.ts +13 -7
  28. package/dist/ui/ui_strings.d.ts.map +1 -1
  29. package/dist/ui/voice_input.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/core/ag_ui_chat.ts +431 -41
  32. package/src/core/agui_client.ts +18 -1
  33. package/src/core/conversation_store.ts +128 -42
  34. package/src/core/create_http_agent.ts +24 -2
  35. package/src/core/remote_conversation_store.ts +35 -2
  36. package/src/core/utils.ts +58 -0
  37. package/src/tools/is_destructive.ts +8 -2
  38. package/src/tools/parse_tool_catalog.ts +18 -6
  39. package/src/ui/render_markdown.ts +111 -21
  40. package/src/ui/resize_handle.ts +32 -2
  41. package/src/ui/ui_strings.ts +19 -8
  42. package/src/ui/voice_input.ts +43 -0
  43. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,251 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.28.0] — 2026-08-26
11
+
12
+ ### Added
13
+
14
+ - **A conversation could carry from one signed-in user to the next in the same
15
+ tab.** New `user-key` attribute (and matching `userKey` property): set it to
16
+ whatever identifies the signed-in principal, and the stored conversation is
17
+ scoped to them, so two principals in one tab cannot reach each other's
18
+ transcript. Changing it — or clearing it — purges everything the previous
19
+ principal left behind under this element: the transcript, the history drawer
20
+ index and any navigation checkpoint. That purge is why it is a live attribute
21
+ rather than a connect-time one: `sessionStorage` is scoped to the tab and not
22
+ to the session, so it survives the navigation a logout is, and a single-page
23
+ app signs out through its own router without remounting anything — the host
24
+ naming the new principal is the only signal there is. The first value to
25
+ arrive is treated as a host naming the user who was already there rather than
26
+ as a handover, so an element configured by an async auth handshake keeps the
27
+ conversation on screen. Absent, behaviour is exactly what it was, including
28
+ that carry-over; the README says so where the attribute is documented.
29
+ `SessionStorageStore.purge(namespace)` exposes the same primitive for a host
30
+ driving its own sign-out.
31
+
32
+ - **Pointing history at a server still left a full copy of every transcript in
33
+ the browser.** `RemoteConversationStore` mirrored each message body into
34
+ `sessionStorage` whatever the deployment had chosen, so an operator who
35
+ configured `data-threads-url` precisely to keep conversations in the database
36
+ got them in both places. The mirror is now a `cacheMessages` constructor
37
+ option, exposed on the element as `data-threads-cache="false"`, and it still
38
+ defaults to the caching behaviour so nobody's setup changes silently. Turning
39
+ it off keeps only the client-only state — the active thread id and the
40
+ navigation checkpoint — so reloads and navigating tools work as before; what
41
+ it costs is the offline fallback, since there is no longer a local copy to
42
+ fall back to.
43
+
44
+ - **`UiStrings.recordingLimit`** — what the mic button says after a recording
45
+ stopped itself at the length cap. Token: `{n}`, the cap in minutes. Like every
46
+ other key it has an English default, so an existing `strings` override keeps
47
+ working untouched.
48
+
49
+ - **`trustedOrigins` on the agent factory options** — the origins, besides the
50
+ document's own, the agent may carry host credentials to. Naming one confirms
51
+ the destination was chosen deliberately and silences the notice above for it.
52
+ Compared as serialized origins (`https://agent.example.com`), scheme and port
53
+ included. Reachable through a custom `agentFactory`, which is how a host wraps
54
+ `createHttpAgent` today.
55
+
56
+ - **`trustedOrigins` on the element** — the origins, besides the page's own, that
57
+ every one of its seven configurable URLs may carry host credentials to without
58
+ a console notice. Forwarded to the agent factory as well, so a host that does
59
+ not override `agentFactory` configures all seven in one place.
60
+
61
+ ### Changed
62
+
63
+ - **`parseToolCatalog` returns whole catalog entries, not bare summaries.** Its
64
+ return type was `Record<string, string>`, so the `description` field that
65
+ `ToolCatalogEntry` declares and documents was dropped at parse time for every
66
+ entry — a documented wire field no consumer could reach, and none could be
67
+ added without changing this signature first. It now returns
68
+ `Record<string, ToolCatalogEntry>`. Tool-call cards still label themselves from
69
+ `summary`; callers of the exported parser get the entry the server actually
70
+ sent. A malformed `description` costs that field, not the entry, matching the
71
+ tolerance the rest of the parse already had.
72
+
73
+ ### Fixed
74
+
75
+ - **A reasoning block lost its last sentence, and a short one never appeared at
76
+ all.** The protocol client hands a delta subscriber the text accumulated
77
+ *before* the delta it is announcing, so following that callback alone trails
78
+ the stream by one and renders nothing whatsoever for reasoning that arrives as
79
+ a single delta. The answer text was spared because its own end event carries
80
+ the whole message; reasoning subscribed to an end event that carries no buffer.
81
+ It now also listens to the one that does. The test helper was the reason this
82
+ went unseen: it handed the subscriber the full buffer on every delta, a wire no
83
+ server writes, so every existing test agreed with the bug.
84
+
85
+ - **Two chats on one page shared one conversation.** The storage namespace falls
86
+ back from the element's `id` to its `endpoint`, so two `<ag-ui-chat>` elements
87
+ with no `id` against the same agent mount — a docked support panel and an
88
+ inline page assistant, say, and nothing requires an `id` — resolved to the same
89
+ namespace and shared a thread pointer, a history drawer and every message key.
90
+ Whichever mounted second adopted the first's active thread and rehydrated its
91
+ transcript into its own panel. The first element to mount now keeps the
92
+ namespace, so the ordinary single-element case is untouched, and a second is
93
+ given a throwaway namespace of its own plus a console warning naming the fix.
94
+ The throwaway namespace is minted per mount, so give each element an `id` for
95
+ its conversation to survive a reload.
96
+
97
+ - **A full storage quota was reported as an agent failure.** `sessionStorage`
98
+ writes throw once the quota is exhausted — a long conversation, or one turn
99
+ carrying a large tool result — and in privacy modes that deny storage
100
+ outright. The transcript is persisted from inside the run loop, so that throw
101
+ surfaced as a run error and told the user the agent had failed when nothing
102
+ but the browser's storage had; on the cancel path it escaped as an unhandled
103
+ rejection instead. A write that cannot be made now costs the reload and not
104
+ the conversation, and says so in the console once rather than once per turn.
105
+
106
+ - **A tool a page deliberately withheld from a run still ran when the agent
107
+ called it.** `getTools` is a per-run catalog provider, so a host is invited to
108
+ scope what a given page offers while registering everything once at mount.
109
+ Dispatch never consulted it: it resolved the call name straight against the
110
+ mount-wide registry, so a call naming a scoped-out tool found its handler and
111
+ ran it — with the confirmation card the only remaining gate, and that card is
112
+ waived by `autoConfirm`, by a `confirmPredicate`, or by a schema without
113
+ `x-destructive`. The page's decision not to offer the tool carried no weight
114
+ where it mattered. The names a run advertises are now captured as the catalog
115
+ goes out, and a call outside that set is treated exactly as a call naming a
116
+ tool that was never registered. The set is the snapshot, not a fresh
117
+ `getTools()` at dispatch time — asking the provider again would re-open the
118
+ window it exists to close. Hosts that never override `getTools` advertise the
119
+ built-ins plus everything registered, which is exactly what dispatch could
120
+ reach before, so nothing changes for them.
121
+
122
+ - **A long streamed answer slowed the tab down as it arrived, and destroyed any
123
+ text selected inside it on every token.** Each `TEXT_MESSAGE_CONTENT` event
124
+ carries the whole answer so far, and every one of them re-parsed the markdown,
125
+ re-sanitised it and replaced the bubble's entire subtree. Cost grew with the
126
+ square of the answer's length — a 40 KB answer streamed token by token meant
127
+ thousands of full parses over a document that kept getting longer — and
128
+ because the subtree was rebuilt each time, a selection or a focus inside the
129
+ bubble could not survive a single token. Since a long answer is entirely
130
+ agent-controlled, an agent induced to produce one turned an ordinary run into
131
+ a stalling tab. Deltas now coalesce into one render per animation frame: a
132
+ burst of tokens costs one parse, and a frame is the fastest anything on screen
133
+ can change anyway, so the text still flows rather than arriving in chunks. The
134
+ bubble opens on the first token as before, keeps its identity throughout, and
135
+ a run that ends without closing its text message — a cancel, an error, a round
136
+ boundary — draws the queued delta before letting the bubble go, so a stopped
137
+ answer keeps its last words.
138
+
139
+ - **Model output could draw a pixel-accurate copy of the approval card.** The
140
+ markdown sanitiser kept `class` on every element it allowed, and the shadow
141
+ stylesheet's component classes are unscoped selectors, so a
142
+ `<span class="approval-btn approval-btn--approve">` in an assistant message
143
+ resolved to the same background, border and radius as the genuine
144
+ human-in-the-loop approve button — rendered as ordinary prose, inside the one
145
+ surface where the user decides whether to approve something. The same trick
146
+ reproduced the question card, the tool-call card and a turn the user never
147
+ took. `class` was on the allowlist for exactly one thing, `marked`'s
148
+ `language-*` code-fence hint, and is now narrowed to it: a `language-*` token
149
+ on a `code` or `pre` element survives, and every other class is dropped.
150
+ Highlighting a fenced code block is unaffected.
151
+
152
+ - **The sanitiser allowed far more attributes than the three it declared.**
153
+ DOMPurify's `ALLOW_DATA_ATTR` and `ALLOW_ARIA_ATTR` default to `true`, so every
154
+ `data-*` and `aria-*` attribute passed through alongside `href`/`title`/`class`
155
+ while the configuration read as though only those three could survive. That
156
+ handed model output the attributes the cards drive their resolved, status and
157
+ expanded appearance from, and let an `aria-label` make a screen reader announce
158
+ something other than what a sighted user reads. Both are now off, so the
159
+ declared allowlist is the effective one.
160
+
161
+ - **Rendered markdown was edited after the sanitiser had finished with it.** The
162
+ sanitised string was parsed into a `<template>`, given its `target`/`rel` link
163
+ hardening there, and re-serialised — so the markup actually inserted into a
164
+ bubble was never markup DOMPurify inspected, and it carried two attributes the
165
+ allowlist did not name. Nothing exploitable came of it, because no allowed
166
+ element serialises asymmetrically, but that held by accident rather than by
167
+ design: adding `svg`, `style` or `noscript` to the allowlist would have turned
168
+ the round trip into a bypass, and the suite asserted on the sanitiser's output
169
+ instead of on what was inserted. Link hardening now runs inside the sanitiser,
170
+ so what a caller inserts is exactly what DOMPurify approved, and a test holds
171
+ it there.
172
+
173
+ - **Resizing the panel from the keyboard called `commit` on every key repeat.**
174
+ It is documented as one call per completed resize and the pointer path honours
175
+ that, but the keyboard path called it straight from each `keydown`. Holding an
176
+ arrow key means OS key repeat at twenty to thirty events a second, so a host
177
+ that put a `sessionStorage` write or a `PATCH` behind `commit` got that many
178
+ for a single press — landing hardest on the keyboard users the path exists for.
179
+ Live feedback still happens per key event; the commit now waits for the key to
180
+ come up, or for focus to leave the grip mid-press.
181
+
182
+ - **A voice recording ran until somebody stopped it.** Tapping the mic and then
183
+ being interrupted left `MediaRecorder` running with no upper bound: audio
184
+ accumulated in memory, the browser's recording indicator stayed lit in a tab
185
+ nobody was looking at, and whenever the user came back the whole accumulated
186
+ clip was posted to the transcription endpoint in one body no client-side check
187
+ sized. A recording now stops itself after two minutes — far longer than a
188
+ dictated chat message, short enough to bound a forgotten one. The audio is kept
189
+ and transcribed rather than discarded, and the mic button says why it stopped.
190
+
191
+ - **The README's "Public API surface" tables were missing a third of the surface
192
+ they enumerate.** The section presents itself as complete, so a consumer who
193
+ went looking for the chart-drawing seam, the approval and question card
194
+ helpers, the transcription defaults, `prettifyToolName`, `parseToolCatalog` or
195
+ two of the event-name constants found no mention and concluded the package had
196
+ none. Thirty-two absent exports are now listed, and a test compares the tables
197
+ against the package root's export list so the next new export cannot go missing
198
+ quietly.
199
+
200
+ - **`UiStrings.checkpoints` had no documentation to hover.** An insertion of the
201
+ copy-button strings landed between the checkpoint panel's doc comment and the
202
+ field it described, stranding the comment above `copyCode`. It is back on
203
+ `checkpoints`, and the copy-button and checkpoint fields now have sections of
204
+ their own rather than splitting the relative-time group in half.
205
+
206
+ ### Security
207
+
208
+ - **Host credentials no longer leave the page's origin silently.** `endpoint` and
209
+ its sibling URL attributes are plain HTML, so a page that builds one from a
210
+ query parameter or from tenant-authored configuration has handed whoever wrote
211
+ that value the destination of the element's requests. The browser preflights
212
+ the custom header, any server willing to answer receives it, and the CSRF token
213
+ or bearer the host supplies through `headers` / `getHeaders()` leaves on the
214
+ very first request — before the user has done anything. Nothing in the package
215
+ compared a configured URL against an expected origin, so the delivery was
216
+ invisible. The agent now reports it on the console, naming the destination and
217
+ the header names, once per origin.
218
+
219
+ It reports rather than refuses: an agent on another subdomain is a documented
220
+ deployment and keeps working unchanged. What is removed is the silence.
221
+
222
+ All seven configurable URLs are covered, not the agent endpoint alone. The tool
223
+ catalog, the skills list, the thread index, the attachment upload and the
224
+ transcription endpoint are named by the same kind of host attribute and carry
225
+ the same headers, so reporting only the agent would have reported the least
226
+ interesting of the seven.
227
+
228
+ ### Documentation
229
+
230
+ - **Registering a duplicate tool name does not throw.** The README said it did.
231
+ `ClientToolRegistry.register` is a plain map write, deliberately, so a re-fired
232
+ host ref or React StrictMode's double-invoke replaces rather than raises. The
233
+ harm in the claim is not the defensive guard nobody needs; it is a host
234
+ believing two tools cannot quietly share a name, where the second wins.
235
+
236
+ - **`x-destructive` gates frontend tools only, and the docs claimed otherwise.**
237
+ `isDestructive` described the flag as one the server stamps, which cannot
238
+ reach it: tool schemas travel client-to-server on `RunAgentInput.tools`, and
239
+ the only channel coming back is the label catalog, which carries names and
240
+ summaries and no flags. Marking a server-side tool destructive therefore
241
+ produces no confirmation card in the browser — it has to be gated server-side,
242
+ which surfaces as an approval card instead. The helper's doc comment and the
243
+ README's confirmation section now say so.
244
+
245
+ - **A tool handler's thrown message reaches the model.** When a handler rejects,
246
+ its `Error.message` is posted back as that call's tool result: into the
247
+ conversation, on to the AG-UI endpoint, persisted there, and replayed to the
248
+ model provider on every later round. That is deliberate — a real reason is
249
+ what lets the agent recover — but the same string is only ever shown to the
250
+ user as a short card label, so a host rethrowing an internal error had no way
251
+ to see that an internal hostname, a signed URL or a stack-derived path had
252
+ left the browser. `registerTool` and the README now say it plainly, so hosts
253
+ can throw the message they would be content for the model to read.
254
+
10
255
  ## [0.27.0] — 2026-08-26
11
256
 
12
257
  ### Fixed
@@ -1705,7 +1950,8 @@ hosts that both arrange the page the way it expects.
1705
1950
  ### Notes
1706
1951
  - First release — exercising the automated npm OIDC publish pipeline end-to-end.
1707
1952
 
1708
- [Unreleased]: https://github.com/Artui/ag-ui-web-component/compare/v0.27.0...HEAD
1953
+ [Unreleased]: https://github.com/Artui/ag-ui-web-component/compare/v0.28.0...HEAD
1954
+ [0.28.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.27.0...v0.28.0
1709
1955
  [0.27.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.26.1...v0.27.0
1710
1956
  [0.26.1]: https://github.com/Artui/ag-ui-web-component/compare/v0.26.0...v0.26.1
1711
1957
  [0.26.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.25.2...v0.26.0
package/README.md CHANGED
@@ -53,6 +53,8 @@ No framework, no Django, no admin specifics live here. Downstream consumers (e.g
53
53
  - [Run notices: compaction and agent skills](#run-notices-compaction-and-agent-skills)
54
54
  - [Skills: prompt chips and slash palette](#skills-prompt-chips-and-slash-palette)
55
55
  - [MPA durability: surviving full page reloads](#mpa-durability-surviving-full-page-reloads)
56
+ - [Who the stored conversation belongs to (`user-key`)](#who-the-stored-conversation-belongs-to-user-key)
57
+ - [Mounting more than one chat on a page](#mounting-more-than-one-chat-on-a-page)
56
58
  - [Host seams: the SPA story](#host-seams-the-spa-story)
57
59
  - [Public API surface](#public-api-surface)
58
60
  - [Theming, density, and placement](#theming-density-and-placement)
@@ -162,7 +164,9 @@ another origin, add `credentials="include"` too; see
162
164
  | `data-skills` | — | Inline JSON skill catalog. |
163
165
  | `data-skills-url` | — | URL of a JSON skill catalog (fetched with the element's headers and cookie policy). |
164
166
  | `data-tools-url` | — | URL of a server tool-label catalog (`[{ name, summary, description? }]`), fetched with the element's headers and cookie policy; labels tool-call cards for server-side tools. |
167
+ | `user-key` | `userKey` | Who the stored conversation belongs to — any string identifying the signed-in principal. Joins the storage namespace, and **changing it purges what the previous principal left behind**. Live (not connect-time): a logout is the host's to announce. See [Who the stored conversation belongs to](#who-the-stored-conversation-belongs-to-user-key). |
165
168
  | `data-threads-url` | — | URL of a server thread index (django-ag-ui's `ThreadsView`); enables durable, cross-device chat history. |
169
+ | `data-threads-cache` | — | **On by default.** `="false"` stops mirroring message bodies into `sessionStorage` when `data-threads-url` is set, for a deployment that put history on the server so transcripts stay off the client. Only meaningful alongside `data-threads-url`. |
166
170
  | `data-runs-url` | — | URL of a server run index (django-ag-ui's `RunsView`); reveals the header's ⭯ *Continue a run* panel. See [Resuming a run](#resuming-a-run). |
167
171
  | `data-attachments-url` | — | URL of the file-upload endpoint (django-ag-ui's `AttachmentsView`); reveals the composer's paperclip picker + drag-and-drop. |
168
172
  | `data-attachment-accept` | — | `<input accept>` list for client-side type filtering (e.g. `image/*,.pdf`). The server stays authoritative. |
@@ -196,7 +200,7 @@ same way: `icon-send`, `icon-stop`, `icon-attach`, `icon-voice`.
196
200
  `confirmPredicate`, `askUser`, `agentFactory`, `getTools`, `getContext`, `routeMap`, `navigate`,
197
201
  `getPageMap`, `autoInjectPageMap`, `conversationStore`, `uploadHandler`, `transcribeHandler`,
198
202
  `navigationResult`, `skillContext`, `toolSummaries`, `strings`, `resolvePageTarget`, plus the
199
- mirrors `endpoint` / `toolDisplay` / `collapsed` / `credentials`.
203
+ mirrors `endpoint` / `userKey` / `toolDisplay` / `collapsed` / `credentials`.
200
204
 
201
205
  `headers` and `getHeaders` authenticate **every** request the element makes, not only the agent
202
206
  run; `getHeaders` is the one to use for a credential that rotates. See
@@ -332,6 +336,41 @@ One asymmetry: uploads use `XMLHttpRequest` for real progress events, and its co
332
336
  two-state. `include` turns it on; every other value leaves it off. `omit` therefore cannot suppress
333
337
  cookies on a *same-origin* upload — supply your own `uploadHandler` if that matters.
334
338
 
339
+ ### Where those credentials are allowed to go
340
+
341
+ Every URL in the table above is a plain HTML attribute, and `headers` / `getHeaders` are attached to
342
+ whatever they name. That is what makes a cross-origin agent work — and it is also why a page must
343
+ never build one of those attributes out of a URL parameter, a CMS field, or anything else it did not
344
+ choose itself. Whoever supplies the value chooses where the token goes: the browser preflights the
345
+ custom header, any server willing to answer receives it, and it leaves on the element's first
346
+ request, before the user has typed anything.
347
+
348
+ Treat all seven as trusted configuration. When any of them resolves to another origin, the element
349
+ says so on the console once per origin, naming the destination and the header names it is about to
350
+ send. That covers all seven, not the agent endpoint alone: the tool catalog, the skills list, the
351
+ thread index, the attachment upload and the transcription endpoint carry the same headers, and
352
+ reporting only the agent would report the least interesting of them.
353
+
354
+ To confirm destinations you chose on purpose and silence the notice, name their origins:
355
+
356
+ ```js
357
+ chat.trustedOrigins = ["https://api.example.com"];
358
+ ```
359
+
360
+ That covers every endpoint the element requests itself, and is forwarded to `createHttpAgent`, so a
361
+ host that does not override `agentFactory` needs nothing else. A custom factory can also be given
362
+ the option directly:
363
+
364
+ ```js
365
+ chat.agentFactory = (options) =>
366
+ createHttpAgent({ ...options, trustedOrigins: ["https://api.example.com"] });
367
+ ```
368
+
369
+ Origins are compared as `URL.origin` produces them — scheme, host and port. A notice is a notice,
370
+ not a refusal: nothing is blocked, because a cross-origin agent is a supported deployment and
371
+ refusing would break working installations to defend against a page that is already interpolating
372
+ untrusted data into its own markup.
373
+
335
374
  ### Framework hosts: configure before you insert
336
375
 
337
376
  `headers`, `getHeaders` and `credentials` are read when a request is made, so they can be set at any
@@ -449,6 +488,13 @@ card (honouring `data-tool-display`), so server-side output is visible too. The
449
488
  and context are read **fresh on every run** (`getTools()` / `getContext()`), so they always reflect
450
489
  the current page state.
451
490
 
491
+ The catalog a run advertises is also the set that run can execute. Override `getTools` to scope
492
+ what a page offers — say, exposing `delete_record` only where deleting makes sense — and a call
493
+ naming a tool you withheld is treated exactly as a call naming a tool you never registered: no
494
+ handler runs, and the card settles with the no-result label. Withholding is per run, so the
495
+ mount-wide registry can stay complete. Hosts that leave `getTools` alone advertise the built-ins
496
+ plus everything registered, which is precisely what dispatch could reach anyway.
497
+
452
498
  ### Stopping a run
453
499
 
454
500
  While a run is in flight the **Send button becomes Stop** (same button, label/`aria-label` swap,
@@ -491,10 +537,19 @@ chat.registerTool({
491
537
  });
492
538
  ```
493
539
 
494
- Names must be unique (registering a duplicate throws). Each `<ag-ui-chat>` element owns its own
540
+ Registering a name twice replaces the earlier handler rather than throwing, so a re-fired
541
+ host ref or React StrictMode's double-invoke is harmless -- but two different tools sharing
542
+ a name means the second silently wins. Each `<ag-ui-chat>` element owns its own
495
543
  registry, AG-UI client, and Shadow DOM, so **multiple instances on one page never interfere** —
496
544
  there is no module-level shared state anywhere in the package.
497
545
 
546
+ **A handler's thrown message leaves the browser.** If a handler rejects, its `Error.message` is
547
+ posted back as that call's tool result: into the conversation, on to the AG-UI endpoint, persisted
548
+ there, and replayed to the model provider on every later round. That is deliberate — a real reason
549
+ is what lets the agent recover — but it means an internal hostname, a signed URL or a
550
+ stack-derived path in a rethrown error is disclosed to parties you never chose. Throw the message
551
+ you would be content for the model to read, and log the detail instead.
552
+
498
553
  ### Inline confirmation (`x-destructive` / `x-confirm` / `confirmPredicate`)
499
554
 
500
555
  When a tool call needs confirmation, the element appends an **inline confirmation card** (a
@@ -525,6 +580,13 @@ the `x-destructive` flag (or `confirmPredicate`). The registry forwards the flag
525
580
  If the schema carries an `x-confirm` string (use `X_CONFIRM_KEY`), the card shows it as the prompt;
526
581
  otherwise it falls back to a generic `Run "<tool>"?`.
527
582
 
583
+ **This gate covers frontend tools only.** A server-side tool's schema never reaches the browser —
584
+ tool definitions travel client-to-server on `RunAgentInput.tools`, and the only channel coming back
585
+ is the label catalog (`data-tools-url`), which carries `{ name, summary, description? }` and no
586
+ flags. So marking a server tool destructive does not produce a card here; gate it server-side
587
+ instead (see [Server-side tool approval](#server-side-tool-approval-interrupts)), which surfaces as
588
+ an approval card in the same transcript.
589
+
528
590
  ```js
529
591
  // Per-call: confirm a delete only when it would remove more than one row.
530
592
  chat.confirmPredicate = (name, args) =>
@@ -1042,9 +1104,12 @@ via the `resize-handle` part.
1042
1104
  Assistant bubbles render sanitized markdown/HTML via [`marked`](https://www.npmjs.com/package/marked)
1043
1105
  (GitHub-flavoured, single-newline line breaks) piped through
1044
1106
  [DOMPurify](https://www.npmjs.com/package/dompurify). User messages stay literal text. The
1045
- allowlist permits emphasis, code, lists, quotes, headings, links, tables, and images (`img`); links
1046
- are hardened with `target="_blank" rel="noopener noreferrer"`; `iframe`/`style`/scripting are
1047
- excluded. The exported helper `renderMarkdown(text)` does this standalone. `marked` and `dompurify`
1107
+ allowlist permits emphasis, code, lists, quotes, headings, links, tables, and when `allowImages`
1108
+ is set — images; links are hardened with `target="_blank" rel="noopener noreferrer"`;
1109
+ `iframe`/`style`/scripting are excluded, as are every `data-*` and `aria-*` attribute and every
1110
+ `class` but a code fence's `language-*` hint, so model output cannot dress itself up as the
1111
+ component's own approval or tool-call chrome. The exported helper `renderMarkdown(text)` does this
1112
+ standalone. `marked` and `dompurify`
1048
1113
  are runtime dependencies.
1049
1114
 
1050
1115
  An animated 3-dot "thinking" indicator (`role="status"`, with an aria-label) appears before the
@@ -1177,6 +1242,87 @@ triggers a full reload. Before the handler navigates, the element writes a check
1177
1242
 
1178
1243
  The MPA round-trip becomes a clean observation point instead of a dropped conversation.
1179
1244
 
1245
+ ### Who the stored conversation belongs to (`user-key`)
1246
+
1247
+ `sessionStorage` is scoped to a tab, not to a session. It survives every same-tab navigation,
1248
+ and a logout is a navigation — so on a shared workstation, one user's transcript is still sitting
1249
+ there when the next user signs in and the chat mounts again. Transcripts routinely quote record
1250
+ data, so treat that as the default and turn it off:
1251
+
1252
+ ```html
1253
+ <ag-ui-chat endpoint="/agent/" user-key="{{ request.user.pk }}"></ag-ui-chat>
1254
+ ```
1255
+
1256
+ The value is any string that identifies the principal — a user id, an account id, a hash of one.
1257
+ It joins the storage namespace, so two principals in the same tab cannot reach each other's
1258
+ conversation, and **changing it purges everything the previous principal stored**: transcript,
1259
+ history drawer index and navigation checkpoints, for this element's namespace only.
1260
+
1261
+ Set it live, from script, as part of signing out or in:
1262
+
1263
+ ```js
1264
+ chat.userKey = String(session.userId); // or "" on sign-out
1265
+ ```
1266
+
1267
+ That is why it is a live attribute rather than a connect-time one. A single-page app signs a user
1268
+ out through its own router without remounting anything, so the host naming the new principal — or
1269
+ dropping the attribute — is the only signal the element will ever get. Removing the attribute
1270
+ purges too, so a sign-out that simply clears it is safe.
1271
+
1272
+ The **first** value to arrive is treated as a host naming the user who was already there, not as a
1273
+ handover: the conversation in progress moves into the principal's namespace instead of being
1274
+ destroyed. So an element configured by an async auth handshake — the shape described in
1275
+ [Framework hosts](#framework-hosts-configure-before-you-insert) — keeps what is on screen.
1276
+
1277
+ Two things it deliberately does not do. It does not scope the panel's own collapsed / dragged-size
1278
+ / theme preferences, which are this element's UI state and carry no conversation content. And it
1279
+ does not encrypt or hide anything from the page: any script on the origin can still read
1280
+ `sessionStorage`. It scopes and it purges.
1281
+
1282
+ **Without it, nothing changes** — including the carry-over above. A conversation is scoped to the
1283
+ element and to nobody in particular, and on a shared workstation it will be there for whoever signs
1284
+ in next in the same tab.
1285
+
1286
+ For a deployment that keeps history server-side, `data-threads-cache="false"` stops the local
1287
+ mirror of the message bodies as well, so choosing `data-threads-url` actually keeps transcripts off
1288
+ the client:
1289
+
1290
+ ```html
1291
+ <ag-ui-chat endpoint="/agent/" data-threads-url="/agent/threads/" data-threads-cache="false">
1292
+ </ag-ui-chat>
1293
+ ```
1294
+
1295
+ The client-only concerns (the active thread id, the navigation checkpoint) keep their local store
1296
+ either way, so reloads and navigating tools still work. What is lost is the offline fallback: when
1297
+ the thread endpoint is unreachable the transcript comes back empty rather than stale, and the
1298
+ drawer's offline list loses its previews — a preview being an excerpt of a message, which is the
1299
+ thing being kept off the client. Constructing the store yourself takes the same option:
1300
+
1301
+ ```js
1302
+ chat.conversationStore = new RemoteConversationStore(
1303
+ "/agent/threads/",
1304
+ () => ({ "X-CSRFToken": token }),
1305
+ new SessionStorageStore(),
1306
+ () => "same-origin",
1307
+ false, // cacheMessages
1308
+ );
1309
+ ```
1310
+
1311
+ `SessionStorageStore.purge(namespace)` is the same primitive the element uses, for a host driving
1312
+ its own store from its own sign-out path.
1313
+
1314
+ ### Mounting more than one chat on a page
1315
+
1316
+ Give each `<ag-ui-chat>` its own `id`. The storage namespace is the element's `id`, falling back to
1317
+ its `endpoint` — so two elements with no `id` against the same agent mount (a docked support panel
1318
+ and an inline page assistant, say) would resolve to the same namespace and share a thread pointer,
1319
+ a history drawer and every message key.
1320
+
1321
+ They no longer do: the first element to mount keeps the namespace, and a second is given a
1322
+ throwaway one of its own plus a console warning. That keeps the two conversations apart, but the
1323
+ throwaway namespace is minted per mount, so the second element will not restore its conversation
1324
+ across a reload until it has an `id`.
1325
+
1180
1326
  ---
1181
1327
 
1182
1328
  ## Host seams: the SPA story
@@ -1452,6 +1598,8 @@ re-export point. Internal modules import from leaf paths.
1452
1598
  | `SubmitDetail` | type | `detail` shape of the submit event. |
1453
1599
  | `ToggleDetail` | type | `detail` shape of the `ag-ui-toggle` event (`{ collapsed }`). |
1454
1600
  | `UnreadDetail` | type | `detail` shape of the `ag-ui-unread` event (`{ unread }`). |
1601
+ | `AttachmentsDetail` | type | `detail` shape of the `ag-ui-attachments` event (`{ attachments, pending }`). |
1602
+ | `StateDetail` | type | `detail` shape of the `ag-ui-state` event (`{ state }`). |
1455
1603
 
1456
1604
  ### AG-UI client & agent
1457
1605
 
@@ -1463,6 +1611,8 @@ re-export point. Internal modules import from leaf paths.
1463
1611
  | `ConnectionLostError` | class | Raised (→ `onError`) when a run's stream closes with no terminal AG-UI event. |
1464
1612
  | `createHttpAgent(options)` | function | Default agent factory (wraps `HttpAgent`). |
1465
1613
  | `AgentFactory` / `HttpAgentOptions` | type | Factory signature and its options. |
1614
+ | `ResolveInterrupts` | type | Resolver for server-side-tool approval interrupts (one decision per interrupt). |
1615
+ | `InterruptResponse` | type | One interrupt's answer: `resolved` (with an optional payload) or `cancelled`. |
1466
1616
 
1467
1617
  ### Tools & flags
1468
1618
 
@@ -1476,6 +1626,9 @@ re-export point. Internal modules import from leaf paths.
1476
1626
  | `PAGE_ACTIONS` | const | The page-action opt-in tokens (`scroll` / `drag`). |
1477
1627
  | `ResolvePageTarget` | type | `(target) => HTMLElement | null` — the page-target resolver. |
1478
1628
  | `X_DESTRUCTIVE_KEY` / `X_NAVIGATES_KEY` | const | The JSON-Schema extension keys. |
1629
+ | `parseToolCatalog(data)` | function | Parse a fetched `data-tools-url` catalog into a `name` → `summary` map. |
1630
+ | `ToolCatalogEntry` | type | One row of that catalog. |
1631
+ | `prettifyToolName(name)` | function | Last fallback of the tool-card label chain (`delete_record` reads as *Delete record*). |
1479
1632
 
1480
1633
  ### Host seams
1481
1634
 
@@ -1490,6 +1643,7 @@ re-export point. Internal modules import from leaf paths.
1490
1643
  | `PageState` | type | A page-state binding declaration. |
1491
1644
  | `Skill` | type | A launchable prompt (chip / `/`-command). |
1492
1645
  | `RunFinishedDetail` / `ToolRun` | type | `ag-ui-run-finished` detail: the tools an interaction ran, and which side ran them. |
1646
+ | `createStateHookTools(binding)` / `StateHook` | deprecated | The former names for `createPageStateTools` / `PageState`. |
1493
1647
 
1494
1648
  ### Durability
1495
1649
 
@@ -1515,6 +1669,14 @@ re-export point. Internal modules import from leaf paths.
1515
1669
  | `AttachmentRef` | type | The durable upload ref (`{ id, name, mime, size, url? }`). |
1516
1670
  | `messageAttachments(message)` | function | Read the refs a restored user message carries. |
1517
1671
 
1672
+ ### Voice input
1673
+
1674
+ | Export | Kind | Summary |
1675
+ | --- | --- | --- |
1676
+ | `transcribeAudio(audio, options)` | function | The built-in transcription POST (multipart) → the transcript text. |
1677
+ | `TranscribeOptions` | type | `{ url, headers? }`. |
1678
+ | `TranscribeHandler` | type | `(audio) => Promise<string>` — the `transcribeHandler` swap seam (Web Speech, direct-to-provider). |
1679
+
1518
1680
  ### UI & DOM primitives
1519
1681
 
1520
1682
  | Export | Kind | Summary |
@@ -1527,7 +1689,19 @@ re-export point. Internal modules import from leaf paths.
1527
1689
  | `UiStrings` | type | The flat table of every user-facing string. |
1528
1690
  | `DEFAULT_UI_STRINGS` | const | The English defaults (the override floor). |
1529
1691
  | `mergeUiStrings(overrides)` | function | Merge a partial override over the defaults. |
1530
- | `renderMarkdown(text)` | function | Render sanitized markdown/HTML (marked + DOMPurify). |
1692
+ | `renderMarkdown(text, options?)` | function | Render sanitized markdown/HTML (marked + DOMPurify). |
1693
+ | `RenderMarkdownOptions` | type | `{ allowImages? }` — opt `<img>` back into the sanitized output. |
1694
+ | `requestApproval(host, request, options?)` | function | Append the inline approval card that gates a server-side tool. |
1695
+ | `ApprovalRequest` | type | What that card displays (`{ message?, toolName? }`). |
1696
+ | `ApprovalOptions` | type | `{ signal?, strings? }` — abort resolves the card as denied; `strings` localizes it. |
1697
+ | `ApprovalRenderer` | type | Replace the built-in approval card outright (`AgUiChat.approvalRenderer`). |
1698
+ | `requestQuestion(host, request, options?)` | function | Append the inline `ask_user` card (radios and/or free text). |
1699
+ | `QuestionRequest` | type | What that card asks. |
1700
+ | `QuestionOptions` | type | `{ signal?, strings? }` — abort resolves it with an empty answer. |
1701
+ | `QuestionRenderer` | type | Replace the built-in question card outright (`AgUiChat.questionRenderer`). |
1702
+ | `renderChart(spec)` | function | Draw one spec as a self-contained block, or `null` when it says nothing. |
1703
+ | `chartSpecFrom(value)` | function | Read an arbitrary payload into a `ChartSpec`, or `null` if it cannot be drawn honestly. |
1704
+ | `ChartSpec` / `ChartSeries` / `ChartKind` | type | A chart as data, one named series, and how it is drawn. |
1531
1705
  | `typeInto` / `highlightThenClick` / `pressThenClick` / `selectOption` / `toggleControl` / `scrollIntoCenterView` / `flash` / `focusWithFlash` / `prefersReducedMotion` | function | Animation primitives. |
1532
1706
  | `fillField` / `clickElement` / `pressButton` / `selectControl` / `setControlValue` / `toggleCheckbox` | function | DOM-driver primitives. |
1533
1707
  | `setNativeValue` / `setNativeChecked` | function | Set a control via its native prototype setter (React-controlled inputs). |
@@ -1542,6 +1716,12 @@ re-export point. Internal modules import from leaf paths.
1542
1716
  | `TOGGLE_EVENT` | The collapse-toggle CustomEvent name (`ag-ui-toggle`). |
1543
1717
  | `UNREAD_EVENT` | The unread-count CustomEvent name (`ag-ui-unread`). |
1544
1718
  | `RUN_FINISHED_EVENT` | The interaction-finished CustomEvent name (`ag-ui-run-finished`). |
1719
+ | `ATTACHMENT_EVENT` | The attachments-changed CustomEvent name (`ag-ui-attachments`). |
1720
+ | `STATE_EVENT` | The shared-state CustomEvent name (`ag-ui-state`). |
1721
+ | `CHART_ACTIVITY_TYPE` | The `ACTIVITY_SNAPSHOT` type a server sets to push a chart. |
1722
+ | `CHART_TOOL_NAME` | The name the built-in chart tool registers under (`render_chart`). |
1723
+ | `COMPACTION_ACTIVITY_TYPE` | The `ACTIVITY_SNAPSHOT` type reporting a trimmed history. |
1724
+ | `LOAD_CAPABILITY_TOOL` | The agent-side capability-loading tool's name. |
1545
1725
  | `MESSAGE_ROLE` | Message role constants. |
1546
1726
  | `TOOL_CALL_STATUS` | Tool-call card status constants. |
1547
1727
  | `TOOL_DISPLAY` | Tool-call display-mode constants (`minimal` / `compact` / `full`). |