@artooi/agents-web-components 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (543) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +4397 -2
  4. package/dist/agents-web-components.bundle.js +5708 -0
  5. package/dist/agents-web-components.bundle.js.map +7 -0
  6. package/dist/bundle.d.ts +3 -0
  7. package/dist/bundle.d.ts.map +1 -0
  8. package/dist/chat.d.ts +3 -0
  9. package/dist/chat.d.ts.map +1 -0
  10. package/dist/chat.js +16373 -0
  11. package/dist/chat.js.map +7 -0
  12. package/dist/chunk-ARNLFGCC.js +7715 -0
  13. package/dist/chunk-ARNLFGCC.js.map +7 -0
  14. package/dist/constants.d.ts +575 -0
  15. package/dist/constants.d.ts.map +1 -0
  16. package/dist/core/activity_registration.d.ts +19 -0
  17. package/dist/core/activity_registration.d.ts.map +1 -0
  18. package/dist/core/activity_registry.d.ts +46 -0
  19. package/dist/core/activity_registry.d.ts.map +1 -0
  20. package/dist/core/activity_renderer.d.ts +20 -0
  21. package/dist/core/activity_renderer.d.ts.map +1 -0
  22. package/dist/core/ag_ui_chat.d.ts +836 -0
  23. package/dist/core/ag_ui_chat.d.ts.map +1 -0
  24. package/dist/core/agui_client.d.ts +370 -0
  25. package/dist/core/agui_client.d.ts.map +1 -0
  26. package/dist/core/attachment.d.ts +42 -0
  27. package/dist/core/attachment.d.ts.map +1 -0
  28. package/dist/core/chat_registry.d.ts +23 -0
  29. package/dist/core/chat_registry.d.ts.map +1 -0
  30. package/dist/core/client_seed.d.ts +27 -0
  31. package/dist/core/client_seed.d.ts.map +1 -0
  32. package/dist/core/conversation_store.d.ts +195 -0
  33. package/dist/core/conversation_store.d.ts.map +1 -0
  34. package/dist/core/create_http_agent.d.ts +57 -0
  35. package/dist/core/create_http_agent.d.ts.map +1 -0
  36. package/dist/core/define_chat.d.ts +13 -0
  37. package/dist/core/define_chat.d.ts.map +1 -0
  38. package/dist/core/events/attachments_detail.d.ts +9 -0
  39. package/dist/core/events/attachments_detail.d.ts.map +1 -0
  40. package/dist/core/events/bar_height_detail.d.ts +6 -0
  41. package/dist/core/events/bar_height_detail.d.ts.map +1 -0
  42. package/dist/core/events/custom_agent_detail.d.ts +8 -0
  43. package/dist/core/events/custom_agent_detail.d.ts.map +1 -0
  44. package/dist/core/events/dock_width_detail.d.ts +8 -0
  45. package/dist/core/events/dock_width_detail.d.ts.map +1 -0
  46. package/dist/core/events/feedback_detail.d.ts +28 -0
  47. package/dist/core/events/feedback_detail.d.ts.map +1 -0
  48. package/dist/core/events/invalidate_detail.d.ts +16 -0
  49. package/dist/core/events/invalidate_detail.d.ts.map +1 -0
  50. package/dist/core/events/maximize_detail.d.ts +5 -0
  51. package/dist/core/events/maximize_detail.d.ts.map +1 -0
  52. package/dist/core/events/run_finished_detail.d.ts +36 -0
  53. package/dist/core/events/run_finished_detail.d.ts.map +1 -0
  54. package/dist/core/events/state_detail.d.ts +5 -0
  55. package/dist/core/events/state_detail.d.ts.map +1 -0
  56. package/dist/core/events/submit_detail.d.ts +8 -0
  57. package/dist/core/events/submit_detail.d.ts.map +1 -0
  58. package/dist/core/events/thread_detail.d.ts +27 -0
  59. package/dist/core/events/thread_detail.d.ts.map +1 -0
  60. package/dist/core/events/thread_title_detail.d.ts +8 -0
  61. package/dist/core/events/thread_title_detail.d.ts.map +1 -0
  62. package/dist/core/events/toggle_detail.d.ts +5 -0
  63. package/dist/core/events/toggle_detail.d.ts.map +1 -0
  64. package/dist/core/events/tool_run.d.ts +11 -0
  65. package/dist/core/events/tool_run.d.ts.map +1 -0
  66. package/dist/core/events/unread_detail.d.ts +11 -0
  67. package/dist/core/events/unread_detail.d.ts.map +1 -0
  68. package/dist/core/message_role.d.ts +4 -0
  69. package/dist/core/message_role.d.ts.map +1 -0
  70. package/dist/core/read_flag.d.ts +11 -0
  71. package/dist/core/read_flag.d.ts.map +1 -0
  72. package/dist/core/read_json_attribute.d.ts +15 -0
  73. package/dist/core/read_json_attribute.d.ts.map +1 -0
  74. package/dist/core/read_max_tool_rounds.d.ts +9 -0
  75. package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
  76. package/dist/core/remote_conversation_store.d.ts +71 -0
  77. package/dist/core/remote_conversation_store.d.ts.map +1 -0
  78. package/dist/core/run_handlers.d.ts +85 -0
  79. package/dist/core/run_handlers.d.ts.map +1 -0
  80. package/dist/core/run_index.d.ts +58 -0
  81. package/dist/core/run_index.d.ts.map +1 -0
  82. package/dist/core/storage_scope.d.ts +118 -0
  83. package/dist/core/storage_scope.d.ts.map +1 -0
  84. package/dist/core/tool_outcome.d.ts +27 -0
  85. package/dist/core/tool_outcome.d.ts.map +1 -0
  86. package/dist/core/transcribe_audio.d.ts +32 -0
  87. package/dist/core/transcribe_audio.d.ts.map +1 -0
  88. package/dist/core/upload_attachment.d.ts +44 -0
  89. package/dist/core/upload_attachment.d.ts.map +1 -0
  90. package/dist/core/utils.d.ts +117 -0
  91. package/dist/core/utils.d.ts.map +1 -0
  92. package/dist/dom/animations.d.ts +122 -0
  93. package/dist/dom/animations.d.ts.map +1 -0
  94. package/dist/dom/dom_driver.d.ts +28 -0
  95. package/dist/dom/dom_driver.d.ts.map +1 -0
  96. package/dist/dom/highlight_overlay.d.ts +39 -0
  97. package/dist/dom/highlight_overlay.d.ts.map +1 -0
  98. package/dist/dom/native_setter.d.ts +5 -0
  99. package/dist/dom/native_setter.d.ts.map +1 -0
  100. package/dist/dom/ring_color.d.ts +16 -0
  101. package/dist/dom/ring_color.d.ts.map +1 -0
  102. package/dist/dom/utils.d.ts +3 -0
  103. package/dist/dom/utils.d.ts.map +1 -0
  104. package/dist/index.d.ts +62 -0
  105. package/dist/index.d.ts.map +1 -0
  106. package/dist/index.js +223 -0
  107. package/dist/index.js.map +7 -0
  108. package/dist/skills/fill_template.d.ts +16 -0
  109. package/dist/skills/fill_template.d.ts.map +1 -0
  110. package/dist/skills/parse_skills.d.ts +8 -0
  111. package/dist/skills/parse_skills.d.ts.map +1 -0
  112. package/dist/skills/skill.d.ts +33 -0
  113. package/dist/skills/skill.d.ts.map +1 -0
  114. package/dist/skills/skill_catalog.d.ts +69 -0
  115. package/dist/skills/skill_catalog.d.ts.map +1 -0
  116. package/dist/skills/skill_name_from.d.ts +12 -0
  117. package/dist/skills/skill_name_from.d.ts.map +1 -0
  118. package/dist/tools/chat_surface_tools.d.ts +77 -0
  119. package/dist/tools/chat_surface_tools.d.ts.map +1 -0
  120. package/dist/tools/client_tool_registry.d.ts +58 -0
  121. package/dist/tools/client_tool_registry.d.ts.map +1 -0
  122. package/dist/tools/is_destructive.d.ts +14 -0
  123. package/dist/tools/is_destructive.d.ts.map +1 -0
  124. package/dist/tools/is_navigates.d.ts +9 -0
  125. package/dist/tools/is_navigates.d.ts.map +1 -0
  126. package/dist/tools/page_action_tools.d.ts +30 -0
  127. package/dist/tools/page_action_tools.d.ts.map +1 -0
  128. package/dist/tools/page_map.d.ts +16 -0
  129. package/dist/tools/page_map.d.ts.map +1 -0
  130. package/dist/tools/page_state.d.ts +27 -0
  131. package/dist/tools/page_state.d.ts.map +1 -0
  132. package/dist/tools/parse_tool_catalog.d.ts +21 -0
  133. package/dist/tools/parse_tool_catalog.d.ts.map +1 -0
  134. package/dist/tools/route_map.d.ts +37 -0
  135. package/dist/tools/route_map.d.ts.map +1 -0
  136. package/dist/tools/tool_catalog.d.ts +86 -0
  137. package/dist/tools/tool_catalog.d.ts.map +1 -0
  138. package/dist/tools/tool_dispatch.d.ts +82 -0
  139. package/dist/tools/tool_dispatch.d.ts.map +1 -0
  140. package/dist/ui/charts/chart_block.d.ts +57 -0
  141. package/dist/ui/charts/chart_block.d.ts.map +1 -0
  142. package/dist/ui/charts/chart_spec_from.d.ts +12 -0
  143. package/dist/ui/charts/chart_spec_from.d.ts.map +1 -0
  144. package/dist/ui/charts/chart_styles.d.ts +2 -0
  145. package/dist/ui/charts/chart_styles.d.ts.map +1 -0
  146. package/dist/ui/charts/chart_tool.d.ts +15 -0
  147. package/dist/ui/charts/chart_tool.d.ts.map +1 -0
  148. package/dist/ui/composer/attachment_styles.d.ts +2 -0
  149. package/dist/ui/composer/attachment_styles.d.ts.map +1 -0
  150. package/dist/ui/composer/attachment_tray.d.ts +53 -0
  151. package/dist/ui/composer/attachment_tray.d.ts.map +1 -0
  152. package/dist/ui/composer/auto_grow.d.ts +10 -0
  153. package/dist/ui/composer/auto_grow.d.ts.map +1 -0
  154. package/dist/ui/composer/build_composer.d.ts +32 -0
  155. package/dist/ui/composer/build_composer.d.ts.map +1 -0
  156. package/dist/ui/composer/composer_attachments.d.ts +57 -0
  157. package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
  158. package/dist/ui/composer/composer_styles.d.ts +2 -0
  159. package/dist/ui/composer/composer_styles.d.ts.map +1 -0
  160. package/dist/ui/composer/composer_voice.d.ts +48 -0
  161. package/dist/ui/composer/composer_voice.d.ts.map +1 -0
  162. package/dist/ui/composer/skill_styles.d.ts +2 -0
  163. package/dist/ui/composer/skill_styles.d.ts.map +1 -0
  164. package/dist/ui/composer/skills_menu.d.ts +34 -0
  165. package/dist/ui/composer/skills_menu.d.ts.map +1 -0
  166. package/dist/ui/composer/voice_input.d.ts +46 -0
  167. package/dist/ui/composer/voice_input.d.ts.map +1 -0
  168. package/dist/ui/excerpts/attach_copy_buttons.d.ts +13 -0
  169. package/dist/ui/excerpts/attach_copy_buttons.d.ts.map +1 -0
  170. package/dist/ui/excerpts/copy_payload.d.ts +30 -0
  171. package/dist/ui/excerpts/copy_payload.d.ts.map +1 -0
  172. package/dist/ui/excerpts/page_quote_offer.d.ts +33 -0
  173. package/dist/ui/excerpts/page_quote_offer.d.ts.map +1 -0
  174. package/dist/ui/excerpts/place_offer.d.ts +9 -0
  175. package/dist/ui/excerpts/place_offer.d.ts.map +1 -0
  176. package/dist/ui/excerpts/quote_selection.d.ts +66 -0
  177. package/dist/ui/excerpts/quote_selection.d.ts.map +1 -0
  178. package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
  179. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
  180. package/dist/ui/fill_ui_string.d.ts +32 -0
  181. package/dist/ui/fill_ui_string.d.ts.map +1 -0
  182. package/dist/ui/history/checkpoint_menu.d.ts +41 -0
  183. package/dist/ui/history/checkpoint_menu.d.ts.map +1 -0
  184. package/dist/ui/history/conversation_history.d.ts +317 -0
  185. package/dist/ui/history/conversation_history.d.ts.map +1 -0
  186. package/dist/ui/history/history_styles.d.ts +2 -0
  187. package/dist/ui/history/history_styles.d.ts.map +1 -0
  188. package/dist/ui/history/relative_time.d.ts +23 -0
  189. package/dist/ui/history/relative_time.d.ts.map +1 -0
  190. package/dist/ui/history/thread_drawer.d.ts +64 -0
  191. package/dist/ui/history/thread_drawer.d.ts.map +1 -0
  192. package/dist/ui/history/utils.d.ts +2 -0
  193. package/dist/ui/history/utils.d.ts.map +1 -0
  194. package/dist/ui/interrupts/approval_card.d.ts +64 -0
  195. package/dist/ui/interrupts/approval_card.d.ts.map +1 -0
  196. package/dist/ui/interrupts/approval_styles.d.ts +2 -0
  197. package/dist/ui/interrupts/approval_styles.d.ts.map +1 -0
  198. package/dist/ui/interrupts/confirmation_card.d.ts +51 -0
  199. package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -0
  200. package/dist/ui/interrupts/confirmation_styles.d.ts +2 -0
  201. package/dist/ui/interrupts/confirmation_styles.d.ts.map +1 -0
  202. package/dist/ui/interrupts/pending_decision.d.ts +22 -0
  203. package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
  204. package/dist/ui/interrupts/question_card.d.ts +49 -0
  205. package/dist/ui/interrupts/question_card.d.ts.map +1 -0
  206. package/dist/ui/interrupts/question_styles.d.ts +2 -0
  207. package/dist/ui/interrupts/question_styles.d.ts.map +1 -0
  208. package/dist/ui/place/place.d.ts +17 -0
  209. package/dist/ui/place/place.d.ts.map +1 -0
  210. package/dist/ui/place/place_chip.d.ts +26 -0
  211. package/dist/ui/place/place_chip.d.ts.map +1 -0
  212. package/dist/ui/place/place_from_route.d.ts +9 -0
  213. package/dist/ui/place/place_from_route.d.ts.map +1 -0
  214. package/dist/ui/place/place_styles.d.ts +2 -0
  215. package/dist/ui/place/place_styles.d.ts.map +1 -0
  216. package/dist/ui/placement/clamp_launcher.d.ts +20 -0
  217. package/dist/ui/placement/clamp_launcher.d.ts.map +1 -0
  218. package/dist/ui/placement/clamp_panel.d.ts +13 -0
  219. package/dist/ui/placement/clamp_panel.d.ts.map +1 -0
  220. package/dist/ui/placement/collapse_styles.d.ts +2 -0
  221. package/dist/ui/placement/collapse_styles.d.ts.map +1 -0
  222. package/dist/ui/placement/dock_styles.d.ts +2 -0
  223. package/dist/ui/placement/dock_styles.d.ts.map +1 -0
  224. package/dist/ui/placement/embedded_styles.d.ts +2 -0
  225. package/dist/ui/placement/embedded_styles.d.ts.map +1 -0
  226. package/dist/ui/placement/launcher_drag.d.ts +49 -0
  227. package/dist/ui/placement/launcher_drag.d.ts.map +1 -0
  228. package/dist/ui/placement/launcher_placement.d.ts +77 -0
  229. package/dist/ui/placement/launcher_placement.d.ts.map +1 -0
  230. package/dist/ui/placement/launcher_styles.d.ts +2 -0
  231. package/dist/ui/placement/launcher_styles.d.ts.map +1 -0
  232. package/dist/ui/placement/page_styles.d.ts +2 -0
  233. package/dist/ui/placement/page_styles.d.ts.map +1 -0
  234. package/dist/ui/placement/panel_drag.d.ts +40 -0
  235. package/dist/ui/placement/panel_drag.d.ts.map +1 -0
  236. package/dist/ui/placement/panel_placement.d.ts +173 -0
  237. package/dist/ui/placement/panel_placement.d.ts.map +1 -0
  238. package/dist/ui/placement/place_widget.d.ts +31 -0
  239. package/dist/ui/placement/place_widget.d.ts.map +1 -0
  240. package/dist/ui/placement/preset_styles.d.ts +2 -0
  241. package/dist/ui/placement/preset_styles.d.ts.map +1 -0
  242. package/dist/ui/placement/resize_handle.d.ts +78 -0
  243. package/dist/ui/placement/resize_handle.d.ts.map +1 -0
  244. package/dist/ui/placement/resize_styles.d.ts +2 -0
  245. package/dist/ui/placement/resize_styles.d.ts.map +1 -0
  246. package/dist/ui/placement/sidebar_styles.d.ts +2 -0
  247. package/dist/ui/placement/sidebar_styles.d.ts.map +1 -0
  248. package/dist/ui/placement/small_viewport_styles.d.ts +2 -0
  249. package/dist/ui/placement/small_viewport_styles.d.ts.map +1 -0
  250. package/dist/ui/placement/surface_for.d.ts +93 -0
  251. package/dist/ui/placement/surface_for.d.ts.map +1 -0
  252. package/dist/ui/progress/prettify_tool_name.d.ts +11 -0
  253. package/dist/ui/progress/prettify_tool_name.d.ts.map +1 -0
  254. package/dist/ui/progress/run_announcer.d.ts +41 -0
  255. package/dist/ui/progress/run_announcer.d.ts.map +1 -0
  256. package/dist/ui/progress/run_notice.d.ts +21 -0
  257. package/dist/ui/progress/run_notice.d.ts.map +1 -0
  258. package/dist/ui/progress/run_notice_styles.d.ts +2 -0
  259. package/dist/ui/progress/run_notice_styles.d.ts.map +1 -0
  260. package/dist/ui/progress/subagent_panel.d.ts +101 -0
  261. package/dist/ui/progress/subagent_panel.d.ts.map +1 -0
  262. package/dist/ui/progress/subagent_progress.d.ts +57 -0
  263. package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
  264. package/dist/ui/progress/subagent_update.d.ts +28 -0
  265. package/dist/ui/progress/subagent_update.d.ts.map +1 -0
  266. package/dist/ui/progress/thoughts_block.d.ts +57 -0
  267. package/dist/ui/progress/thoughts_block.d.ts.map +1 -0
  268. package/dist/ui/progress/thoughts_styles.d.ts +2 -0
  269. package/dist/ui/progress/thoughts_styles.d.ts.map +1 -0
  270. package/dist/ui/progress/tool_call_card.d.ts +167 -0
  271. package/dist/ui/progress/tool_call_card.d.ts.map +1 -0
  272. package/dist/ui/progress/tool_group_styles.d.ts +2 -0
  273. package/dist/ui/progress/tool_group_styles.d.ts.map +1 -0
  274. package/dist/ui/progress/tool_step_group.d.ts +50 -0
  275. package/dist/ui/progress/tool_step_group.d.ts.map +1 -0
  276. package/dist/ui/progress/tool_step_styles.d.ts +2 -0
  277. package/dist/ui/progress/tool_step_styles.d.ts.map +1 -0
  278. package/dist/ui/shell/adopt_styles.d.ts +28 -0
  279. package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
  280. package/dist/ui/shell/build_header.d.ts +45 -0
  281. package/dist/ui/shell/build_header.d.ts.map +1 -0
  282. package/dist/ui/shell/build_launcher.d.ts +33 -0
  283. package/dist/ui/shell/build_launcher.d.ts.map +1 -0
  284. package/dist/ui/shell/collapse_glyph.d.ts +14 -0
  285. package/dist/ui/shell/collapse_glyph.d.ts.map +1 -0
  286. package/dist/ui/shell/frame_styles.d.ts +2 -0
  287. package/dist/ui/shell/frame_styles.d.ts.map +1 -0
  288. package/dist/ui/shell/glyph_slot.d.ts +7 -0
  289. package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
  290. package/dist/ui/shell/header_button.d.ts +11 -0
  291. package/dist/ui/shell/header_button.d.ts.map +1 -0
  292. package/dist/ui/shell/icon_element.d.ts +12 -0
  293. package/dist/ui/shell/icon_element.d.ts.map +1 -0
  294. package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
  295. package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
  296. package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
  297. package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
  298. package/dist/ui/shell/theme_styles.d.ts +2 -0
  299. package/dist/ui/shell/theme_styles.d.ts.map +1 -0
  300. package/dist/ui/shell/token_styles.d.ts +2 -0
  301. package/dist/ui/shell/token_styles.d.ts.map +1 -0
  302. package/dist/ui/styles.d.ts +2 -0
  303. package/dist/ui/styles.d.ts.map +1 -0
  304. package/dist/ui/themes/classic_styles.d.ts +2 -0
  305. package/dist/ui/themes/classic_styles.d.ts.map +1 -0
  306. package/dist/ui/themes/classic_tokens.d.ts +2 -0
  307. package/dist/ui/themes/classic_tokens.d.ts.map +1 -0
  308. package/dist/ui/themes/code_tokens.d.ts +2 -0
  309. package/dist/ui/themes/code_tokens.d.ts.map +1 -0
  310. package/dist/ui/themes/ledger_styles.d.ts +2 -0
  311. package/dist/ui/themes/ledger_styles.d.ts.map +1 -0
  312. package/dist/ui/themes/ledger_tokens.d.ts +2 -0
  313. package/dist/ui/themes/ledger_tokens.d.ts.map +1 -0
  314. package/dist/ui/themes/modern_styles.d.ts +2 -0
  315. package/dist/ui/themes/modern_styles.d.ts.map +1 -0
  316. package/dist/ui/themes/quiet_styles.d.ts +2 -0
  317. package/dist/ui/themes/quiet_styles.d.ts.map +1 -0
  318. package/dist/ui/themes/scheme_styles.d.ts +2 -0
  319. package/dist/ui/themes/scheme_styles.d.ts.map +1 -0
  320. package/dist/ui/themes/soft_styles.d.ts +2 -0
  321. package/dist/ui/themes/soft_styles.d.ts.map +1 -0
  322. package/dist/ui/themes/soft_tokens.d.ts +2 -0
  323. package/dist/ui/themes/soft_tokens.d.ts.map +1 -0
  324. package/dist/ui/themes/theme_hosts.d.ts +15 -0
  325. package/dist/ui/themes/theme_hosts.d.ts.map +1 -0
  326. package/dist/ui/transcript/answer_actions.d.ts +50 -0
  327. package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
  328. package/dist/ui/transcript/answer_stream.d.ts +56 -0
  329. package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
  330. package/dist/ui/transcript/attachment_chip_styles.d.ts +2 -0
  331. package/dist/ui/transcript/attachment_chip_styles.d.ts.map +1 -0
  332. package/dist/ui/transcript/attachment_chips.d.ts +21 -0
  333. package/dist/ui/transcript/attachment_chips.d.ts.map +1 -0
  334. package/dist/ui/transcript/build_transcript.d.ts +21 -0
  335. package/dist/ui/transcript/build_transcript.d.ts.map +1 -0
  336. package/dist/ui/transcript/greeting_styles.d.ts +2 -0
  337. package/dist/ui/transcript/greeting_styles.d.ts.map +1 -0
  338. package/dist/ui/transcript/groups_tool_steps.d.ts +13 -0
  339. package/dist/ui/transcript/groups_tool_steps.d.ts.map +1 -0
  340. package/dist/ui/transcript/message_action_styles.d.ts +2 -0
  341. package/dist/ui/transcript/message_action_styles.d.ts.map +1 -0
  342. package/dist/ui/transcript/message_actions.d.ts +74 -0
  343. package/dist/ui/transcript/message_actions.d.ts.map +1 -0
  344. package/dist/ui/transcript/message_styles.d.ts +2 -0
  345. package/dist/ui/transcript/message_styles.d.ts.map +1 -0
  346. package/dist/ui/transcript/render_markdown.d.ts +40 -0
  347. package/dist/ui/transcript/render_markdown.d.ts.map +1 -0
  348. package/dist/ui/transcript/render_or_warn.d.ts +16 -0
  349. package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
  350. package/dist/ui/transcript/reveal_words.d.ts +8 -0
  351. package/dist/ui/transcript/reveal_words.d.ts.map +1 -0
  352. package/dist/ui/transcript/starter_chips.d.ts +21 -0
  353. package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
  354. package/dist/ui/transcript/stick_to_bottom.d.ts +55 -0
  355. package/dist/ui/transcript/stick_to_bottom.d.ts.map +1 -0
  356. package/dist/ui/transcript/suggestion_chips.d.ts +29 -0
  357. package/dist/ui/transcript/suggestion_chips.d.ts.map +1 -0
  358. package/dist/ui/transcript/transcript.d.ts +196 -0
  359. package/dist/ui/transcript/transcript.d.ts.map +1 -0
  360. package/dist/ui/transcript/transcript_styles.d.ts +2 -0
  361. package/dist/ui/transcript/transcript_styles.d.ts.map +1 -0
  362. package/dist/ui/ui_strings.d.ts +352 -0
  363. package/dist/ui/ui_strings.d.ts.map +1 -0
  364. package/dist/version.d.ts +2 -0
  365. package/dist/version.d.ts.map +1 -0
  366. package/package.json +86 -3
  367. package/src/bundle.ts +2 -0
  368. package/src/chat.ts +2 -0
  369. package/src/constants.ts +650 -0
  370. package/src/core/activity_registration.ts +19 -0
  371. package/src/core/activity_registry.ts +133 -0
  372. package/src/core/activity_renderer.ts +19 -0
  373. package/src/core/ag_ui_chat.ts +3966 -0
  374. package/src/core/agui_client.ts +1028 -0
  375. package/src/core/attachment.ts +62 -0
  376. package/src/core/chat_registry.ts +53 -0
  377. package/src/core/client_seed.ts +27 -0
  378. package/src/core/conversation_store.ts +478 -0
  379. package/src/core/create_http_agent.ts +97 -0
  380. package/src/core/define_chat.ts +25 -0
  381. package/src/core/events/attachments_detail.ts +9 -0
  382. package/src/core/events/bar_height_detail.ts +5 -0
  383. package/src/core/events/custom_agent_detail.ts +7 -0
  384. package/src/core/events/dock_width_detail.ts +7 -0
  385. package/src/core/events/feedback_detail.ts +27 -0
  386. package/src/core/events/invalidate_detail.ts +15 -0
  387. package/src/core/events/maximize_detail.ts +4 -0
  388. package/src/core/events/run_finished_detail.ts +36 -0
  389. package/src/core/events/state_detail.ts +4 -0
  390. package/src/core/events/submit_detail.ts +8 -0
  391. package/src/core/events/thread_detail.ts +26 -0
  392. package/src/core/events/thread_title_detail.ts +7 -0
  393. package/src/core/events/toggle_detail.ts +4 -0
  394. package/src/core/events/tool_run.ts +10 -0
  395. package/src/core/events/unread_detail.ts +10 -0
  396. package/src/core/message_role.ts +4 -0
  397. package/src/core/read_flag.ts +13 -0
  398. package/src/core/read_json_attribute.ts +30 -0
  399. package/src/core/read_max_tool_rounds.ts +13 -0
  400. package/src/core/remote_conversation_store.ts +279 -0
  401. package/src/core/run_handlers.ts +524 -0
  402. package/src/core/run_index.ts +114 -0
  403. package/src/core/storage_scope.ts +291 -0
  404. package/src/core/tool_outcome.ts +39 -0
  405. package/src/core/transcribe_audio.ts +76 -0
  406. package/src/core/upload_attachment.ts +130 -0
  407. package/src/core/utils.ts +230 -0
  408. package/src/dom/animations.ts +341 -0
  409. package/src/dom/dom_driver.ts +91 -0
  410. package/src/dom/highlight_overlay.ts +204 -0
  411. package/src/dom/native_setter.ts +40 -0
  412. package/src/dom/ring_color.ts +206 -0
  413. package/src/dom/utils.ts +5 -0
  414. package/src/index.ts +220 -0
  415. package/src/skills/fill_template.ts +31 -0
  416. package/src/skills/parse_skills.ts +31 -0
  417. package/src/skills/skill.ts +32 -0
  418. package/src/skills/skill_catalog.ts +172 -0
  419. package/src/skills/skill_name_from.ts +19 -0
  420. package/src/tools/chat_surface_tools.ts +183 -0
  421. package/src/tools/client_tool_registry.ts +80 -0
  422. package/src/tools/is_destructive.ts +17 -0
  423. package/src/tools/is_navigates.ts +12 -0
  424. package/src/tools/page_action_tools.ts +123 -0
  425. package/src/tools/page_map.ts +26 -0
  426. package/src/tools/page_state.ts +57 -0
  427. package/src/tools/parse_tool_catalog.ts +41 -0
  428. package/src/tools/route_map.ts +132 -0
  429. package/src/tools/tool_catalog.ts +334 -0
  430. package/src/tools/tool_dispatch.ts +453 -0
  431. package/src/ui/charts/chart_block.ts +519 -0
  432. package/src/ui/charts/chart_spec_from.ts +125 -0
  433. package/src/ui/charts/chart_styles.ts +63 -0
  434. package/src/ui/charts/chart_tool.ts +72 -0
  435. package/src/ui/composer/attachment_styles.ts +71 -0
  436. package/src/ui/composer/attachment_tray.ts +295 -0
  437. package/src/ui/composer/auto_grow.ts +12 -0
  438. package/src/ui/composer/build_composer.ts +114 -0
  439. package/src/ui/composer/composer_attachments.ts +324 -0
  440. package/src/ui/composer/composer_styles.ts +177 -0
  441. package/src/ui/composer/composer_voice.ts +93 -0
  442. package/src/ui/composer/skill_styles.ts +120 -0
  443. package/src/ui/composer/skills_menu.ts +188 -0
  444. package/src/ui/composer/voice_input.ts +282 -0
  445. package/src/ui/excerpts/attach_copy_buttons.ts +73 -0
  446. package/src/ui/excerpts/copy_payload.ts +156 -0
  447. package/src/ui/excerpts/page_quote_offer.ts +203 -0
  448. package/src/ui/excerpts/place_offer.ts +24 -0
  449. package/src/ui/excerpts/quote_selection.ts +345 -0
  450. package/src/ui/excerpts/transcript_quote_offer.ts +175 -0
  451. package/src/ui/fill_ui_string.ts +41 -0
  452. package/src/ui/history/checkpoint_menu.ts +300 -0
  453. package/src/ui/history/conversation_history.ts +1169 -0
  454. package/src/ui/history/history_styles.ts +492 -0
  455. package/src/ui/history/relative_time.ts +50 -0
  456. package/src/ui/history/thread_drawer.ts +471 -0
  457. package/src/ui/history/utils.ts +11 -0
  458. package/src/ui/interrupts/approval_card.ts +202 -0
  459. package/src/ui/interrupts/approval_styles.ts +52 -0
  460. package/src/ui/interrupts/confirmation_card.ts +138 -0
  461. package/src/ui/interrupts/confirmation_styles.ts +114 -0
  462. package/src/ui/interrupts/pending_decision.ts +33 -0
  463. package/src/ui/interrupts/question_card.ts +213 -0
  464. package/src/ui/interrupts/question_styles.ts +73 -0
  465. package/src/ui/place/place.ts +16 -0
  466. package/src/ui/place/place_chip.ts +146 -0
  467. package/src/ui/place/place_from_route.ts +27 -0
  468. package/src/ui/place/place_styles.ts +108 -0
  469. package/src/ui/placement/clamp_launcher.ts +38 -0
  470. package/src/ui/placement/clamp_panel.ts +30 -0
  471. package/src/ui/placement/collapse_styles.ts +472 -0
  472. package/src/ui/placement/dock_styles.ts +116 -0
  473. package/src/ui/placement/embedded_styles.ts +14 -0
  474. package/src/ui/placement/launcher_drag.ts +206 -0
  475. package/src/ui/placement/launcher_placement.ts +133 -0
  476. package/src/ui/placement/launcher_styles.ts +189 -0
  477. package/src/ui/placement/page_styles.ts +47 -0
  478. package/src/ui/placement/panel_drag.ts +138 -0
  479. package/src/ui/placement/panel_placement.ts +1975 -0
  480. package/src/ui/placement/place_widget.ts +64 -0
  481. package/src/ui/placement/preset_styles.ts +49 -0
  482. package/src/ui/placement/resize_handle.ts +229 -0
  483. package/src/ui/placement/resize_styles.ts +201 -0
  484. package/src/ui/placement/sidebar_styles.ts +113 -0
  485. package/src/ui/placement/small_viewport_styles.ts +70 -0
  486. package/src/ui/placement/surface_for.ts +183 -0
  487. package/src/ui/progress/prettify_tool_name.ts +16 -0
  488. package/src/ui/progress/run_announcer.ts +67 -0
  489. package/src/ui/progress/run_notice.ts +61 -0
  490. package/src/ui/progress/run_notice_styles.ts +52 -0
  491. package/src/ui/progress/subagent_panel.ts +227 -0
  492. package/src/ui/progress/subagent_progress.ts +200 -0
  493. package/src/ui/progress/subagent_update.ts +89 -0
  494. package/src/ui/progress/thoughts_block.ts +165 -0
  495. package/src/ui/progress/thoughts_styles.ts +74 -0
  496. package/src/ui/progress/tool_call_card.ts +429 -0
  497. package/src/ui/progress/tool_group_styles.ts +313 -0
  498. package/src/ui/progress/tool_step_group.ts +317 -0
  499. package/src/ui/progress/tool_step_styles.ts +486 -0
  500. package/src/ui/shell/adopt_styles.ts +34 -0
  501. package/src/ui/shell/build_header.ts +121 -0
  502. package/src/ui/shell/build_launcher.ts +79 -0
  503. package/src/ui/shell/collapse_glyph.ts +42 -0
  504. package/src/ui/shell/frame_styles.ts +147 -0
  505. package/src/ui/shell/glyph_slot.ts +12 -0
  506. package/src/ui/shell/header_button.ts +22 -0
  507. package/src/ui/shell/icon_element.ts +33 -0
  508. package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
  509. package/src/ui/shell/read_launcher_icon_url.ts +9 -0
  510. package/src/ui/shell/theme_styles.ts +11 -0
  511. package/src/ui/shell/token_styles.ts +480 -0
  512. package/src/ui/styles.ts +110 -0
  513. package/src/ui/themes/classic_styles.ts +32 -0
  514. package/src/ui/themes/classic_tokens.ts +51 -0
  515. package/src/ui/themes/code_tokens.ts +21 -0
  516. package/src/ui/themes/ledger_styles.ts +169 -0
  517. package/src/ui/themes/ledger_tokens.ts +33 -0
  518. package/src/ui/themes/modern_styles.ts +80 -0
  519. package/src/ui/themes/quiet_styles.ts +118 -0
  520. package/src/ui/themes/scheme_styles.ts +26 -0
  521. package/src/ui/themes/soft_styles.ts +149 -0
  522. package/src/ui/themes/soft_tokens.ts +29 -0
  523. package/src/ui/themes/theme_hosts.ts +14 -0
  524. package/src/ui/transcript/answer_actions.ts +165 -0
  525. package/src/ui/transcript/answer_stream.ts +123 -0
  526. package/src/ui/transcript/attachment_chip_styles.ts +116 -0
  527. package/src/ui/transcript/attachment_chips.ts +82 -0
  528. package/src/ui/transcript/build_transcript.ts +67 -0
  529. package/src/ui/transcript/greeting_styles.ts +171 -0
  530. package/src/ui/transcript/groups_tool_steps.ts +16 -0
  531. package/src/ui/transcript/message_action_styles.ts +155 -0
  532. package/src/ui/transcript/message_actions.ts +234 -0
  533. package/src/ui/transcript/message_styles.ts +262 -0
  534. package/src/ui/transcript/render_markdown.ts +181 -0
  535. package/src/ui/transcript/render_or_warn.ts +22 -0
  536. package/src/ui/transcript/reveal_words.ts +41 -0
  537. package/src/ui/transcript/starter_chips.ts +42 -0
  538. package/src/ui/transcript/stick_to_bottom.ts +126 -0
  539. package/src/ui/transcript/suggestion_chips.ts +73 -0
  540. package/src/ui/transcript/transcript.ts +555 -0
  541. package/src/ui/transcript/transcript_styles.ts +153 -0
  542. package/src/ui/ui_strings.ts +550 -0
  543. package/src/version.ts +1 -0
package/README.md CHANGED
@@ -1,3 +1,4398 @@
1
- # Temporary Holding Version
1
+ # @artooi/agents-web-components
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/Artui/agents-web-components/workflows/tests/badge.svg)](https://github.com/Artui/agents-web-components/actions/workflows/tests.yml)
4
+ [![npm](https://img.shields.io/npm/v/@artooi/agents-web-components.svg)](https://www.npmjs.com/package/@artooi/agents-web-components)
5
+ [![License](https://img.shields.io/npm/l/@artooi/agents-web-components.svg)](https://github.com/Artui/agents-web-components/blob/main/LICENSE)
6
+
7
+ A **framework-free** `<ag-ui-chat>` Web Component over the [AG-UI](https://docs.ag-ui.com)
8
+ protocol. Drop it into any page — SPA or MPA, any framework or none — point it at an AG-UI
9
+ endpoint, and you get a streaming chat sidebar that can call tools you register in the browser.
10
+
11
+ It wraps [`@ag-ui/client`](https://www.npmjs.com/package/@ag-ui/client)'s `HttpAgent` and ships:
12
+
13
+ - A Custom Element with a self-contained Shadow DOM chat UI (header, scrolling transcript,
14
+ input row), themeable via CSS custom properties.
15
+ - A pluggable **client-side tool registry** — `registerTool({ name, description, parameters,
16
+ handler })`; every registered tool is added to each run's `RunAgentInput.tools`.
17
+ - Generic **DOM-driver primitives** (`fillField`, `clickElement`, `setControlValue`) and
18
+ **animation primitives** (`typeInto`, `highlightThenClick`, …) so the agent can drive the page
19
+ at human-readable speed.
20
+ - An **inline confirmation card** that intercepts tool calls needing confirmation (those whose
21
+ JSON Schema carries `x-destructive: true`, or a per-call `confirmPredicate`) before the handler
22
+ runs — rendered right in the transcript, never a modal overlay.
23
+ - **Markdown + HTML rendering** of assistant replies (sanitized `marked` + DOMPurify), with
24
+ themes, density/placement presets, incoming-text animations, tool-call display modes, an
25
+ animated thinking indicator, and an opt-in **skills** palette (prompt chips + `/`-commands).
26
+ - A **new-chat** button and a **collapse** toggle built into the header.
27
+ - An **MPA durability story**: a durable conversation store, a stable thread id, and a resumable
28
+ run loop that survives full page reloads (`x-navigates` + `navigationResult`).
29
+ - **Host seams** for SPAs: a route map, an auto-injected page map, page state, and an optional
30
+ `navigate()` callback.
31
+
32
+ No framework, no Django, no admin specifics live here. Downstream consumers (e.g.
33
+ `django-admin-agent`) register their own tool handlers on top via the pluggable registry.
34
+
35
+ This package is the successor of `@artooi/ag-ui-web-component`. Coming from it? See
36
+ [Moving from `@artooi/ag-ui-web-component`](#moving-from-artooiag-ui-web-component).
37
+
38
+ ---
39
+
40
+ ## Table of contents
41
+
42
+ - [Install](#install)
43
+ - [Quickstart](#quickstart)
44
+ - [Authenticating requests](#authenticating-requests)
45
+ - [Core concepts](#core-concepts)
46
+ - [The run loop and the AG-UI client](#the-run-loop-and-the-ag-ui-client)
47
+ - [Stopping a run](#stopping-a-run)
48
+ - [Registering tools](#registering-tools)
49
+ - [Inline confirmation (`x-destructive` / `x-confirm` / `confirmPredicate`)](#inline-confirmation-x-destructive--x-confirm--confirmpredicate)
50
+ - [DOM-driver and animation primitives](#dom-driver-and-animation-primitives)
51
+ - [Page-action tools](#page-action-tools)
52
+ - [New chat and collapse](#new-chat-and-collapse)
53
+ - [Maximizing the panel](#maximizing-the-panel)
54
+ - [Collapsing to the launcher](#collapsing-to-the-launcher)
55
+ - [Collapsing to a bottom bar](#collapsing-to-a-bottom-bar)
56
+ - [Tool-call display modes](#tool-call-display-modes)
57
+ - [Delegated sub-agents](#delegated-sub-agents)
58
+ - [Markdown rendering](#markdown-rendering)
59
+ - [Follow-up suggestions](#follow-up-suggestions)
60
+ - [Editing a gated call before approving it](#editing-a-gated-call-before-approving-it)
61
+ - [Localizing the timestamps](#localizing-the-timestamps)
62
+ - [Message actions: copy, retry, feedback](#message-actions-copy-retry-feedback)
63
+ - [Quoting a selection](#quoting-a-selection)
64
+ - [Run notices: compaction and agent skills](#run-notices-compaction-and-agent-skills)
65
+ - [Skills: prompt chips and slash palette](#skills-prompt-chips-and-slash-palette)
66
+ - [MPA durability: surviving full page reloads](#mpa-durability-surviving-full-page-reloads)
67
+ - [Who the stored conversation belongs to (`user-key`)](#who-the-stored-conversation-belongs-to-user-key)
68
+ - [Mounting more than one chat on a page](#mounting-more-than-one-chat-on-a-page)
69
+ - [Host seams: the SPA story](#host-seams-the-spa-story)
70
+ - [Where the user is: the place chip](#where-the-user-is-the-place-chip)
71
+ - [Public API surface](#public-api-surface)
72
+ - [Theming, density, and placement](#theming-density-and-placement)
73
+ - [Themes](#themes)
74
+ - [Internationalization (i18n)](#internationalization-i18n)
75
+ - [Building the bundle](#building-the-bundle)
76
+ - [Moving from `@artooi/ag-ui-web-component`](#moving-from-artooiag-ui-web-component)
77
+ - [Compatibility](#compatibility)
78
+
79
+ ---
80
+
81
+ ## Install
82
+
83
+ ```bash
84
+ npm install @artooi/agents-web-components
85
+ ```
86
+
87
+ The package ships two builds (see [`package.json` exports](package.json)):
88
+
89
+ | Entry | What it is | When to use |
90
+ | --- | --- | --- |
91
+ | `@artooi/agents-web-components` | ESM library build; `@ag-ui/*` stay **external** | You bundle the app yourself (Vite, webpack, esbuild) and want to dedupe `@ag-ui/*`. |
92
+ | `@artooi/agents-web-components/chat` | The same library build's `<ag-ui-chat>` entry: `AgUiChat` and `defineChat` | Beside the root entry; the two share one module instance, so import from both freely. |
93
+ | `@artooi/agents-web-components/bundle` | ESM bundle with `@ag-ui/*` **inlined**, minified | Drop in via a single `<script type="module">` with no build step. |
94
+
95
+ ### The vendored-bundle story
96
+
97
+ The `/bundle` entry inlines every dependency into one self-contained ESM file
98
+ (`dist/agents-web-components.bundle.js`). This is the artefact intended for **vendoring**: a host
99
+ that can't (or won't) run a JS build — for example a Django app — copies the built bundle into its
100
+ `static/` directory and serves it directly. `django-admin-agent` re-vendors a pinned built bundle
101
+ on every release. For SPA hosts that already have a bundler, import the bare package name instead
102
+ so `@ag-ui/client` / `@ag-ui/core` are deduped against the rest of your app.
103
+
104
+ ---
105
+
106
+ ## Quickstart
107
+
108
+ Drop the element into your page and register the tools the agent may call:
109
+
110
+ ```html
111
+ <script type="module">
112
+ import { fillField, clickElement, X_DESTRUCTIVE_KEY } from "@artooi/agents-web-components";
113
+ import { defineChat } from "@artooi/agents-web-components/chat";
114
+
115
+ // Register the <ag-ui-chat> Custom Element. An explicit call, not an import
116
+ // side effect, so importing the package defines nothing. Idempotent; it throws
117
+ // if another class already holds the tag (a second copy of the element).
118
+ defineChat();
119
+
120
+ const chat = document.querySelector("ag-ui-chat");
121
+
122
+ // Extra request headers (e.g. CSRF), sent with every request the element makes.
123
+ // For a credential that rotates, set chat.getHeaders instead - it is consulted
124
+ // per request. See "Authenticating requests".
125
+ chat.headers = { "X-CSRFToken": getCsrfToken() };
126
+
127
+ // A non-destructive tool: fills a text field with a typing animation.
128
+ chat.registerTool({
129
+ name: "fill_field",
130
+ description: "Fill a text input by id with a value.",
131
+ parameters: {
132
+ type: "object",
133
+ properties: { field: { type: "string" }, value: { type: "string" } },
134
+ required: ["field", "value"],
135
+ },
136
+ handler: async ({ field, value }) => {
137
+ await fillField(document.getElementById(field), String(value));
138
+ return "ok";
139
+ },
140
+ });
141
+
142
+ // A destructive tool: x-destructive at the JSON-Schema root gates it behind
143
+ // the inline confirmation card before the handler runs.
144
+ chat.registerTool({
145
+ name: "save_article",
146
+ description: "Save the article. Destructive — asks for confirmation.",
147
+ parameters: { type: "object", properties: {}, [X_DESTRUCTIVE_KEY]: true },
148
+ handler: async () => {
149
+ await clickElement(document.getElementById("save"));
150
+ return "saved";
151
+ },
152
+ });
153
+ </script>
154
+
155
+ <ag-ui-chat endpoint="/agent/" title-text="Assistant"></ag-ui-chat>
156
+ ```
157
+
158
+ > [!IMPORTANT]
159
+ > **If nothing appears, check that `defineChat()` ran.** Forgetting it looks
160
+ > like nothing at all. `<ag-ui-chat>` is then an *unknown element*, which every
161
+ > browser renders as an inline 0x0 box, in flow, with no console warning and no
162
+ > error — so the page looks finished and the chat is simply absent. There is
163
+ > nothing to search for.
164
+ >
165
+ > A bare `import "@artooi/agents-web-components"` registers nothing, deliberately:
166
+ > registration is an explicit call so the package stays tree-shakeable.
167
+ >
168
+ > `customElements.get("ag-ui-chat")` in a console answers it in one line.
169
+ > `undefined` means the call did not run.
170
+
171
+ That's the whole integration: an `endpoint` attribute pointing at your AG-UI server, optional
172
+ `headers`, and the tools you want the agent to be able to invoke in the browser. If your API is on
173
+ another origin, add `credentials="include"` too; see
174
+ [Authenticating requests](#authenticating-requests).
175
+
176
+ ### Attributes and properties
177
+
178
+ **Attributes** (set in HTML; the CSS-only ones are styling presets with no JS API):
179
+
180
+ | Attribute | Property | Notes |
181
+ | --- | --- | --- |
182
+ | `endpoint` | `endpoint` | The AG-UI endpoint URL. Required to send: without it a send is refused with a `console.error` naming the missing attribute and a `not-connected` run notice in the transcript. Reflecting getter + setter. |
183
+ | `credentials` | `credentials` | Cookie policy for every request the element makes: `omit` / `same-origin` / `include`. Unset means the browser default (`same-origin`), which sends no cookies cross-origin. See [Authenticating requests](#authenticating-requests). |
184
+ | `title-text` | — | Header label; defaults to `"Assistant"`. `"auto"` makes the header, the rail and the bar label follow the thread on screen (`threadTitle`), falling back to the strings table's `title` while it has none. Live: writing it after the element connects re-labels the header. See [When each attribute is read](#when-each-attribute-is-read). |
185
+ | `data-tool-display` | `toolDisplay` | How tool calls are drawn: `grouped` (the default: a run's consecutive calls fold into one row) / `inline` / `minimal` / `compact` / `full` (per-call cards; `full` was the default before `grouped`). An unknown value reads as `grouped`. See [Tool-call display modes](#tool-call-display-modes). |
186
+ | `data-tool-group-display` | `toolGroupDisplay` | How an opened step group draws its calls under `grouped`: `full` (the default) / `compact` / `minimal` / `inline`. Ignored under the four card modes. An absent or unknown value reads as `full`. See [Choosing how a group draws its calls](#choosing-how-a-group-draws-its-calls). |
187
+ | `data-text-animation` | — | Incoming-text reveal: `none` (default) / `fade` / `word`. |
188
+ | `data-prompt-chips` | — | Present (bare, or any value but `"false"`) to surface skills as chips. |
189
+ | `data-slash-commands` | — | Present (bare, or any value but `"false"`) to enable the `/`-command palette. |
190
+ | `data-skills` | — | Inline JSON skill catalog. A value that will not parse is ignored and reported on the console, rather than reading as an empty catalog. |
191
+ | `data-skills-url` | — | URL of a JSON skill catalog (fetched with the element's headers and cookie policy). |
192
+ | `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. |
193
+ | `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). |
194
+ | `user-name` | `userName` | The signed-in user's display name, for the [greeting](#the-greeting-on-an-empty-page) an empty conversation shows under `placement="page"`. Presentation only: unlike `user-key` it scopes nothing and is never sent. Absent or blank gives the nameless greeting. Live. |
195
+ | `data-threads-url` | — | URL of a server thread index (django-ag-ui's `ThreadsView`); enables durable, cross-device chat history. |
196
+ | `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`. |
197
+ | `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). |
198
+ | `data-attachments-url` | — | URL of the file-upload endpoint (django-ag-ui's `AttachmentsView`); reveals the composer's paperclip picker, drag-and-drop, and paste. |
199
+ | `data-attachment-accept` | — | `<input accept>` list for client-side type filtering (e.g. `image/*,.pdf`). The server stays authoritative. |
200
+ | `data-attachment-max-bytes` | — | Client-side upload size cap in bytes (default 10 MiB; `0` disables). The server stays authoritative. |
201
+ | `data-transcribe-url` | — | URL of the voice-transcription endpoint (django-ag-ui's `TranscribeView`); reveals the composer's mic button. See [Voice input](#voice-input). |
202
+ | `data-theme-toggle` | — | Boolean: show a built-in header light⇄dark toggle (persists per tab). Off by default. See [Theme toggle](#theme-toggle). |
203
+ | `data-strings` | `strings` | Partial JSON override of the UI string table (localization). The property wins key-by-key over the attribute; a value that will not parse is ignored and reported on the console. See [Internationalization](#internationalization-i18n). |
204
+ | `data-icon-url` | — | Header (and launcher) icon image URL. A slotted `slot="icon"` wins; see [Header & launcher icon](#header-and-launcher-icon). |
205
+ | `data-launcher-icon-url` | — | Icon image URL for the collapsed launcher only, when it should differ from the header's. Falls back to `data-icon-url`; a slotted `slot="launcher"` wins over both. |
206
+ | `data-unread-badge` | — | **On by default.** `="false"` hides the launcher's unread badge; the count and the `ag-ui-unread` event keep running. See [Collapsing to the launcher](#collapsing-to-the-launcher). |
207
+ | `data-launcher-drag` | — | **On by default.** `="false"` leaves the widget wherever your CSS puts it. Otherwise the collapsed launcher can be dragged anywhere on screen (the panel opens into the clearest space) and the open panel can be dragged by its header. See [Moving the launcher](#moving-the-launcher) and [Moving the panel](#moving-the-panel). |
208
+ | `data-quote-selection` | — | **On by default.** `="false"` stops the transcript offering to quote a selection. `quote()` keeps working either way. See [Quoting a selection](#quoting-a-selection). |
209
+ | `data-message-actions` | — | **Copy and retry by default; feedback is opt-in.** A comma list of the actions a finished answer keeps: `copy` / `retry` / `feedback` (e.g. `"copy,retry,feedback"` to add the rating pair, which needs an `ag-ui-feedback` listener). `="false"` removes the row entirely. See [Message actions](#message-actions-copy-retry-feedback). |
210
+ | `data-max-tool-rounds` | — | Upper bound on frontend tool-call → re-run rounds within one send (default 10; a value below 1 is ignored). Raise it for a page-driving agent whose turn takes many small steps. See [The run loop](#the-run-loop-and-the-ag-ui-client). |
211
+ | `data-page-actions` | — | Opt-in built-in page-action tools: a comma list of `scroll` / `drag` / `chat` (e.g. `"scroll,drag"`). See [Page-action tools](#page-action-tools). |
212
+ | `data-side` | — | For `placement="sidebar"`: which edge it docks to, `right` (default) / `left`. Styling, and read by script for the side `dockWidth` and `ag-ui-dock-width` report. Live. |
213
+ | `data-collapse-icon` | — | The header collapse control's mark: `panel-close` (a window with its docked pane and an arrow into the edge, mirrored on `data-side="left"`) / `chevron-up` / `chevron-down` / `chevron-left` / `chevron-right`. Absent, or any other value, follows the placement: `panel-close` on `sidebar` (and `side`), `chevron-down` everywhere else. A host icon in `slot="icon-collapse"` wins over it. Live. |
214
+ | `data-answer-well` | — | CSS-only boolean: box each assistant turn (its text, tool cards, and thinking) in one bordered "well". Off by default. See [The answer well](#the-answer-well). |
215
+ | `collapsed` | `collapsed` | Reflected boolean; collapses the widget to its [launcher](#collapsing-to-the-launcher) (a rail under `placement="sidebar"`, the header bar under `embedded`). Persisted per tab. `placement="page"` has no collapsed state and ignores it. |
216
+ | `collapse-to` | — | What the panel collapses to: `auto` (the default; absent or any other value reads the same) leaves every placement as it is, `bar` draws a strip along the bottom edge in place of the round launcher, `none` hides the collapsed widget so the host's own control brings it back. Under either, a corner panel still moves by its header and `moveTo` on a wide viewport, and remembers where it was dragged. Live. See [Collapsing to a bottom bar](#collapsing-to-a-bottom-bar). |
217
+ | `anchor` | `anchor` | Under `collapse-to="none"`, a selector for the host's element the panel rises from, right-aligned to it. The `anchor` property takes an element instead and wins over the attribute. Live. |
218
+ | `maximized` | `maximized` | Reflected boolean; grows the open panel to its [maximized size](#maximizing-the-panel). Refused on `page`, `full` and `embedded`, at 600px or less, and while collapsed; a collapse leaves it. Not persisted. Live. |
219
+ | `anchor-side` | — | Under `collapse-to="none"` with an anchor, which side of it the panel opens on: `above`, `below`, or `auto` (the default, and any other value), which takes the side with more room and re-decides on a window resize, never during a drag. Live. |
220
+ | `data-dragging` | — | **Written by the element, not by you.** Stamped on whichever handle a gesture is currently using, so the styles can react and so the element knows not to re-place the widget under a drag in progress. Cleared on `pointerup` and on `pointercancel`. |
221
+ | `data-expand-corner` | — | **Written by the element, not by you.** It stamps the corner a dragged or agent-moved panel opens from, so the collapse animation starts where the panel actually is. Listed because the element reads its own stamp back; setting it yourself is overwritten on the next move. |
222
+ | `data-greeting` | — | CSS-only: `off` turns the [greeting layout](#the-greeting-on-an-empty-page) off under `placement="page"`, where it is on by default. Any other value turns it on under `placement="embedded"`. No other placement has it. |
223
+ | `data-empty` | — | **Written by the element, not by you.** Present while the conversation has nothing in it, which is when the greeting shows. Public as a styling hook, so the chrome around a full-page chat can react to the same state, e.g. `ag-ui-chat[data-empty] ~ .page-footer { display: none }`. |
224
+ | `data-running` | — | **Written by the element, not by you.** Present while an interaction is in flight, the same state that shows the Stop button (see the `running` property). Public as a styling hook, as `data-empty` is, so the chrome around the chat can react to it, e.g. `ag-ui-chat[data-running] ~ .toolbar button { pointer-events: none }`. |
225
+ | `data-threads-search` | — | When the conversation list shows its search box: `auto` (absent, or any other value) from eight conversations, `always` over any list, `never` not at all. Live. See [The conversation list](#the-conversation-list). |
226
+ | `data-small-viewport` | — | `off` keeps the desktop layout at every width, opting out of the [small-viewport override](#small-viewports). Styling, and read by script for the width `dockWidth` reports on a phone. Live. Everything that override sets is a token you can re-state; its trigger is a media query, which is the one thing you cannot. |
227
+ | `data-paste-attach` | — | When to turn a long text paste into an attachment instead of composer text: absent for the 5000-character default, `off` to never, or a positive number of characters. Only acts where `data-attachments-url` (or a custom `uploadHandler`) gives it somewhere to go. |
228
+ | `data-starters` | — | JSON array of prompts offered on an empty transcript, e.g. `'["Summarise this page"]'`. Fallback content for `slot="empty"`, so slotting your own replaces them. Shares the four-prompt and 120-character limits with the suggestion chips a run pushes. Read once at connect. |
229
+ | `data-start-open` | — | Mount the panel open on a first visit. The corner placements otherwise rest at their launcher, the way every corner chat does; a stored choice wins over both. The placements that place themselves are unaffected. |
230
+ | `theme` | — | Styling: an optional theme name (`quiet` (default) / `soft` / `ledger` / `classic`, or a word of your own) and an optional scheme (`light` (default) / `dark` / `auto`), in any order, e.g. `soft dark`. Live: the [header theme toggle](#theme-toggle)'s glyph follows a scheme written after connect. See [Themes](#themes). |
231
+ | `density` | — | CSS-only: `comfortable` (default) / `compact`. |
232
+ | `placement` | — | CSS-only: `floating` (default) / `sidebar` / `page` / `embedded` / `full`. `full` fills the viewport at every width, inside the safe-area insets; the `--ag-ui-inset`, size and `--ag-ui-panel-radius` tokens still apply. |
233
+
234
+ Each header control also takes its own icon slot — `icon-history`, `icon-checkpoints`,
235
+ `icon-new`, `icon-maximize`, `icon-collapse` — with the built-in drawn glyph as the fallback, so a host can project a
236
+ brand `<img>` or `<svg>` in its place. The built-in header marks are drawn at
237
+ `--ag-ui-glyph-size`. The composer's glyphs work the
238
+ same way: `icon-send`, `icon-stop`, `icon-attach`, `icon-voice`.
239
+
240
+ ```html
241
+ <ag-ui-chat endpoint="/agent/">
242
+ <svg slot="icon-new" width="16" height="16"><!-- ... --></svg>
243
+ </ag-ui-chat>
244
+ ```
245
+
246
+ The collapse control's built-in mark follows the placement, because a sidebar hides sideways
247
+ into its edge rather than down: `panel-close` (a window with its docked pane and an arrow into
248
+ the edge) on `sidebar` and `side`, mirrored for either on `data-side="left"`, and
249
+ `chevron-down` on `floating`, `full`, `embedded` and every other placement. `data-collapse-icon`
250
+ picks one of the built-in marks outright, at any placement and live: `panel-close`,
251
+ `chevron-up`, `chevron-down`, `chevron-left` or `chevron-right`; any other value reads as
252
+ absent. `panel-close` always points into the edge the panel docks to, so it mirrors on a left
253
+ docked panel whether chosen or defaulted; the chevrons do not turn. For a mark of your own, slot it
254
+ into `icon-collapse`, which wins over the attribute and every placement:
255
+
256
+ ```html
257
+ <ag-ui-chat endpoint="/agent/" placement="floating" data-collapse-icon="chevron-right"></ag-ui-chat>
258
+ <ag-ui-chat endpoint="/agent/" placement="sidebar">
259
+ <svg slot="icon-collapse" width="16" height="16"><!-- ... --></svg>
260
+ </ag-ui-chat>
261
+ ```
262
+
263
+ #### When each attribute is read
264
+
265
+ The element observes two groups of attributes, and they behave differently once it is in the DOM.
266
+ Nothing outside those groups is observed: a CSS-only attribute (`theme`, `density`,
267
+ `data-answer-well`, `data-tool-group-display`) is read by the stylesheet rather than by script, and `endpoint`,
268
+ `data-tool-display`, `data-text-animation`, `data-runs-url`, `data-page-actions`,
269
+ `data-message-actions`, `data-max-tool-rounds`, `data-unread-badge`, `data-launcher-drag` and
270
+ `data-quote-selection` are re-read at each use, so a late write to any of those simply takes effect. The one attribute in
271
+ neither camp is `data-launcher-icon-url`: it is read while the element connects, like the group
272
+ below, but is not observed, so a late write is inert and says nothing.
273
+
274
+ **Live attributes.** Written at any time, before or after the element connects, and acted on
275
+ either way: `title-text`, `placement`, `collapse-to`, `anchor`, `anchor-side`, `maximized`, `data-side`, `data-small-viewport`, `data-threads-search`, `credentials`, `user-key`,
276
+ `user-name`, `data-tool-group-display`, `data-collapse-icon`, `theme`.
277
+
278
+ **Connect-time attributes.** Read once, while the element connects, to decide what chrome exists at
279
+ all — the tray, the mic, the skills menu, the header mark. Writing one afterwards has **no effect**;
280
+ the element logs a console warning naming the attribute rather than failing silently, because the
281
+ symptom is an affordance that never appears and that reads as a broken component. Set them before
282
+ the element enters the DOM, or remove and re-insert it. See
283
+ [Framework hosts](#framework-hosts-configure-before-you-insert), where the boundary bites hardest.
284
+ The list: `data-attachments-url`, `data-attachment-accept`, `data-attachment-max-bytes`,
285
+ `data-transcribe-url`, `data-threads-url`, `data-threads-cache`, `data-tools-url`,
286
+ `data-skills-url`, `data-skills`, `data-prompt-chips`, `data-slash-commands`, `data-theme-toggle`,
287
+ `data-strings`, `data-icon-url`.
288
+
289
+ What re-inserting costs: the chrome is built again from the attributes as they stand, a run in
290
+ progress is cancelled, and the conversation is redrawn from its stored history — the same rebuild
291
+ `reload()` performs, and the reason that method exists for a host that can only configure the
292
+ element after the fact.
293
+
294
+ **Properties** (JS only, not attributes): `headers`, `getHeaders`, `trustedOrigins`, `allowImages`,
295
+ `autoConfirm`, `confirmPredicate`, `askUser`, `askUserRenderer`, `approvalRenderer`,
296
+ `approveWithEdits`, `agentFactory`, `getTools`, `getContext`, `routeMap`, `navigate`, `getPageMap`,
297
+ `autoInjectPageMap`, `getPlace`, `conversationStore`, `uploadHandler`, `transcribeHandler`, `navigationResult`,
298
+ `skillContext`, `toolSummaries`, `formatToolPayload`, `formatRelativeTime`, `strings`,
299
+ `resolvePageTarget`, `sharedState`, `anchor` (an element, which wins over the attribute's selector), `extraStyles` ([unstable](#d-extrastyles-the-last-mile)), plus the read-only `unread`, `unhandledActivityTypes`, `threadId`, `threadTitle`, `running`, `barHeight` and `dockWidth`, and
300
+ the attribute mirrors `endpoint` / `userKey` / `userName` / `toolDisplay` / `toolGroupDisplay` / `collapsed` /
301
+ `maximized` / `credentials`.
302
+
303
+ `headers` and `getHeaders` authenticate **every** request the element makes, not only the agent
304
+ run; `getHeaders` is the one to use for a credential that rotates. See
305
+ [Authenticating requests](#authenticating-requests).
306
+
307
+ `allowImages` (default `false`) re-enables `<img>` in rendered assistant markdown.
308
+ It is off by default because a model-controlled image URL is fetched by the browser
309
+ with no user interaction — a zero-click exfiltration channel for prompt-injected
310
+ page data. Enable only when the content source is trusted.
311
+
312
+ `toolSummaries` is a `Record<string, string>` mapping tool name → a friendly card
313
+ label, used when a tool has no `x-summary` in its own schema. Built-in and client tools
314
+ should carry `x-summary` directly; this map is the seam for **server-side tools** (drf-mcp,
315
+ the django-ag-ui `@tool` registry), whose schema never reaches the browser — e.g.
316
+ `chat.toolSummaries = { list_projects: "Search projects" }`. Or point
317
+ `data-tools-url` at a server catalog endpoint (django-ag-ui's `tools/`) and the
318
+ labels are fetched automatically — per card, `x-summary` → an explicit
319
+ `toolSummaries` entry → the fetched catalog → the raw name.
320
+
321
+ `sharedState` is AG-UI shared state, documented under
322
+ [the run loop](#the-run-loop-and-the-ag-ui-client); `unread` and `unhandledActivityTypes` are
323
+ read-only counters, covered under [the unread badge](#the-unread-badge) and
324
+ [finding out what arrived](#finding-out-what-arrived); `threadId` and `running` are covered under
325
+ [driving the conversation from your own chrome](#driving-the-conversation-from-your-own-chrome).
326
+
327
+ Code blocks in an agent's answer carry a **copy button**, revealed on hover or
328
+ keyboard focus and styleable via the `code-copy` part. Override its labels with
329
+ the `copyCode` / `copied` / `copyFailed` strings.
330
+
331
+ **Methods**: `registerTool`, `registerPageState`, `registerActivityRenderer`, `setSkills`,
332
+ `sendMessage`, `attachFile`, `appendMessage`, `retryLastTurn`, `quote`, `offerQuoteInPage`,
333
+ `enableCharts`, `newChat`, `setCollapsed`, `toggleCollapsed`, `setMaximized`, `toggleMaximized`,
334
+ `describeSurface`, `moveTo`, `resetSize`, `toggleTheme`, `openThreads`, `closeThreads`, `switchThread`, `deleteThread`,
335
+ `openCheckpoints`, `closeCheckpoints`, `toggleCheckpoints`, `refreshPlace` and `reload`.
336
+
337
+ ### Sending from your own UI
338
+
339
+ `sendMessage(content, attachments?)` sends as if the user had typed it — user bubble,
340
+ `ag-ui-submit` event, run started. `ag-ui-submit` means a turn was sent, not that a run started:
341
+ a Retry and a run continued from the checkpoint panel start runs without one. To know whether
342
+ anything is running, read [`running`](#driving-the-conversation-from-your-own-chrome) or style on
343
+ `data-running`. Use it for an "Ask about this order" button, a command
344
+ palette, a prompt fired when your page mounts the chat, or a composer of your own replacing the
345
+ built-in one.
346
+
347
+ It returns a `Promise<boolean>`. It resolves `true` once the turn has been handed to the agent
348
+ and its run has settled, frontend-tool rounds included, and `false` when nothing was sent:
349
+
350
+ - for an entirely empty message;
351
+ - while a run, or a checkpoint continuation picked from the panel, is in flight. A continuation
352
+ counts from the pick, and a send from the call rather than from when its run starts, so a
353
+ `sendMessage` from an `ag-ui-submit` listener, or a second call in the same task, is refused
354
+ rather than starting a second run;
355
+ - with no `endpoint` set, which the transcript also says;
356
+ - while a reload resumes a run a navigating tool interrupted. The resume counts from the moment
357
+ the element asks its conversation store for the conversation, and it ends by starting that
358
+ run, so a send then is refused, and the built-in Send queues behind the resumed answer;
359
+ - when your own code that runs on the way to the request — an `ag-ui-submit` listener, the
360
+ conversation store's save — stops it by starting a new chat or changing `user-key`. A
361
+ `getContext` or `getTools` that does the same stops it too, but after the turn was handed
362
+ over, so the call resolves `true`.
363
+
364
+ **While the element restores a stored conversation, a send waits for it.** Every restore —
365
+ when the element connects, on `reload()`, when a thread is picked from the drawer, and when
366
+ `user-key` changes from one principal to another — first asks the conversation store for the conversation, and with
367
+ `data-threads-url` that is a request to your server. A `sendMessage` made in that time waits
368
+ until the conversation is drawn, then sends with it: the bubble, the `ag-ui-submit` event and the
369
+ request all come after the replay, later than the call by the length of the load. So a prompt
370
+ fired on mount needs no timing of its own. If New chat, a thread switch, a change from one
371
+ `user-key` to another or a `reload()` comes while it waits, the conversation it was addressed
372
+ to is gone, so it is dropped and resolves `false` at once. The element leaving the page drops it
373
+ too, but it resolves `false` only once the load answers or the element is inserted again. A
374
+ first `user-key` on an anonymous conversation adopts that conversation rather than replacing
375
+ it, so a send waiting then still goes out. A second `sendMessage` while one waits is refused. The built-in Send queues what is typed during the load ("Waiting to send"),
376
+ and sends it after the conversation is drawn, behind a `sendMessage` that was waiting.
377
+
378
+ Unlike the built-in Send it does **not** queue, so your composer keeps what it tried to send, and
379
+ it does **not** consult the attachment tray: what you pass is what is sent, so your composer stays
380
+ in charge of its own state.
381
+
382
+ `retryLastTurn()` keeps the same rules, because it starts a run on the same conversation. It
383
+ returns `false` while a run, a send, another Retry or a continuation is in flight, and while
384
+ any restore of the stored conversation is still loading. It does not wait for the load as a send
385
+ does: until the conversation is drawn there is nothing on screen to retry, and afterwards it
386
+ would ask the previous visit's last question again. It counts from the call as a send does: until its run starts, a `sendMessage` or a
387
+ second Retry is refused, the built-in Send queues, and a checkpoint pick is refused. It also
388
+ returns `false` when your own code stops it before its run starts: a conversation store, or an
389
+ activity or tool renderer it redraws through, that starts a new chat or changes `user-key`. A
390
+ `getContext` or `getTools` that does the same stops it too, before anything is sent, but it runs
391
+ after the Retry has handed its turn to the client, so the call resolves `true`. A store that
392
+ throws from the Retry's save leaves the conversation as it was, and `retryLastTurn()` rejects
393
+ with its error.
394
+
395
+ `attachFile(file)` queues a file into the tray exactly as the picker and drag-and-drop do, with
396
+ the same validation and progress chip. It returns `false` when uploads are not configured
397
+ (no `data-attachments-url` and no `uploadHandler`) — the only way to tell, since with no tray
398
+ there is nothing to report through.
399
+
400
+ Uploading is asynchronous, so watch `ag-ui-attachments` for the result. Its `detail` carries
401
+ `{ attachments, pending }`: the durable refs of everything that has finished, and how many are
402
+ still in flight. Send once `pending` is `0`, or you will leave files behind.
403
+
404
+ ```js
405
+ chat.addEventListener("ag-ui-attachments", (e) => {
406
+ const { attachments, pending } = e.detail;
407
+ sendButton.disabled = pending > 0;
408
+ sendButton.onclick = () => chat.sendMessage(input.value, attachments);
409
+ });
410
+ chat.attachFile(fileInput.files[0]);
411
+ ```
412
+
413
+ A self-contained live playground lives in [`demo/`](demo/) — run `make demo` to serve it against a
414
+ mock AG-UI server.
415
+
416
+ ---
417
+
418
+ ## Authenticating requests
419
+
420
+ The element talks to more than one endpoint. Beyond the AG-UI run itself, it may fetch the thread
421
+ index and a thread's messages, the tool-label and skill catalogs, the run index, and it may POST an
422
+ upload or a voice clip. **Every one of them is authenticated the same way**, by the element rather
423
+ than by the agent — so configuring authentication on a custom `agentFactory` authenticates the run
424
+ and nothing else, and the history drawer comes back empty because its request was anonymous.
425
+
426
+ | Request | Endpoint | Transport |
427
+ | --- | --- | --- |
428
+ | The agent run | `endpoint` | `fetch` (SSE), via `agentFactory` |
429
+ | Thread index / a thread's messages / rename / delete | `data-threads-url` | `fetch` |
430
+ | Tool-label catalog | `data-tools-url` | `fetch` |
431
+ | Skill catalog | `data-skills-url` | `fetch` |
432
+ | Run index | `data-runs-url` | `fetch` |
433
+ | Voice transcription | `data-transcribe-url` | `fetch` |
434
+ | File upload | `data-attachments-url` | `XMLHttpRequest` (for progress events) |
435
+
436
+ ### `headers` and `getHeaders`
437
+
438
+ `headers` is a plain record sent with every request above:
439
+
440
+ ```js
441
+ chat.headers = { "X-CSRFToken": getCsrfToken() };
442
+ ```
443
+
444
+ It is read at request time, but only an assignment changes it — so a token captured there is pinned
445
+ until you remember to assign again. For anything that rotates (a short-lived JWT, a re-issued CSRF
446
+ token) set **`getHeaders`** instead, a function consulted immediately before every request:
447
+
448
+ ```js
449
+ chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
450
+ ```
451
+
452
+ Because it is called per request, a token refreshed between two requests reaches the second one —
453
+ including mid-conversation, on the cached agent's own stream.
454
+
455
+ The two **compose**: they are merged per key, `getHeaders` winning, so a fixed header and a rotating
456
+ one can be configured independently and neither silently drops the other.
457
+
458
+ ```js
459
+ chat.headers = { "X-Client": "admin" };
460
+ chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
461
+ // every request: X-Client: admin + a freshly-read Authorization
462
+ ```
463
+
464
+ ### Cross-origin cookies (`credentials`)
465
+
466
+ If your API is on a different origin from the page — `app.example.com` calling `api.example.com`
467
+ counts, subdomains are cross-origin — the browser's default of `same-origin` sends **no cookies at
468
+ all**. The requests still go out; they arrive unauthenticated, and the server answers `401` while
469
+ looking perfectly configured. Set the cookie policy explicitly:
470
+
471
+ ```html
472
+ <ag-ui-chat endpoint="https://api.example.com/agent/" credentials="include"></ag-ui-chat>
473
+ ```
474
+
475
+ ```js
476
+ chat.credentials = "include"; // mirrors the attribute
477
+ ```
478
+
479
+ It takes `fetch`'s own three modes — `omit`, `same-origin`, `include` — applies to every request in
480
+ the table above, and is read per request, so a late assignment applies to everything after it.
481
+ Anything else is rejected where you wrote it: an unknown value assigned as a property throws, and an
482
+ unknown value in the attribute is reported to the console and ignored, rather than becoming a `401`
483
+ later on.
484
+
485
+ The server has to agree: `Access-Control-Allow-Credentials: true` and a concrete
486
+ `Access-Control-Allow-Origin` (the wildcard is invalid with credentials).
487
+
488
+ One asymmetry: uploads use `XMLHttpRequest` for real progress events, and its cookie switch is
489
+ two-state. `include` turns it on; every other value leaves it off. `omit` therefore cannot suppress
490
+ cookies on a *same-origin* upload — supply your own `uploadHandler` if that matters.
491
+
492
+ ### Where those credentials are allowed to go
493
+
494
+ Every URL in the table above is a plain HTML attribute, and `headers` / `getHeaders` are attached to
495
+ whatever they name. That is what makes a cross-origin agent work — and it is also why a page must
496
+ never build one of those attributes out of a URL parameter, a CMS field, or anything else it did not
497
+ choose itself. Whoever supplies the value chooses where the token goes: the browser preflights the
498
+ custom header, any server willing to answer receives it, and it leaves on the element's first
499
+ request, before the user has typed anything.
500
+
501
+ Treat all seven as trusted configuration. When any of them resolves to another origin, the element
502
+ says so on the console once per origin, naming the destination and the header names it is about to
503
+ send. That covers all seven, not the agent endpoint alone: the tool catalog, the skills list, the
504
+ thread index, the attachment upload and the transcription endpoint carry the same headers, and
505
+ reporting only the agent would report the least interesting of them.
506
+
507
+ To confirm destinations you chose on purpose and silence the notice, name their origins:
508
+
509
+ ```js
510
+ chat.trustedOrigins = ["https://api.example.com"];
511
+ ```
512
+
513
+ That covers every endpoint the element requests itself, and is forwarded to `createHttpAgent`, so a
514
+ host that does not override `agentFactory` needs nothing else. A custom factory can also be given
515
+ the option directly:
516
+
517
+ ```js
518
+ chat.agentFactory = (options) =>
519
+ createHttpAgent({ ...options, trustedOrigins: ["https://api.example.com"] });
520
+ ```
521
+
522
+ Origins are compared as `URL.origin` produces them — scheme, host and port. A notice is a notice,
523
+ not a refusal: nothing is blocked, because a cross-origin agent is a supported deployment and
524
+ refusing would break working installations to defend against a page that is already interpolating
525
+ untrusted data into its own markup.
526
+
527
+ ### Framework hosts: configure before you insert
528
+
529
+ `headers`, `getHeaders` and `credentials` are read when a request is made, so they can be set at any
530
+ time. Several other things are read once, while the element **connects**: `strings`, `uploadHandler`
531
+ and `transcribeHandler` (they decide whether the attach and voice affordances exist at all) and
532
+ every chrome-building `data-*` attribute — and the catalogs and thread history are requested at, or
533
+ just after, that same moment.
534
+
535
+ React attaches `ref`s *after* it inserts the node, which puts the canonical integration on the wrong
536
+ side of that boundary. Create the element, configure it, then append:
537
+
538
+ ```jsx
539
+ function Assistant() {
540
+ const host = useRef(null);
541
+
542
+ useEffect(() => {
543
+ defineChat();
544
+ const chat = document.createElement("ag-ui-chat");
545
+
546
+ // Configure first - every one of these is read as the element connects.
547
+ chat.setAttribute("endpoint", "/agent/");
548
+ chat.setAttribute("data-threads-url", "/agent/threads/");
549
+ chat.credentials = "include";
550
+ chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
551
+ chat.registerTool(myTool);
552
+
553
+ // ...then insert it.
554
+ host.current.appendChild(chat);
555
+ return () => chat.remove();
556
+ }, []);
557
+
558
+ return <div ref={host} />;
559
+ }
560
+ ```
561
+
562
+ Writing it as `<ag-ui-chat ref={...} />` in JSX and configuring in the ref callback mostly works —
563
+ the catalog requests are held back one microtask precisely so a ref assigned in the same commit is
564
+ honoured — but the thread-history request is **not** deferred, so that one goes out with whatever
565
+ was configured at insertion. It starts at insertion because the restore is what a `sendMessage()`
566
+ from the same commit waits on: a restore that had not started yet would have nothing to hold it,
567
+ and the send would go out without the conversation.
568
+
569
+ If your credentials can only arrive later still — an awaited token, a passive effect — call
570
+ **`reload()`** once they land:
571
+
572
+ ```js
573
+ const token = await auth.login();
574
+ chat.getHeaders = () => ({ Authorization: `Bearer ${token}` });
575
+ await chat.reload();
576
+ ```
577
+
578
+ `reload()` re-runs everything the element loads on startup (tool catalog, skills, thread history)
579
+ with the configuration as it then stands. It is a reload, not a merge: the in-flight run is
580
+ cancelled and the transcript is rebuilt from the persisted history, so call it when configuration
581
+ lands rather than between turns.
582
+
583
+ #### The same boundary in four frameworks
584
+
585
+ Each framework reaches that pre-insertion window differently, and only one of them reaches it
586
+ declaratively. Built and driven in all four:
587
+
588
+ | Host | Pre-insertion window | What to do |
589
+ | --- | --- | --- |
590
+ | React | None — refs attach after insertion | `createElement`, configure, `appendChild` (above) |
591
+ | **Vue 3** | **Yes** — a directive's `beforeMount` | Attributes in the template, properties in the directive |
592
+ | Svelte 5 | None — `use:` actions and `$effect` run after insertion | Same as React |
593
+ | Angular | None — bindings apply during change detection | Same as React, in `ngOnInit` with `@ViewChild({ static: true })` |
594
+
595
+ **Vue** is the one host that can configure declaratively, because a custom directive's `beforeMount`
596
+ runs while the element is still detached:
597
+
598
+ ```vue
599
+ <script setup>
600
+ const vConfigure = {
601
+ beforeMount(element) {
602
+ element.getHeaders = () => ({ Authorization: `Bearer ${token()}` });
603
+ element.registerTool(myTool);
604
+ },
605
+ };
606
+ </script>
607
+
608
+ <template>
609
+ <ag-ui-chat v-configure endpoint="/agent/" data-threads-url="/agent/threads/" />
610
+ </template>
611
+ ```
612
+
613
+ Tell Vue's compiler the tag is a custom element, or it will warn and try to resolve a component:
614
+ `vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === "ag-ui-chat" } } })`.
615
+
616
+ **Svelte 5**'s `use:` action and `$effect` both run after the node is in the DOM, so build the
617
+ element by hand in an `$effect` and append it — the React shape, in runes. **Angular** needs
618
+ `CUSTOM_ELEMENTS_SCHEMA` on the component and, if it wraps the panel in its own component, one line
619
+ of CSS: `:host { display: contents }`. Angular's host element otherwise lands between your grid and
620
+ the children it sizes, and the panel renders a few hundred pixels tall in the middle of the page.
621
+
622
+ ---
623
+
624
+ ## Core concepts
625
+
626
+ ### The run loop and the AG-UI client
627
+
628
+ `<ag-ui-chat>` is the view; [`AgUiClient`](src/core/agui_client.ts) is the orchestration layer over
629
+ an AG-UI `AbstractAgent`. On the first send the element builds a client (via the overridable
630
+ `agentFactory`, which defaults to [`createHttpAgent`](src/core/create_http_agent.ts)). Each turn:
631
+
632
+ 1. The user message is appended and the agent runs once.
633
+ 2. AG-UI subscriber events are translated into the element's handlers — streaming text deltas
634
+ render into a bubble; each `TOOL_CALL_END` becomes a tool-call card.
635
+ 3. Any **frontend** tool calls collected during the run are executed locally, their results are
636
+ appended as `tool` messages, and the agent is re-run with the results.
637
+ 4. This repeats until the agent stops calling frontend tools, bounded by `MAX_TOOL_ROUNDS`
638
+ (10) — raise it with `data-max-tool-rounds`, or `AgUiClientConfig.maxToolRounds` when you
639
+ drive the client yourself. The default suits a chat whose tools answer questions; a
640
+ page-driving deployment reaches it legitimately, one round per field filled, and the symptom
641
+ is not an error but an answer that stops mid-task. A value below 1 is ignored rather than
642
+ honoured — it would be a send that never runs the agent at all.
643
+
644
+ Tool calls the client doesn't own (server-side tools the server already executed) are left alone —
645
+ the loop doesn't re-run them, but their streamed `TOOL_CALL_RESULT` is rendered into the tool-call
646
+ card (honouring `data-tool-display`), so server-side output is visible too. The current tool catalog
647
+ and context are read **fresh on every run** (`getTools()` / `getContext()`), so they always reflect
648
+ the current page state.
649
+
650
+ #### A tool call that failed
651
+
652
+ A `TOOL_CALL_RESULT` may say how the call ended, as an optional **`outcome`** key in the event's
653
+ `metadata` (`"metadata": { "outcome": "failed" }`). It takes pydantic-ai's own `ToolReturnPart`
654
+ vocabulary — read the values off `TOOL_OUTCOME`:
655
+
656
+ | `outcome` | Card | Means |
657
+ | --- | --- | --- |
658
+ | *(absent)* | done | A result, said the way every server has always said it. |
659
+ | `success` | done | The same thing, stated. |
660
+ | `failed` | error | The call ran and failed. |
661
+ | `denied` | declined | A person or a guard refused it, so it never ran. |
662
+ | `interrupted` | not finished | It produced no result because the run ended first, and nobody refused it. |
663
+
664
+ **Absent is a success, and anything unrecognised is too** — including a value from a later protocol
665
+ version. A card is a claim about what happened, and refusing to
666
+ recognise a word is not grounds for claiming failure. So no server has to change to keep the
667
+ rendering it has today, and a server that adds the field gets the truth on screen instead of a
668
+ green card with a refusal folded inside it.
669
+
670
+ It has to be in `metadata`. `@ag-ui/client` 1.0 strips every key its schemas do not declare from an
671
+ event before any subscriber sees it, with a `[ag-ui][enforce]` console warning, and `metadata` is
672
+ the one place on an event it declares open by key. An `outcome` at the top level of the event —
673
+ where a server wrote it for the 0.x client, and where a django-ag-ui older than 0.63.0 still does —
674
+ never arrives, so the card settles as done. The key arrives on `AgUiClientHandlers.onToolResult` as
675
+ an optional third argument, typed `unknown` because the protocol validates nothing inside
676
+ `metadata`; `toolStatusFromOutcome` does the narrowing.
677
+
678
+ A **frontend** tool never streams this event — the client posts its own result — so its outcome is
679
+ recorded on the `ToolExecution` the executor returns (`{ content, outcome: "denied" }`). That covers
680
+ the two refusals the component makes itself: a confirmation card the user declined, and a call
681
+ blocked because the page moved under the round.
682
+
683
+ Either way the outcome is **persisted on the tool message, in its `metadata`**, so a reload replays
684
+ the card as it settled rather than as a plain result. For a server's result `@ag-ui/client` puts it
685
+ there itself, folding the event's metadata onto the tool message it appends; for its own results the
686
+ client writes it in the same place. See
687
+ [MPA durability](#mpa-durability-surviving-full-page-reloads).
688
+
689
+ **Every request carries a result for every tool call in it.** Several model providers reject a
690
+ turn holding a tool call with no result, and a run can leave one open in more ways than one: Stop
691
+ while the stream is still arriving, a round that ends on `RUN_ERROR`, a call naming a tool nothing
692
+ here owns, a server that never streamed the result. So before each request the client answers
693
+ every call still open with the `callNotFinished` string (by default `Not finished: the run ended
694
+ or moved on before this tool call returned a result.`) and the outcome `interrupted`, placed at the
695
+ end of the round that made the call, and the card settles to **not finished** in the same words.
696
+ That result blames nobody, because nobody declined: `declinedAction` is kept for a call a person
697
+ was asked about and turned down — a confirmation card or an approval they declined, or one Stop
698
+ closed while it was open. The calls a resumed approval is answering are left alone, because the
699
+ resume is their answer.
700
+
701
+ The catalog a run advertises is also the set that run can execute. Override `getTools` to scope
702
+ what a page offers — say, exposing `delete_record` only where deleting makes sense — and a call
703
+ naming a tool you withheld is treated exactly as a call naming a tool you never registered: no
704
+ handler runs, the card settles as not finished, and the next request answers the call in those
705
+ words. Withholding is per run, so the
706
+ mount-wide registry can stay complete. Hosts that leave `getTools` alone advertise the built-ins
707
+ plus everything registered, which is precisely what dispatch could reach anyway.
708
+
709
+ ### Stopping a run
710
+
711
+ While a run is in flight the **Send button becomes Stop** (same button, label/`aria-label` swap,
712
+ `data-state="running"` for styling); clicking it — or pressing **Escape** in the composer (when
713
+ the skills palette is closed; the palette owns Escape while open) — calls `AgUiClient.cancel()`.
714
+ AG-UI has no server-side cancel route: cancelling **aborts the streaming request**
715
+ (`abortRun()`), and the server observes the disconnect. On cancel:
716
+
717
+ - Partial assistant text already streamed **stays in the transcript** and is persisted via
718
+ `onPersist`, so a reload shows the truncated exchange. A muted **"⏹ Stopped"** note is appended
719
+ (`.stopped-note`) — a deliberate stop is not an error, so no bubble.
720
+ - The run loop stops: tool calls collected before the abort are **not executed**, and no further
721
+ round starts. A frontend tool handler already running completes and its result is kept, but it
722
+ doesn't trigger a re-run, and the calls after it in the same round do not start. Each call that
723
+ did not run settles as **not finished**, and the next request answers
724
+ it in those words rather than sending it without a result.
725
+ - An **open confirmation card is declined** (`data-resolved="declined"`) — cancelling the run
726
+ answers the pending question. Likewise an open **approval card** is denied, and the next request
727
+ carries that decline as the call's result; a call already approved when Stop lands was declined
728
+ by nobody, so it is answered as not finished instead. An open **question card** (`ask_user`)
729
+ resolves with an empty answer. Reloading the page while a card is open does not decline it,
730
+ because nobody answered: the call comes back not finished; see
731
+ [MPA durability](#mpa-durability-surviving-full-page-reloads).
732
+ - The new `onCancelled()` handler fires instead of `onError()`; `onSettled()` still follows
733
+ (the terminal-rest guarantee), returning the button to **Send**.
734
+
735
+ `cancel()` with no run in flight is a safe no-op. `newChat()` cancels any in-flight run before
736
+ discarding the client, and so do switching conversations, `reload()` and removing the element. A
737
+ [checkpoint continuation](#resuming-a-run) is the run in flight while it lasts, so Stop and all of
738
+ these end it too. So does a turn that has not made its request yet: called from host code a send
739
+ or a resume runs on the way — the conversation store's save, `getContext`, `getTools` —
740
+ `cancel()` stops it before anything is sent, and `onCancelled()` and `onSettled()` fire as for any
741
+ other Stop.
742
+
743
+ A cancelled run ends a moment later, once its request has closed or a running tool handler has
744
+ returned. What it does then stays with the conversation it belonged to: its truncated exchange is
745
+ saved to that thread, `ag-ui-run-finished` still reports the tools it ran, and nothing reaches the
746
+ transcript or the Stop button of the conversation that replaced it.
747
+
748
+ ### Registering tools
749
+
750
+ A tool is a `ClientTool`: `{ name, description, parameters, handler }`, where `parameters` is a
751
+ **JSON Schema** and `handler` receives the parsed args and returns a value that is JSON-serialised
752
+ into the tool-result message. Register them on the element:
753
+
754
+ ```js
755
+ chat.registerTool({
756
+ name: "search_products",
757
+ description: "Search the catalog.",
758
+ parameters: {
759
+ type: "object",
760
+ properties: { query: { type: "string" } },
761
+ required: ["query"],
762
+ },
763
+ handler: async ({ query }) => await api.search(query),
764
+ });
765
+ ```
766
+
767
+ Registering a name twice replaces the earlier handler rather than throwing, so a re-fired
768
+ host ref or React StrictMode's double-invoke is harmless -- but two different tools sharing
769
+ a name means the second silently wins. Each `<ag-ui-chat>` element owns its own
770
+ registry, AG-UI client, and Shadow DOM, so **multiple instances on one page never interfere**.
771
+ The one record they share is which storage namespace each has claimed, and it exists to keep two
772
+ of them out of each other's conversation.
773
+
774
+ **A handler's thrown message leaves the browser.** If a handler rejects, its `Error.message` is
775
+ posted back as that call's tool result: into the conversation, on to the AG-UI endpoint, persisted
776
+ there, and replayed to the model provider on every later round. That is deliberate — a real reason
777
+ is what lets the agent recover — but it means an internal hostname, a signed URL or a
778
+ stack-derived path in a rethrown error is disclosed to parties you never chose. Throw the message
779
+ you would be content for the model to read, and log the detail instead.
780
+
781
+ ### Inline confirmation (`x-destructive` / `x-confirm` / `confirmPredicate`)
782
+
783
+ When a tool call needs confirmation, the element appends an **inline confirmation card** (a
784
+ `<div class="confirm">`) to the transcript via
785
+ [`requestConfirmation`](src/ui/confirmation_card.ts) — it is not a modal overlay. The card reads
786
+ naturally after the assistant's explanation, never steals focus from the page, and stays in the
787
+ transcript as a resolved record after the decision:
788
+
789
+ - **Confirm** → the handler runs and the result is posted back.
790
+ - **Cancel** → a `"User declined the action."` result is posted; the agent acknowledges on its
791
+ next turn.
792
+ - **Always allow** → the handler runs *and* this tool stops prompting for the rest of the
793
+ session. See below for when this button appears.
794
+
795
+ Whether a call is gated is decided in this order:
796
+
797
+ 1. If `chat.autoConfirm === true`, the call **never** prompts (an "autopilot" toggle).
798
+ 2. Else if `chat.confirmPredicate` is set, its boolean return is authoritative — given the tool
799
+ name + parsed args it decides per-call (so one tool can be instant for some args and confirmed
800
+ for others, which a static flag can't express). **A predicate that throws or rejects refuses
801
+ the call**: no card, the handler does not run, the tool card settles as declined, and the agent
802
+ gets the `confirmCheckFailed` string as the result and carries on, as after a decline. The
803
+ error goes to the console, never to the endpoint. A predicate still pending when the run is
804
+ stopped decides nothing: the call does not run, and its card settles as not finished.
805
+ 3. Else if the user has waived this tool name for the session, the call runs.
806
+ 4. Else the element falls back to [`isDestructive(parameters)`](src/tools/is_destructive.ts),
807
+ which reads the `x-destructive` JSON-Schema flag.
808
+
809
+ #### "Always allow", and why only sometimes
810
+
811
+ A prompt that is approved nearly every time is not a decision, it is a speed bump — and the
812
+ reflex it trains is what makes the rare refusal easy to miss. Anthropic published that users
813
+ approve **~93%** of Claude Code permission prompts manually and called interactive confirmation
814
+ *"behaviorally unreliable as a sole safety mechanism"* on that basis. The waiver exists so the
815
+ prompts that remain still mean something.
816
+
817
+ **The button is offered only on cards raised by step 4** — the `x-destructive` default. Where
818
+ `confirmPredicate` is what gated the call, there is no button, because that predicate is
819
+ documented as authoritative and letting one click retire it would silently defeat a host policy.
820
+ The offer and the allowlist sit on the same path, so there is no dead button either.
821
+
822
+ The waiver is **per tool name and per element**, held in memory and never persisted. A session
823
+ decision that outlived the tab would be a permanent grant made by one click — which is what
824
+ `autoConfirm` already exists to say deliberately. It is cleared when the element goes away, and
825
+ when [`user-key`](#who-the-stored-conversation-belongs-to-user-key) changes hands: one person's
826
+ *Always allow* is not the next person's, and a sign-out in the same tab does not remount anything.
827
+ The first key to arrive keeps it, for the same reason it keeps the conversation.
828
+
829
+ AG-UI has no built-in risk flag, so destructiveness is carried as a JSON-Schema extension at the
830
+ **schema root**: `parameters["x-destructive"] = true` (use the exported `X_DESTRUCTIVE_KEY`
831
+ constant). There is no parallel metadata channel and no name heuristic — destructiveness is exactly
832
+ the `x-destructive` flag (or `confirmPredicate`). The registry forwards the flag verbatim to
833
+ `RunAgentInput.tools`.
834
+
835
+ If the schema carries an `x-confirm` string (use `X_CONFIRM_KEY`), the card shows it as the prompt;
836
+ otherwise it falls back to a generic `Run "<tool>"?`.
837
+
838
+ **This gate covers frontend tools only.** A server-side tool's schema never reaches the browser —
839
+ tool definitions travel client-to-server on `RunAgentInput.tools`, and the only channel coming back
840
+ is the label catalog (`data-tools-url`), which carries `{ name, summary, description? }` and no
841
+ flags. So marking a server tool destructive does not produce a card here; gate it server-side
842
+ instead (see [Server-side tool approval](#server-side-tool-approval-interrupts)), which surfaces as
843
+ an approval card in the same transcript.
844
+
845
+ ```js
846
+ // Per-call: confirm a delete only when it would remove more than one row.
847
+ chat.confirmPredicate = (name, args) =>
848
+ name === "delete_rows" && Array.isArray(args.ids) && args.ids.length > 1;
849
+
850
+ // Prompt text via x-confirm:
851
+ chat.registerTool({
852
+ name: "activate_project",
853
+ description: "Activate the current project.",
854
+ parameters: { type: "object", properties: {}, [X_DESTRUCTIVE_KEY]: true, [X_CONFIRM_KEY]: "Activate this project?" },
855
+ handler: async () => await api.activate(),
856
+ });
857
+ ```
858
+
859
+ The confirmation card gates **client-registered** tools *before* they run. A **server-side**
860
+ tool runs on the server, so the browser can't intercept it the same way — that is what the
861
+ approval card below is for.
862
+
863
+ ### Server-side tool approval (interrupts)
864
+
865
+ When the server gates a destructive tool (e.g. django-ag-ui's `ToolGuard`), the tool **defers**
866
+ instead of executing and the run finishes on an AG-UI *interrupt*. The element then renders an
867
+ **inline approval card** (a `<div class="approval">`) via
868
+ [`requestApproval`](src/ui/approval_card.ts) **inside the tool card of the call it gates**:
869
+
870
+ - **Approve** → the run resumes and the server runs the tool; its result streams back into the
871
+ same card.
872
+ - **Deny** → the run resumes carrying a `cancelled` answer, so the model learns the tool was
873
+ declined; the card settles as declined.
874
+
875
+ This uses the AG-UI protocol's own interrupt/resume mechanism (`RunAgentInput.resume[]`) — the
876
+ wire stays vanilla AG-UI. A **Stop** while an approval card is open denies every open card and
877
+ cancels the run. No configuration is needed on the client; the gate is enabled server-side.
878
+
879
+ **A run can defer several calls, and each is answered on its own.** Importing three rows defers
880
+ three `create_event` calls, and the wire takes a different answer for each. So every question is
881
+ asked **at once**, each in its own tool card, above that call's own arguments — which is what says
882
+ *which* call it is about. The prompt cannot: it comes from the tool, so all three read "Add this
883
+ event to the board?". While the run waits, those cards read **`waiting for you`**
884
+ (`data-status="deferred"`), not "running…" — nothing is running, the stream is over and the server
885
+ is idle. A card asking a question shows its arguments in every `data-tool-display` mode, since
886
+ hiding them would hide the answer to "which one is this".
887
+
888
+ A **frontend** tool such as `ask_user` stays at "running…" while its card is open, because the
889
+ browser really is running it. Only a deferred call was claiming something untrue.
890
+
891
+ **What the card asks.** An AG-UI interrupt carries the question as `message`, and the default is the
892
+ call spelled out — `Approve create_event({"title": "Design sync", …})?` — which is accurate and not
893
+ something to put in front of a person. A server can supply its own wording as **`x-confirm` in the
894
+ interrupt's `metadata`**, the same key a client-side confirmation reads off the tool's schema, and
895
+ the card prefers it:
896
+
897
+ ```json
898
+ { "id": "int-1", "reason": "tool_call", "toolCallId": "call-1",
899
+ "message": "Approve create_event({\"title\": \"Design sync\"})?",
900
+ "metadata": { "x-confirm": "Book Design sync on Friday at 14:00?" } }
901
+ ```
902
+
903
+ Anything non-string or blank under that key is ignored in favour of `message`, and with neither the
904
+ card falls back to `strings.approvalPrompt`.
905
+
906
+ **The card approves or denies, and nothing else.** The interrupt's `responseSchema` also advertises
907
+ `editedArgs` and `reason` — the protocol allows a client to rewrite a gated call's arguments before
908
+ letting it run. The built-in card does not offer that; a host that wants it can implement
909
+ `approvalRenderer` and resolve the interrupt itself.
910
+
911
+ **A gated write is still a write the page cannot see.** Approving one runs a *server-side* tool, so
912
+ if your page renders the data it touched, listen for
913
+ [`ag-ui-run-finished`](#host-seams-the-spa-story) and refetch.
914
+
915
+ Like the question card, the approval card is customizable at three levels: **text** (`strings`:
916
+ `approveAction` / `approvalPrompt` / `approve` / `deny` / `toolDeferred`), **CSS** (`::part()`:
917
+ `approval`, `approval-body`, `approval-actions`, `approval-button`, `approval-approve`,
918
+ `approval-deny`, and `tool-card-approval` for the region inside the card), and **full replacement**
919
+ via `chat.approvalRenderer` — given the request (`message` + `toolName`) and a Stop `AbortSignal`,
920
+ render your own UI and resolve `true`/`false`. A renderer is called **once per interrupt,
921
+ concurrently**, so a host that can only ask one thing at a time should queue inside it:
922
+
923
+ ```js
924
+ chat.approvalRenderer = (request, { signal }) =>
925
+ myConfirmDialog(request.message ?? `Run ${request.toolName}?`, { signal });
926
+ ```
927
+
928
+ **A renderer that throws or rejects falls back to the built-in card** for that interrupt, with a
929
+ `console.warn` naming it. The renderer decides how the question looks, not whether it is asked, so
930
+ nothing runs without a click and the run carries on as if no renderer were set. The one exception
931
+ is a rejection after the signal fired, which is how a renderer is expected to honour Stop: that
932
+ wait resolves as denied, as the built-in card does on the same signal, with no card and no warning.
933
+
934
+ ### Asking the user a question (`ask_user`)
935
+
936
+ Set `chat.askUser = true` to offer the agent a built-in `ask_user` frontend tool. When the agent
937
+ calls it, the element renders an **inline question card** (a `<div class="question">`) via
938
+ [`requestQuestion`](src/ui/question_card.ts) and returns the user's answer as the tool result:
939
+
940
+ ```js
941
+ chat.askUser = true; // opt in; off by default so the tool catalog is unchanged otherwise
942
+ ```
943
+
944
+ `ask_user(question, options?, allow_custom?)` renders `options` as radio buttons, adds a free-text
945
+ field when `allow_custom` is set (or when no options are given), and feeds the chosen or typed
946
+ answer back through the normal frontend-tool path — no new protocol. A **Stop** dismisses an open
947
+ question with an empty answer.
948
+
949
+ The question card is **fully customizable** at three levels:
950
+
951
+ - **Text** — every label is a `strings` key: `askUserAction` (the card's `aria-label`),
952
+ `otherOption`, `answerPlaceholder`, `submit`.
953
+ - **CSS** — every element exposes a `::part()`: `question`, `question-body`, `question-options`,
954
+ `question-choice`, `question-radio`, `question-input`, `question-actions`, `question-button`
955
+ (plus the `--ag-ui-*` theme variables). No shadow piercing.
956
+ - **Full replacement** — set `chat.askUserRenderer` to own the entire UI. Given the parsed request
957
+ and an `AbortSignal` (fired on Stop), render anything — a native modal, a framework component —
958
+ and resolve with the answer (empty string = no answer). The built-in card is bypassed entirely.
959
+
960
+ ```js
961
+ // Level 1+2: restyle the built-in card.
962
+ chat.strings = { submit: "Answer", answerPlaceholder: "Type here…" };
963
+ // ag-ui-chat::part(question) { border-radius: 0; }
964
+
965
+ // Level 3: replace the card with your own UI.
966
+ chat.askUserRenderer = (request, { signal }) =>
967
+ myModal.ask(request.question, request.options, { allowCustom: request.allowCustom, signal });
968
+ ```
969
+
970
+ **A renderer that throws or rejects falls back to the built-in card** for that call, with a
971
+ `console.warn` naming the tool call. As with `approvalRenderer`, the renderer decides how the
972
+ question looks, not whether it is asked, so the run carries on as if no renderer were set. The one
973
+ exception is a rejection after the signal fired, which is how a renderer is expected to honour Stop:
974
+ that wait resolves with an empty answer, as the built-in card does on the same signal, with no card
975
+ and no warning.
976
+
977
+ ### DOM-driver and animation primitives
978
+
979
+ So the agent can visibly drive the host page, the package ships generic, framework-free
980
+ primitives. The **animation** primitives ([`animations.ts`](src/dom/animations.ts)) operate at
981
+ human-readable speed (configurable; pass small/zero durations in tests):
982
+
983
+ - `typeInto(el, value, { charDelayMs })` — clears and types a value character by character,
984
+ firing `input`/`change` events as a real user would.
985
+ - `highlightThenClick(el, { highlightMs })` / `pressThenClick(el, options)` — outline/press an
986
+ element, pause, then click.
987
+ - `selectOption(el, value)` / `toggleControl(el, checked)` — animate a `<select>` / checkbox.
988
+ - `scrollIntoCenterView(el, { settleMs })` — scrolls the element to the vertical centre and
989
+ resolves once the scroll has **settled**, so the ring that follows is drawn where the user is
990
+ looking rather than mid-glide. Awaiting is optional; the scroll is requested synchronously
991
+ either way. Settlement is `scrollend` where the browser has it, a short probe when nothing
992
+ actually moved (the element was already in view), and a 600 ms cap otherwise.
993
+ - `flash(el, { flashMs, color })` / `focusWithFlash(el, { flashMs, color, focus })` — ring the
994
+ element so the user can find it. The ring is an `outline`, not a `box-shadow`, because a shadow
995
+ paints outside the border box and any `overflow: hidden` ancestor sharing the element's box (a
996
+ card, a table cell) clips it away entirely. It holds for **1200 ms** by default and fades out
997
+ over the last third — a 200 ms blink is not long enough to be *found* by someone who does not
998
+ yet know where to look. The colour is `color` when passed, else — see
999
+ [Ring colour](#ring-colour) — the widget's own accent.
1000
+ `flash` leaves focus alone; `focusWithFlash` moves it — see "Flash versus focus" below.
1001
+ - `prefersReducedMotion()` — honoured by every primitive that *moves* something: the hold delays
1002
+ in `pressThenClick` / `selectOption` / `toggleControl` collapse to instant, `scrollIntoCenterView`
1003
+ jumps instead of gliding and settles immediately, and the flash drops its fade while still
1004
+ holding the ring for its full duration. Reduced motion asks for no animation, not for no
1005
+ feedback. `typeInto` and `highlightThenClick` are the exceptions: they keep their
1006
+ explicit-duration contract, so pass `charDelayMs: 0` / `highlightMs: 0` yourself if you want
1007
+ them instant.
1008
+
1009
+ The **DOM-driver** primitives ([`dom_driver.ts`](src/dom/dom_driver.ts)) compose those into the
1010
+ operations a tool handler typically wants:
1011
+
1012
+ - `fillField(el, value, options)` — scroll to, focus-flash, and type into a text field. The
1013
+ flash defaults to `flashMs: 0` here: the field is about to be typed into, which is its own
1014
+ highlight. Pass `flashMs` (and optionally `color`) to ring it first.
1015
+ - `clickElement(el, options)` / `pressButton(el, options)` — scroll to, highlight/press, and click.
1016
+ - `showHighlightOverlay(el, options)` — ring an element from an **overlay drawn outside it**, and
1017
+ optionally dim everything else (`scrim`) or flow a gradient round it (`gradient`). Returns a
1018
+ function that removes it. `flash` and `focusWithFlash` take `scrim` and `gradient` too and route
1019
+ through this when either is asked for.
1020
+
1021
+ **Why a second mechanism.** The plain ring is an `outline` on the element, which is deliberate:
1022
+ a `box-shadow` paints outside the border box, so an `overflow: hidden` ancestor sharing the
1023
+ target's box clips the whole ring away while the helper still reports success. But an outline
1024
+ takes a *colour* — there is no `outline-image` — so a gradient cannot be one, and anything else
1025
+ that can be is a property of the target and lands back inside whatever is clipping it. Dimming
1026
+ everything else needs a surface larger than the target, which is the same problem from the other
1027
+ side. So they are one overlay rather than two features.
1028
+
1029
+ **It is inert.** The overlay never takes a pointer event, at the cut-out or anywhere else — a dim
1030
+ that swallows clicks is a modal the user did not open, and `highlightThenClick` has to reach the
1031
+ control it just finished pointing at. It follows the target on scroll and resize, and under
1032
+ reduced motion the gradient is drawn but does not travel.
1033
+
1034
+ **Themed from the element you point at, not from the widget.** The overlay is appended to the
1035
+ document body so it can escape the clipping it exists to avoid, which means a `var()` in its own
1036
+ style would resolve against the body — so every token is read from the *target's* computed style
1037
+ instead. Set them wherever they inherit to the elements the agent touches, usually `:root`. The
1038
+ ring colour is the one exception, read from the widget as well; see
1039
+ [Ring colour](#ring-colour).
1040
+
1041
+ ```css
1042
+ :root {
1043
+ --ag-ui-highlight-ring-color: #0f766e; /* also read from ag-ui-chat; see Ring colour */
1044
+ --ag-ui-highlight-scrim: rgba(15, 15, 25, 0.45);
1045
+ --ag-ui-highlight-gradient: linear-gradient(115deg, transparent 20%, #4f46e5 50%, transparent 80%);
1046
+ --ag-ui-highlight-ring-width: 3; /* unitless; px */
1047
+ --ag-ui-highlight-flow-ms: 2400; /* one pass of the gradient */
1048
+ --ag-ui-highlight-z-index: 2147483001;
1049
+ }
1050
+ ```
1051
+
1052
+ `ringWidth` and `flowMs` options override the tokens per call; `color`, `padding` and `radius`
1053
+ have no token because they are per-target rather than per-theme. The overlay's own styles are
1054
+ inline and it lives in the light DOM, so neither a stylesheet rule nor `::part` can reach it —
1055
+ these tokens and options are the whole surface, which is why they cover every value it draws.
1056
+ - `selectControl(el, value)` / `toggleCheckbox(el, checked)` — animate a `<select>` / checkbox.
1057
+ - `setControlValue(el, value)` — set a `<select>` or checkbox without animation, dispatching
1058
+ `input`/`change`.
1059
+
1060
+ Every driver primitive **awaits the scroll** before it animates. A smooth scroll is not awaitable
1061
+ on its own, so a highlight fired straight after `scrollIntoView` could be applied and removed
1062
+ while the element was still travelling — visible to nobody. Budget up to ~600 ms of settle time
1063
+ per action in a browser without `scrollend`; an element already in view costs ~100 ms.
1064
+
1065
+ **Flash versus focus.** `focusWithFlash` does what its name says: it moves keyboard focus. That is
1066
+ rarely what you want just to *point at* something — it takes focus off the composer, can fire blur
1067
+ validation on whatever the user was mid-edit in, and can close an open menu. Reach for `flash(el)`
1068
+ to highlight, and keep `focusWithFlash(el)` for the case where the agent is about to type. Either
1069
+ way you can be explicit with `focus`:
1070
+
1071
+ ```js
1072
+ await flash(el); // highlight, focus untouched
1073
+ await focusWithFlash(el); // highlight and take focus
1074
+ await focusWithFlash(el, { focus: false }); // same as flash(el)
1075
+ ```
1076
+
1077
+ `focusWithFlash` focuses with `preventScroll: true`, so it cannot fight a smooth scroll that is
1078
+ still in flight.
1079
+
1080
+ The native-setter helpers ([`native_setter.ts`](src/dom/native_setter.ts)) — `setNativeValue` /
1081
+ `setNativeChecked` — set a control through its native prototype setter so React-controlled inputs
1082
+ register the change.
1083
+
1084
+ Each takes an element the caller has already located; host packages wrap them with
1085
+ environment-aware lookups (e.g. "find `#id_<name>`, then `fillField`").
1086
+
1087
+ #### Ring colour
1088
+
1089
+ Every ring the driver draws — the flash outline, the overlay ring, the press and toggle halo — is
1090
+ drawn in the host's page, outside the widget, so a token set on `ag-ui-chat` never reaches it
1091
+ through the cascade. The driver reads the widget directly instead, so on a page that themes
1092
+ nothing the rings follow the widget's theme: Quiet's near-black, Soft's violet, the dark half in a
1093
+ dark scheme. When a call passes no `color`, the first of these that is set wins:
1094
+
1095
+ 1. `--ag-ui-highlight-ring-color` on the target (so `:root` reaches every ring);
1096
+ 2. `--ag-ui-highlight-ring-color` on the chat — on `ag-ui-chat` or anything above it;
1097
+ 3. the target's `--ag-ui-accent`, so a page that themes its accent keeps the rings it has;
1098
+ 4. the chat's resolved accent, its theme's default included, held to 3:1 against the page (below);
1099
+ 5. the package indigo, when no chat is on the page at all.
1100
+
1101
+ The chat's own accent follows the widget's scheme, not the page's, so a dark widget's near-white
1102
+ accent would all but vanish on a light page. When the ring falls to step 4 it is measured against
1103
+ **the surface behind the target**: the background of the target's parent, and of each element
1104
+ above it until one is opaque, composited over the page canvas (which follows the root's
1105
+ `color-scheme`). The target's own fill is not that surface, because the ring is drawn outside its
1106
+ box: a white field on a dark page is ringed on the dark page. Background images are not read. The
1107
+ ring takes the first of these that holds 3:1 there (WCAG 2.4.13): the accent as the chat resolves
1108
+ it, the other half of the chat's `light-dark()` pair, or the accent stepped toward black or white.
1109
+ Steps 1 to 3 are a host's own choice and are drawn as given, contrast or not.
1110
+
1111
+ ```css
1112
+ /* Rings in the brand colour, whatever the widget's accent. */
1113
+ ag-ui-chat { --ag-ui-highlight-ring-color: #0f766e; }
1114
+ ```
1115
+
1116
+ The halo of `pressThenClick` and `toggleControl` is the chosen colour at 40%. "The chat" is the
1117
+ most recently connected `<ag-ui-chat>`; on a page with two, pass the one acting as the `chat`
1118
+ option (`RingOwnerOptions`, which every ring-drawing primitive and `showHighlightOverlay` take).
1119
+
1120
+ ### Page-action tools
1121
+
1122
+ Two built-in client tools let the agent perform common page interactions without every host
1123
+ re-implementing them. They are **opt-in** via `data-page-actions` — a comma list of the tokens you
1124
+ want — so you control the agent's interaction surface:
1125
+
1126
+ ```html
1127
+ <ag-ui-chat endpoint="/agent/" data-page-actions="scroll,drag"></ag-ui-chat>
1128
+ ```
1129
+
1130
+ - **`scroll_to`** — scroll a target into view. `target` is `"top"`, `"bottom"`, or a CSS selector
1131
+ / page-map element id. Read-only (no confirmation). It centres the target **vertically** and
1132
+ brings it into view **horizontally** (`inline: "nearest"`), so on a two-axis surface a
1133
+ horizontal target lands at the near edge rather than in the middle. In view is the contract;
1134
+ "centred" is not, in that axis.
1135
+ - **`drag_and_drop`** — drag the `from` element onto the `to` element (selectors / page-map ids),
1136
+ firing the standard HTML5 drag sequence (`dragstart` → `dragenter`/`dragover`/`drop` → `dragend`)
1137
+ so the page's own drop handler reacts. Useful for reordering sortable lists.
1138
+ - **`chat`** — four tools that let the agent move the panel it is speaking from:
1139
+ `read_chat_surface`, `move_chat`, `minimise_chat`, `restore_chat`.
1140
+
1141
+ This is the one nobody else can offer. Every other assistant's chat is a surface of its own, so
1142
+ there is nothing for it to be in the way *of*; ours is mounted in the page the user is working
1143
+ in, which makes "let me move this aside so you can see the table" something the agent can act on
1144
+ rather than apologise for.
1145
+
1146
+ **They report what happened, not what was asked.** A panel that fills the screen has nowhere to
1147
+ move to, and a placement that places itself owns its position — so `move_chat` answers
1148
+ `moved: false` with the reason and what would work instead, rather than reporting success on a
1149
+ panel that did not budge. `read_chat_surface` is there so the agent can ask before it acts
1150
+ instead of learning through a failure; it is read-only.
1151
+
1152
+ None of them is stamped `x-destructive`. Moving a window destroys nothing, and a confirmation
1153
+ card in front of it would be worse than the move.
1154
+
1155
+ **Your drag surface must listen to drag events, and many "modern" ones do not.** `drag_and_drop`
1156
+ dispatches the native HTML5 sequence with one shared `DataTransfer`. A surface built on a
1157
+ pointer-event drag library — dnd-kit, most React DnD packages, the Angular CDK — listens to
1158
+ `pointerdown`/`pointermove` and **never sees any of it**: the agent's drag is a silent no-op that
1159
+ still reports success. Either use the native API or pick a library that listens to drag events.
1160
+ React's synthetic `onDrop` does receive the dispatched sequence, `DataTransfer` included.
1161
+
1162
+ **A page action reports that it fired, not that it worked.** `drag_and_drop` returns as soon as the
1163
+ sequence is dispatched; whether your drop handler's save succeeded is invisible to it, so a refused
1164
+ change still looks like a successful tool call. Two things follow. Have the page report its own
1165
+ refusals somewhere the agent can read them, and have the agent re-read the page before claiming
1166
+ anything. Where the outcome matters more than the gesture, call the operation as a **server tool**
1167
+ instead — it can return the real error.
1168
+
1169
+ **A page that saves asynchronously should say so.** A verification read straight after a drag can
1170
+ outrun the page's own save and conclude that nothing happened. Report a busy flag in your
1171
+ `getPageMap` (`{ saving: true }` while a write is in flight) and the agent can wait for a page that
1172
+ says it is busy. It cannot wait for one that does not.
1173
+
1174
+ Targets resolve through the overridable `resolvePageTarget` property — `(target) => HTMLElement |
1175
+ null`, defaulting to `document.querySelector`. A host with a page map overrides it to map its own
1176
+ element ids (the same way the DOM-driver primitives are wrapped with environment-aware lookups):
1177
+
1178
+ ```js
1179
+ chat.resolvePageTarget = (id) => myPageMap.elementFor(id);
1180
+ ```
1181
+
1182
+ **Destructiveness.** Page actions are *not* stamped `x-destructive` — a drag rearranges transient
1183
+ state, and the durable change happens at the page's explicit commit (a Save), which stays in the
1184
+ user's hands. If your page persists *on drop* (a kanban board firing a PATCH from the drop
1185
+ handler), gate `drag_and_drop` with [`confirmPredicate`](#inline-confirmation-x-destructive--x-confirm--confirmpredicate)
1186
+ — or don't enable it. A target that resolves to nothing returns a clean, model-readable tool error.
1187
+
1188
+ ---
1189
+
1190
+ ## New chat and collapse
1191
+
1192
+ The header carries built-in buttons for a new chat (a pencil on a square), maximize (two arrows pointing out) and
1193
+ collapse (a chevron down, or on a sidebar a panel closing into its edge; `data-collapse-icon`
1194
+ chooses it). The
1195
+ matching JS API:
1196
+
1197
+ - `newChat()` — clears the transcript, drops the in-memory run state, and mints a new thread id.
1198
+ The conversation it leaves is **kept**: it stays in the history drawer to return to, and on a
1199
+ server-backed store it stays on the server. Deleting one is the drawer row's own action. A chat
1200
+ nothing was ever sent in is the exception — it was never listed, so it is dropped rather than
1201
+ left behind. Focus moves to the composer, whichever control started the new chat, unless the
1202
+ widget is collapsed, and the page is not scrolled to it. A restore still loading or replaying
1203
+ the conversation being left stops there, so nothing of it is drawn into the new chat, and a
1204
+ run it was about to resume after a reload is not resumed: coming back to that conversation
1205
+ later answers the interrupted call as not finished. Called before the element connects (or while
1206
+ it is out of the document), it waits: `threadId` stays `null` and nothing fires until the
1207
+ element connects, which then starts the new chat, so the one `ag-ui-thread` event is a `new`.
1208
+ - `describeSurface()` — where the panel is and what can be done to it: placement, collapsed,
1209
+ `maximized` (read-only), what it collapses to (`collapseTo`), whether it can be moved, whether it fills the screen, its box and the viewport. `movable` folds
1210
+ the two reasons a move can fail into the one answer a caller needs.
1211
+ - `moveTo(corner)` — send the panel to `top-left` / `top-right` / `bottom-left` / `bottom-right`,
1212
+ returning whether it went. It takes the axes the same way a user drag does, so the launcher
1213
+ travels with it and switching placement hands them back. Returns `false` rather than pretending
1214
+ when the placement owns its position or the panel fills the screen. Under `collapse-to="bar"`
1215
+ or `"none"` it moves the panel within the room above the bar or the anchor.
1216
+ - `resetSize()` — drop the size the user dragged in the current placement, and its stored copy,
1217
+ so the placement's own size and your `--ag-ui-width` / `--ag-ui-height` apply again; sizes
1218
+ dragged in other placements are kept. For a host that enters a sized state of its own (an
1219
+ expanded mode) instead of the built-in [maximize](#maximizing-the-panel): a user's drag
1220
+ outranks host tokens, so call this first.
1221
+ Under `collapse-to="bar"` or `"none"` it also returns a panel the user dragged off its dock to
1222
+ the dock and forgets the stored spot; a corner panel under `auto` keeps its dragged position.
1223
+ - `setCollapsed(collapsed)` / `toggleCollapsed()` — collapse or expand the widget. The state is
1224
+ reflected as the boolean `collapsed` attribute/property and persisted per-tab in
1225
+ `sessionStorage`, so it survives a reload.
1226
+
1227
+ Each change emits an `ag-ui-toggle` event (the `TOGGLE_EVENT` constant) with
1228
+ `detail: { collapsed: boolean }` (typed `ToggleDetail`), so a host can mirror the state in its own
1229
+ chrome — or hide the built-in toggle and drive the `collapsed` attribute itself.
1230
+
1231
+ ```js
1232
+ chat.newChat();
1233
+ chat.toggleCollapsed();
1234
+ chat.addEventListener("ag-ui-toggle", (e) => console.log(e.detail.collapsed));
1235
+ ```
1236
+
1237
+ ### Maximizing the panel
1238
+
1239
+ The maximize control (two arrows pointing out, `maximize-button` part, `icon-maximize` slot) grows the open panel,
1240
+ and pressed again it restores it (the arrows point in). It is labelled "Maximize" and "Restore" (the `maximize`
1241
+ and `restore` strings), apart from the launcher's and the rail's "Expand", which opens the panel.
1242
+
1243
+ - **Where it is offered.** A corner panel (`floating`, `bottom-left`, the default) and a docked
1244
+ one (`collapse-to="bar"` or `"none"`) grow on both axes; `sidebar` and `side` grow in width
1245
+ only. `page`, `full` and `embedded` have no control, and neither does any viewport of 600px or
1246
+ less, `data-small-viewport="off"` included. The control is absent from the header there, not
1247
+ hidden, and the state is refused: a panel maximized when its placement switches to one of
1248
+ these, or when the window narrows to 600px, leaves it.
1249
+ - **What it grows to.** `--ag-ui-maximized-width` and `--ag-ui-maximized-height`, which default
1250
+ to the panel's cap (`--ag-ui-max-width` / `--ag-ui-max-height`, the room the host left less
1251
+ one gutter). A side column reads the width only.
1252
+ - **The drag.** Entering it takes the dragged size off without forgetting it, and the resize
1253
+ grips are hidden while it lasts. Restoring, and collapsing (which leaves the state), put the
1254
+ placement's own size back, or the size the user dragged in it.
1255
+ - **The API.** `chat.maximized` (reflected as the `maximized` attribute), `setMaximized(bool)`
1256
+ and `toggleMaximized()`. Each change emits `ag-ui-maximize` (the `MAXIMIZE_EVENT` constant)
1257
+ with `detail: { maximized }` (typed `MaximizeDetail`). It fires only while connected: a
1258
+ markup `maximized` is announced once at connect, and only if it holds (a panel that mounts
1259
+ collapsed announces nothing), and a change made while detached is announced once on reconnect,
1260
+ only if it differs from what listeners last heard. It is not stored: a reload opens at the
1261
+ placement's own size. `describeSurface()` reports it, and no agent tool changes it.
1262
+
1263
+ ```js
1264
+ chat.addEventListener("ag-ui-maximize", (e) => console.log(e.detail.maximized));
1265
+ ```
1266
+
1267
+ ```css
1268
+ ag-ui-chat { --ag-ui-maximized-width: 960px; --ag-ui-maximized-height: 85vh; }
1269
+ ```
1270
+
1271
+ `--ag-ui-min-page-width` keeps that much of the page beside a `sidebar` or `side` column: it caps
1272
+ the column's width at the viewport less it, whether the width was dragged, set by
1273
+ `--ag-ui-width`, or maximized. It is unset by default, which caps nothing.
1274
+
1275
+ ```css
1276
+ ag-ui-chat[placement="sidebar"] { --ag-ui-min-page-width: 480px; }
1277
+ ```
1278
+
1279
+ A host that wants its own sized state can still build it: hide the control
1280
+ (`ag-ui-chat::part(maximize-button) { display: none }`), slot a button into `header-actions`, set
1281
+ its own `--ag-ui-width` / `--ag-ui-height` in a class, and call `resetSize()` as it enters, so a
1282
+ dragged size does not outrank its tokens.
1283
+
1284
+ ### Driving the conversation from your own chrome
1285
+
1286
+ A host that lists conversations in its own bar, rather than in the built-in drawer, drives the
1287
+ one on screen with the drawer's own paths:
1288
+
1289
+ - `switchThread(threadId)` — put a stored thread on screen and replay it, as picking its row in
1290
+ the drawer does. Returns a `Promise<void>` that resolves once the thread is drawn. **A run in
1291
+ flight is cancelled**, as the drawer and `newChat()` cancel it; read `running` first if you
1292
+ would rather wait. Switching to the thread already on screen does nothing, and so does a call
1293
+ before the element connects; a second call for the thread that is still loading settles with
1294
+ that load, after the replay. If the store rejects the load, the element stays on the thread it
1295
+ was on, redraws it, fires no `ag-ui-thread`, and the promise rejects with the store's error.
1296
+ - `deleteThread(threadId)` — delete a stored thread, as the drawer row's delete does. Deleting
1297
+ the thread on screen cancels its run and puts a fresh thread in its place; deleting another
1298
+ leaves the screen alone. Prefer it to `conversationStore.clear()`, which deletes the record and
1299
+ nothing else: the run, the transcript and the drawer are left pointing at it.
1300
+ - `threadId` *(read-only)* — the thread on screen, `null` before the element first connects.
1301
+ It only reads. `conversationStore.threadId()` mints and stores a thread when the store points
1302
+ at none, so asking it which conversation is showing can start one.
1303
+ - `threadTitle` *(read-only)* — the title of the thread on screen, `""` while nothing has been
1304
+ sent in it. A title renamed here, or one the conversation list shows that the messages do not
1305
+ derive (a server's own), wins; otherwise it is the first user message, and a first message that
1306
+ is exactly a skill's `/token` reads as that skill's catalog `title`, else as the token in words
1307
+ (`/summarize_draw` reads "Summarize draw"). It is set on the first send, before the server lists
1308
+ the thread. Every change fires one **`ag-ui-thread-title`** event (`THREAD_TITLE_EVENT`, detail
1309
+ `{ threadId, title }`, typed `ThreadTitleDetail`), after the `ag-ui-thread` event of the change
1310
+ that caused it. `title-text="auto"` draws it without a listener.
1311
+ - `running` *(read-only)* — whether an interaction is in flight: the composer's own state, the
1312
+ one it shows Stop for, across every tool round, a Retry and a checkpoint continuation as well
1313
+ as a send. Reflected on the element as the `data-running` attribute, written by the element
1314
+ rather than by you, as `data-empty` is, so the chrome around the chat can style on it.
1315
+
1316
+ Every change of conversation fires one **`ag-ui-thread`** event (the `THREAD_EVENT` constant),
1317
+ after the transcript has been replaced, so a listener reads the conversation it is told about.
1318
+ `detail` is `{ threadId, previousThreadId, reason }` (typed `ThreadDetail`). `previousThreadId`
1319
+ is the thread the previous event reported, `null` before the first, and `reason` is one of:
1320
+
1321
+ | `reason` | When |
1322
+ | --- | --- |
1323
+ | `new` | `newChat()` or the built-in New chat button. |
1324
+ | `switch` | `switchThread()` or a row picked in the drawer. Two switches made before the first had replayed report only the second. |
1325
+ | `delete` | The thread on screen was deleted, and a fresh one took its place. |
1326
+ | `restore` | The stored thread was replayed: when the element connects (`previousThreadId` is `null` the first time), or by `reload()`. |
1327
+ | `rescope` | A `user-key` change handed the element to another principal, live or while it was out of the document. |
1328
+
1329
+ A page reload keeps restoring the thread, as [MPA durability](#mpa-durability-surviving-full-page-reloads) describes, and
1330
+ reports it as `restore`. Ignore a `reason` you do not know: more may be added.
1331
+
1332
+ ```js
1333
+ chat.addEventListener("ag-ui-thread", (e) => myBar.markActive(e.detail.threadId));
1334
+ myBar.onPick = (threadId) => chat.switchThread(threadId);
1335
+ ```
1336
+
1337
+ ### The composer's own keys
1338
+
1339
+ **Enter during a run queues.** A second run cannot start while one is in flight —
1340
+ it would orphan the first — so that key used to do nothing at all, silently. A
1341
+ checkpoint continuation picked from the panel counts as in flight from the pick
1342
+ rather than from its first event, which is a request later, so a turn typed in
1343
+ that gap is parked too rather than racing it. What
1344
+ is waiting shows above the composer as chips, each of which takes its message
1345
+ back when pressed, and the next one is sent when the run settles. Stopping the
1346
+ run discards them: sending into a conversation someone has just stopped is the
1347
+ opposite of what stopping meant. Nothing is lost, though — a queued message
1348
+ entered the recall history below when it left the composer, so **Up** gets it
1349
+ back after a Stop, and after its chip is taken back.
1350
+
1351
+ The composer also walks back through what you have already sent, on **Up** and
1352
+ **Down** — the shape every shell and every coding agent uses. It holds every turn
1353
+ that left the composer: one sent at once, one queued behind a run (from the
1354
+ moment it is queued), and one sent to resume or fork a run from the checkpoint
1355
+ panel. A message sent with `sendMessage` is not in it, because it was never
1356
+ typed there. Only from an empty composer and only with the skills palette closed:
1357
+ an arrow inside text is how you move the caret, and taking it unconditionally
1358
+ would break editing to add a shortcut. Arrowing forward past the newest turn
1359
+ empties the box again, so the way out is the key that got you in. A walk lasts
1360
+ only while the composer holds the turn it put there: anything else that writes
1361
+ the box — typing, a quotation, a skill, dictation, or the host page itself — ends
1362
+ it, so the next arrow moves the caret in what was written rather than replacing
1363
+ it. The history is this conversation's: starting a new chat, switching threads
1364
+ or changing `user-key` clears it with the transcript.
1365
+
1366
+ ### Collapsing to the launcher
1367
+
1368
+ A collapsed widget shrinks to a **floating launcher** in the corner it already occupies: the
1369
+ panel scales down into it and fades, the launcher grows out of the same point, and clicking the
1370
+ launcher reverses it. Only `transform` and `opacity` animate, so the motion is compositor-only and
1371
+ never reflows your page. Two placements collapse to something else instead — `sidebar` slides out
1372
+ to its [edge rail](#sidebar-placement), and `embedded` / `page` keep the header bar, since one is
1373
+ laid out by your page and the other is a full-screen route.
1374
+
1375
+ The launcher's mark comes from the same seam as the header icon, most specific first: a slotted
1376
+ `slot="launcher"` child, then `data-launcher-icon-url`, then `data-icon-url`, then the built-in
1377
+ speech bubble.
1378
+
1379
+ ```html
1380
+ <ag-ui-chat endpoint="/agent/" data-launcher-icon-url="/mark.svg"></ag-ui-chat>
1381
+
1382
+ <!-- or any markup at all -->
1383
+ <ag-ui-chat endpoint="/agent/">
1384
+ <svg slot="launcher" width="26" height="26"><!-- ... --></svg>
1385
+ </ag-ui-chat>
1386
+ ```
1387
+
1388
+ ```css
1389
+ ag-ui-chat {
1390
+ --ag-ui-launcher-size: 48px;
1391
+ --ag-ui-launcher-radius: 14px; /* 50% for a circle */
1392
+ --ag-ui-launcher-bg: #14532d; /* defaults to the panel background */
1393
+ --ag-ui-launcher-fg: #ffffff; /* defaults to the text colour */
1394
+ --ag-ui-launcher-icon-size: 22px;
1395
+ --ag-ui-launcher-inset: auto 0 0 auto; /* which corner of the widget's box */
1396
+ }
1397
+ ```
1398
+
1399
+ The sizes and defaults above are Quiet's. The other themes bring their own launcher: `classic`
1400
+ the 56px circle in the header's colours, `soft` a 56px accent tile, and `ledger` a 44px tile in
1401
+ the text colour.
1402
+
1403
+ > **The collapsed host keeps its box.** Animating the element's own width and height would animate
1404
+ > layout; instead the box stays put with `pointer-events: none`, and the launcher takes the clicks.
1405
+ > A host measuring `getBoundingClientRect()` on a collapsed widget still sees the panel's
1406
+ > footprint — nothing there paints or takes input.
1407
+
1408
+ #### The unread badge
1409
+
1410
+ A collapsed widget is the one state where an answer can arrive with nothing on screen to say so,
1411
+ so the launcher carries a count of the answers that finished while it was closed (capped at `9+`).
1412
+ It is the only affordance here that is **on by default**; `data-unread-badge="false"` turns the
1413
+ badge off.
1414
+
1415
+ **Unseen answers are kept per thread.** Each answer counts against the thread it ran on, and a
1416
+ thread is read only when it is on screen **and** the panel is expanded. On screen in a collapsed
1417
+ panel is not read. So an answer that finished on thread A while collapsed stays unread across a
1418
+ collapsed switch to B, a `newChat()`, or an expand on B, and is read once A is on screen in the
1419
+ open panel. `unread` is the total across threads, which is what the badge shows; it shows on the
1420
+ collapsed launcher only, so an open panel whose other threads hold unseen answers shows no
1421
+ badge. A principal change (a new `user-key`) drops the previous principal's marks, and deleting a
1422
+ thread drops its own.
1423
+
1424
+ A store can carry read state across pages and devices, through two optional members. The element
1425
+ fetches the thread list when the drawer opens or a thread switches, not at page load, so a
1426
+ store's `unread` rows are counted from that fetch on: a page that never opens the drawer shows
1427
+ no badge for answers that finished elsewhere.
1428
+
1429
+ - **`ThreadMeta.unread`** (boolean): a thread `listThreads()` reports as unread, and the page has
1430
+ not counted, counts once, when the list is fetched.
1431
+ - **`markRead(threadId)`**: called when the element reads a thread with unseen answers, and when
1432
+ an answer finishes on the thread in view. A server store maps it to its read endpoint, and
1433
+ should list the thread with `unread: false` afterwards.
1434
+
1435
+ A store without either still has its threads counted and cleared in the page. Neither is
1436
+ implemented by the default `SessionStorageStore`.
1437
+
1438
+ The count is also the launcher's accessible name (`Expand — 2 unread`, from the `expandUnread`
1439
+ string), because a coloured dot says nothing to a screen reader.
1440
+
1441
+ #### Moving the launcher
1442
+
1443
+ The collapsed launcher can be dragged anywhere on screen, and it stays there — per tab, in
1444
+ `sessionStorage`, namespaced per element exactly like the collapsed, theme and size preferences.
1445
+ Arrow keys move it from the keyboard (`Shift` for a larger step). `data-launcher-drag="false"`
1446
+ turns it off; so does any placement that already places the launcher itself — `sidebar` collapses
1447
+ it to a full-height rail, `embedded` and `page` hide it and keep their header bar, and a
1448
+ full-bleed panel has no clear space to open into.
1449
+
1450
+ **The panel then opens into whichever side of the launcher has more room.** For each axis the
1451
+ element compares the space a panel would have on either side of the launcher and pins the side
1452
+ with more of it, so a launcher dragged to the top-left opens down and to the right. What it
1453
+ compares is the room the *panel* would get, not which half of the screen the launcher is in —
1454
+ those give different answers either side of centre, and only the first one is about whether the
1455
+ panel fits.
1456
+
1457
+ A launcher parked where the panel fits neither way — the middle of a short viewport — keeps its
1458
+ position anyway: the panel is clamped into the viewport and the launcher carries the difference,
1459
+ which is why it can end up sitting outside its own host box. Nothing clips it there.
1460
+
1461
+ A drag writes `--ag-ui-inset` and `--ag-ui-launcher-inset` on the host, and an inline custom
1462
+ property outranks your stylesheet's rule for the same one — the same trade a dragged size makes
1463
+ against a placement. Switching to a placement that places itself hands both back.
1464
+
1465
+ > **An undragged launcher is untouched.** With nothing stored, the element writes neither
1466
+ > property and your CSS decides, exactly as before. The geometry is built so that feeding it the
1467
+ > resting position reproduces the default `auto 24px 24px auto` unchanged.
1468
+
1469
+ #### Moving the panel
1470
+
1471
+ An open panel moves by its **header**, the way a window moves by its title bar.
1472
+
1473
+ **The launcher travels the same distance.** Drag the panel 100px left and the bubble it collapses
1474
+ into is 100px left of where it was — it is one widget being moved, not two things being placed.
1475
+ The distance is the panel's own, so a panel held against the viewport's margin stops and the
1476
+ bubble stops with it, and the bubble is then held on screen in its own right.
1477
+
1478
+ **A position you state is kept, not re-derived.** That is the difference between the two drags: a
1479
+ launcher drag says where the bubble goes and lets the panel open into whatever room the viewport
1480
+ has, which is re-decided on every expand and every window resize; a header drag states the
1481
+ panel's own position, so it survives collapsing, reopening and reloading. Dragging the bubble
1482
+ again hands the decision back. Both are stored per tab, and `data-launcher-drag="false"` turns
1483
+ off both.
1484
+
1485
+ Which corner the panel is *pinned* by is re-picked when the drag ends, from where the bubble has
1486
+ ended up, so the next expand still opens into clear space. Re-picking it moves nothing: the
1487
+ corner only says which edges the two insets are written from, and both are written from positions
1488
+ that are already decided.
1489
+
1490
+ The controls in the header keep their own presses — a drag started on a button, a link or a field
1491
+ never begins, including one you slot in.
1492
+
1493
+ There is no keyboard shortcut on the header, deliberately. A header is not a control, and making
1494
+ it focusable would put a tab stop with no role ahead of the controls a keyboard user came for —
1495
+ while arrow keys on the collapsed launcher already move the widget, panel included.
1496
+
1497
+ ```js
1498
+ chat.unread; // 2
1499
+
1500
+ // Every change, whether or not the badge renders it — so a host that hides the
1501
+ // badge can put the count in its own chrome. `threadIds` names the threads the
1502
+ // change marked or cleared, and is empty for a collapse toggle that touched none.
1503
+ chat.addEventListener("ag-ui-unread", (e) => setDockBadge(e.detail.unread, e.detail.threadIds));
1504
+ ```
1505
+
1506
+ ```css
1507
+ ag-ui-chat {
1508
+ --ag-ui-badge-bg: #b91c1c; /* defaults to --ag-ui-danger */
1509
+ --ag-ui-badge-fg: #ffffff;
1510
+ --ag-ui-badge-size: 18px;
1511
+ --ag-ui-badge-font-size: 11px;
1512
+ }
1513
+ ```
1514
+
1515
+ ### Collapsing to a bottom bar
1516
+
1517
+ `collapse-to` chooses what a collapsed panel becomes, for a host whose layout has a bottom
1518
+ toolbar. It is opt-in: absent, `auto`, or any other value leaves every placement as the
1519
+ sections above describe. It is live, like `placement`.
1520
+
1521
+ - **`bar`: the component's own bar.** A strip along the whole bottom edge of the viewport, in
1522
+ place of the round launcher: the title, the [unread badge](#the-unread-badge), and a running
1523
+ indicator while an interaction is in flight. Pressing the strip toggles the panel, which rises
1524
+ from the bar's right end with the bar still beneath it as the minimise target. The bar's
1525
+ history button opens the conversation list as a popover above the bar, without expanding the
1526
+ panel, its outer edge in line with the panel's on a wide viewport, and a second press closes
1527
+ it; `openThreads()` while collapsed opens it too. Expanding the panel closes the popover, so the
1528
+ panel opens on the conversation. It replaces the launcher only, so it applies to the corner
1529
+ placements, `side` and `full`: `sidebar` keeps its rail, `embedded` its header bar, and `page`
1530
+ has no collapsed state at all.
1531
+ - **`none`: your own bar.** Collapsed is hidden: nothing of the widget is left to see or hit,
1532
+ under every collapsible placement. Your bar's button drives the [public
1533
+ API](#driving-the-conversation-from-your-own-chrome) (`collapsed`, `toggleCollapsed()`), and
1534
+ `anchor` names the element the panel rises from, right-aligned to it. An anchor that resolves
1535
+ to nothing opens the panel from the placement's own corner, with one console warning.
1536
+ While collapsed, `openThreads()` opens the conversation list as a popover beside the anchor,
1537
+ or beside the element you pass as `openThreads({ anchor })`, which wins over the attribute and
1538
+ property for that call. It opens below a button in the top half of the viewport and above one
1539
+ in the bottom half, right-aligned to it and held inside the window (full width less 8px on a
1540
+ phone). Picking a conversation switches to it and closes the list, and leaves the panel
1541
+ collapsed: you get `ag-ui-thread` and decide whether to expand. Escape, a press outside,
1542
+ `closeThreads()` and expanding the panel close it; a press on the button itself does not, so
1543
+ your button can toggle the list. With no anchor at all, `openThreads()` warns once per call
1544
+ and draws nothing. `embedded` hides the whole element while collapsed under `none`, so it has
1545
+ no popover.
1546
+
1547
+ ```html
1548
+ <nav class="toolbar">
1549
+ <!-- ... -->
1550
+ <button id="agent-button" type="button">Agent</button>
1551
+ </nav>
1552
+ <ag-ui-chat endpoint="/agent/" collapse-to="none" anchor="#agent-button"></ag-ui-chat>
1553
+ <script type="module">
1554
+ const chat = document.querySelector("ag-ui-chat");
1555
+ const button = document.getElementById("agent-button");
1556
+ button.addEventListener("click", () => chat.toggleCollapsed());
1557
+ // Under none the element announces the state it mounted in, once, so the
1558
+ // button can be labelled before anyone presses it.
1559
+ chat.addEventListener("ag-ui-toggle", (e) => {
1560
+ button.setAttribute("aria-pressed", String(!e.detail.collapsed));
1561
+ });
1562
+ </script>
1563
+ ```
1564
+
1565
+ **Clearing your toolbar.** Under `none` the panel stands `--ag-ui-anchor-gap` (default `8px`)
1566
+ above the anchor's top edge. The anchor is the button, not the toolbar around it, so a button
1567
+ sitting inside a taller toolbar puts the panel's foot across the toolbar's top border. Set the gap
1568
+ to the button's distance from the toolbar's top plus the clearance you want:
1569
+
1570
+ ```css
1571
+ /* The button stands 7px below the toolbar's top edge; clear it by 8px. */
1572
+ ag-ui-chat {
1573
+ --ag-ui-anchor-gap: 15px;
1574
+ }
1575
+ ```
1576
+
1577
+ A gap wider than the gutter is taken off the panel's max height as well, so a tall panel still
1578
+ stops at the top of the window.
1579
+
1580
+ **A button in a header.** The panel opens on the side of the anchor with more room, so a button
1581
+ in a top header opens it below the button and one in a bottom bar opens it above, with no inset
1582
+ of your own. `anchor-side="above"` or `"below"` fixes the side; `auto`, the default, decides again
1583
+ when the window is resized (never during a drag), so an anchor near the middle of the window can
1584
+ change sides. Below the anchor the gap is measured from the button's bottom edge, and the max
1585
+ height is the room under it less one gutter. On either side the panel lines up with the anchor's
1586
+ right edge unless that would put either edge inside the 24px gutter of the window, where it stops
1587
+ instead; its width cap is the window less two gutters, maximized included.
1588
+
1589
+ ```html
1590
+ <ag-ui-chat endpoint="/agent/" collapse-to="none" anchor="#agent-button" anchor-side="below"></ag-ui-chat>
1591
+ ```
1592
+
1593
+ Under `none` the element dispatches `ag-ui-toggle` once as it connects, with whichever state it
1594
+ mounted in, collapsed or open. Under `auto` and `bar` it does not, as before. That event touches
1595
+ neither the stored state nor the unread count.
1596
+
1597
+ **The page's space is yours.** The bar is fixed over the page, so a host pads its own page by
1598
+ the bar's height. The component never writes your CSS, and a custom property cannot carry the
1599
+ height up to your page (it inherits down, never up), so it hands you a number:
1600
+ `barHeight` (CSS px, read-only) and an `ag-ui-bar-height` event (the `BAR_HEIGHT_EVENT`
1601
+ constant, detail `{ height }`) on every change, including a font-size change. Both read `0`
1602
+ where no bar is drawn: under `auto` and `none`, on a phone while the panel is open, and while your
1603
+ size fills the window with the panel open. If you write the height into
1604
+ `--ag-ui-viewport-inset-bottom`, a panel that fills the box that leaves stays judged against that
1605
+ box once the bar goes and the box grows by the bar's height: it settles with the bar gone and room
1606
+ at its foot. Writing some other share of the height does not settle; publish it to your page as below.
1607
+
1608
+ ```js
1609
+ const chat = document.querySelector("ag-ui-chat");
1610
+ const pad = () => {
1611
+ document.documentElement.style.setProperty("--chat-bar-height", `${chat.barHeight}px`);
1612
+ };
1613
+ chat.addEventListener("ag-ui-bar-height", pad);
1614
+ pad();
1615
+ ```
1616
+
1617
+ ```css
1618
+ body {
1619
+ padding-bottom: var(--chat-bar-height, 0px);
1620
+ }
1621
+ ```
1622
+
1623
+ **A fixed sidebar pushes your page the same way.** A docked `placement="sidebar"` (or `"side"`)
1624
+ is fixed over the page, so `dockWidth` (CSS px, read-only) and an `ag-ui-dock-width` event (the
1625
+ `DOCK_WIDTH_EVENT` constant, detail `{ width, side }`, `side` being `"left"` or `"right"`, as
1626
+ `data-side` says) hand you the width it covers at rest: the panel's width while open, the rail's
1627
+ (`--ag-ui-rail-width`, 52 by default) while collapsed to it, and `0` under `collapse-to="none"` or
1628
+ a launcher, under every other placement, on a phone while the panel is open, and under
1629
+ `--ag-ui-position: static`, where your own layout already makes room. The event fires once per
1630
+ change of the resting value, never per frame of the width transition, and once at the end of a
1631
+ resize drag.
1632
+
1633
+ ```js
1634
+ const chat = document.querySelector("ag-ui-chat");
1635
+ const push = () => {
1636
+ const { dockWidth: width } = chat;
1637
+ const side = document.querySelector("ag-ui-chat").getAttribute("data-side") ?? "right";
1638
+ document.documentElement.style.setProperty("--chat-dock-left", side === "left" ? `${width}px` : "0px");
1639
+ document.documentElement.style.setProperty("--chat-dock-right", side === "left" ? "0px" : `${width}px`);
1640
+ };
1641
+ chat.addEventListener("ag-ui-dock-width", push);
1642
+ push();
1643
+ ```
1644
+
1645
+ ```css
1646
+ body {
1647
+ padding-left: var(--chat-dock-left, 0px);
1648
+ padding-right: var(--chat-dock-right, 0px);
1649
+ }
1650
+ ```
1651
+
1652
+ **No inset needed.** Under `bar` and `none` you do not set `--ag-ui-inset` to keep the panel off
1653
+ your toolbar: its max height is the viewport less the bar's height (or, under `none`, the
1654
+ distance to the anchor's top, or below it from the anchor's bottom) and one gutter, and its
1655
+ bottom sits on the bar or the anchor (its top, when it opens below).
1656
+
1657
+ **Docked, and still movable.** On a wide viewport a docked corner panel (the corner placements
1658
+ under `bar`, or under `none` with or without an anchor) moves by its header, and `moveTo` moves
1659
+ it, as it would the corner panel it is; `describeSurface()` reports `movable: true`. There is no
1660
+ launcher to drag: the bar and your own control stay where they are.
1661
+
1662
+ - **Above the dock.** A dragged panel stays inside the viewport and above the bar's top edge, so it
1663
+ never covers the bar it minimises to. Under `none` with an anchor it stays above the anchor's
1664
+ top edge the same way (below its bottom edge, when the panel opens below it), because the
1665
+ anchor is your own way back and has to stay in view;
1666
+ `--ag-ui-anchor-gap` no longer applies once the panel is dragged, and the screen-edge margin
1667
+ (8px) does. Under `none` with no anchor only the viewport holds it, as for a corner panel.
1668
+ - **Remembered like a corner panel.** Minimising and reopening brings it back where it was
1669
+ dragged, and so does a reload. The spot is kept apart from the one a corner panel stores under
1670
+ `auto`, so switching `collapse-to` never puts one surface's spot on the other. A spot that no
1671
+ longer fits, because the window shrank or the bar grew, is held inside the room that is left
1672
+ and comes back whole when there is room again.
1673
+ - **Resizing.** A panel standing on its dock keeps the edges on the bar or the anchor still and the
1674
+ free ones follow the grip. Once dragged off, every edge follows its grip, as on a corner panel.
1675
+ - **Back to the dock.** `resetSize()` drops the placement's dragged size and returns the panel to its dock,
1676
+ forgetting the stored spot, so a reload opens it there too.
1677
+ - **Keeping it fixed.** `data-launcher-drag="false"` turns the header drag and `moveTo` off here as
1678
+ everywhere else, and the panel stays on its dock.
1679
+ - **A host's size that fills the window.** Set `--ag-ui-width` and `--ag-ui-height` to the whole
1680
+ window, with `--ag-ui-max-width` and `--ag-ui-max-height` out of the way (the defaults stop short
1681
+ of it), and the panel stands at the start of the room you left free, whether it was moved or not.
1682
+ The bar and the grips go while it does, as on a phone, with `barHeight` at 0 and the header's
1683
+ minimise control as the way back. The bar comes back when the panel is collapsed, and both when
1684
+ your size stops filling. The stored spot stays, and the panel goes back to it, or to its dock,
1685
+ once your size stops filling. An inline `--ag-ui-inset` of your own stays where you wrote it.
1686
+
1687
+ Placements that own their position (`side` and `full` under `bar`, `sidebar` and `embedded`
1688
+ under `none`) do not move, as without `collapse-to`.
1689
+
1690
+ **Phones.** At 600px or less the open panel stays full-bleed, over the bar, and the header's
1691
+ minimise control is the way back; the bar shows only while collapsed, so no second strip sits
1692
+ at the bottom edge while the keyboard is up. The panel does not move there, and a spot dragged on
1693
+ a wide viewport does not apply: it comes back when the window is wide again.
1694
+
1695
+ The bar is styled through the parts `bar` (the strip's wrapper, holding the launcher, the place and
1696
+ the history button) and `bar-history`, and the launcher's own parts (`launcher`, `rail-label`,
1697
+ `launcher-badge`) inside it.
1698
+
1699
+ **The place in the bar.** When [`getPlace`](#where-the-user-is-the-place-chip) (or the route map
1700
+ standing in for it) names a place, the strip shows it beside the title as quiet secondary text: the
1701
+ `bar-place` part holding `place-label` and `place-detail`, the parts the chip uses. It comes from
1702
+ the chip's one read, so `refreshPlace()`, `popstate` and a withheld place update it as they update
1703
+ the chip. A long place truncates with an ellipsis before the history button leaves the strip, and
1704
+ no place draws nothing. A place the user withheld stays in the strip dashed and struck through
1705
+ (`data-excluded` on `bar-place`), because the bar must not claim the page is sent. The phone strip,
1706
+ shown while collapsed, carries it too.
1707
+
1708
+
1709
+ ---
1710
+
1711
+ ## Charts
1712
+
1713
+ Markdown here goes through a narrow sanitiser and images are off by default — a
1714
+ model-controlled image URL is fetched with no user interaction, which turns
1715
+ prompt-injected page data into a zero-click exfiltration channel. So a chart
1716
+ does not arrive as markup. It arrives as **data**, and the component builds the
1717
+ SVG itself: the model chooses the numbers, the component chooses the DOM.
1718
+
1719
+ Off unless you ask for it, by either route or both:
1720
+
1721
+ ```js
1722
+ const chat = document.querySelector("ag-ui-chat");
1723
+ chat.enableCharts(["tool", "activity"]);
1724
+ ```
1725
+
1726
+ Order does not matter. Calling it after the element is on the page redraws any
1727
+ charts already in the restored history, so you do not have to reach the element
1728
+ before it connects — which you generally cannot.
1729
+
1730
+ Specs are bounded, and anything outside is dropped rather than half-drawn: at
1731
+ most **20,000 points** and **2,000 labels**, and every point a finite number no
1732
+ larger than **1e15**. The first two keep a stored transcript from blocking the
1733
+ main thread on every reload; the last one keeps the value range finite, since
1734
+ scaling divides by it.
1735
+
1736
+ **`"tool"`** registers a `render_chart` tool the agent may call. The numbers are
1737
+ in its context, so it can talk about them; it costs one model round.
1738
+
1739
+ **`"activity"`** draws a chart the server pushes as an `ACTIVITY_SNAPSHOT` with
1740
+ `activityType: "chart"` (exported as `CHART_ACTIVITY_TYPE`). The data never
1741
+ reaches the model, there is no extra round, and this is the only route that can
1742
+ **update a chart in place** — the server repeats the same `messageId` to redraw
1743
+ it, or sends an `ACTIVITY_DELTA` to move one series as a computation advances.
1744
+
1745
+ **Whether a pushed chart survives a reload depends on where the conversation is
1746
+ stored.** A client-side store keeps activities, so it comes back. A server that
1747
+ stores the thread as the model's message history does not — a pushed chart is
1748
+ deliberately not in that history, which is the reason to push it. A chart the
1749
+ agent asked for survives either way, because its spec travels as the tool call's
1750
+ arguments and the component redraws from those without re-running anything.
1751
+
1752
+ Either way the payload is the same shape:
1753
+
1754
+ ```json
1755
+ {
1756
+ "kind": "bar",
1757
+ "title": "Signups this week",
1758
+ "labels": ["Mon", "Tue", "Wed"],
1759
+ "series": [{ "label": "new", "points": [12, 19, 9] }]
1760
+ }
1761
+ ```
1762
+
1763
+ `kind` is one of `bar`, `line`, `pie`, `scatter`, `stacked`; anything else is
1764
+ drawn as a bar rather than refused. Every series needs exactly one point per
1765
+ label — a shorter one misaligns every value after the gap, and a chart that is
1766
+ subtly wrong still reads as authoritative, so the whole spec is dropped instead.
1767
+ A pie's slices are its labels, so it draws the first series only.
1768
+
1769
+ Theme the series with `--ag-ui-chart-1` … `--ag-ui-chart-6`, size it with
1770
+ `--ag-ui-chart-max-width`, and style the block through the `chart-block`,
1771
+ `chart-title` and `chart-legend` parts.
1772
+
1773
+ **A chart stops at its own width, and is sized in pixels rather than scaled to
1774
+ them.** Two separate things, and a widened panel needs both.
1775
+
1776
+ It is drawn for the width the block actually has -- one SVG unit per CSS pixel
1777
+ -- and redrawn when that width changes, so a 10px axis label is 10px in a 380px
1778
+ panel and in a 1200px one. Where the labels no longer fit, the axis draws every
1779
+ second or third rather than a smear of overlapping words. Below 220px it goes
1780
+ back to scaling, since at that size nothing fits either way.
1781
+
1782
+ And it takes the width it needs rather than the width it is offered, capping at
1783
+ `--ag-ui-chart-max-width` (**480px**): widening the panel should no more resize
1784
+ a chart than it resizes a message. Raise the token for a bigger chart and the
1785
+ labels stay 10px -- the cap is about how much room the drawing gets, never
1786
+ about how big its type is. Height follows width inside a 160-320px band.
1787
+
1788
+ ### Drawing something other than a chart
1789
+
1790
+ `render_chart` is built on a seam any tool can use. A `ClientTool` may declare a
1791
+ pure `render` beside its `handler`:
1792
+
1793
+ ```js
1794
+ chat.registerTool({
1795
+ name: "show_route",
1796
+ description: "Draw the route on a map.",
1797
+ parameters: { type: "object", properties: { stops: { type: "array" } } },
1798
+ handler: () => "route shown",
1799
+ render: (args) => buildMapElement(args.stops), // pure; no side effects
1800
+ });
1801
+ ```
1802
+
1803
+ **`render` is the only half a restored transcript replays.** Replaying a tool's
1804
+ *effect* is out of the question — re-running a form-filling tool on every reload
1805
+ is a bug. The replay path is handed the `render` function alone, never the tool
1806
+ that owns it, so the code that runs on restore cannot reach `handler` even by
1807
+ mistake: adding a "no render? fall back to the handler" convenience there means
1808
+ changing a type signature first, which is the moment the question gets asked.
1809
+
1810
+ That is why `render` has to be a pure, deterministic function of its arguments —
1811
+ it runs again every time the conversation is restored.
1812
+
1813
+ ---
1814
+
1815
+ ## Drawing other things the server pushes
1816
+
1817
+ `activityType` is an open string the protocol does not enumerate — `"chart"` is
1818
+ just the one the component ships a renderer for. Register your own and the
1819
+ server can push anything it likes into the transcript:
1820
+
1821
+ ```js
1822
+ chat.registerActivityRenderer({
1823
+ type: "build_status",
1824
+ render: (content) => {
1825
+ const el = document.createElement("div");
1826
+ el.className = "build";
1827
+ el.textContent = `Build ${content.status}`;
1828
+ return el; // return null for content not worth drawing
1829
+ },
1830
+ });
1831
+ ```
1832
+
1833
+ **`render` runs again on every thread load**, so it carries the same contract as
1834
+ a client tool's `render`: a pure function of `content`, deterministic, and free
1835
+ of effects outside the node it returns. Activities are materialised into
1836
+ `role: "activity"` messages and persisted with the transcript, so a renderer that
1837
+ writes to the page instead of returning DOM fires again on every restore.
1838
+
1839
+ The component places what you return, keyed by the activity's `messageId`, so a
1840
+ server repeating an id **replaces** your node rather than adding a second one —
1841
+ the same in-place update charts get. Returning `null` removes whatever was there:
1842
+ live and reload should agree, and the stored content is the version that could
1843
+ not be drawn.
1844
+
1845
+ `chart` and `compaction` are registrations exactly like yours, not privileged
1846
+ branches, so registering either name **replaces the built-in**.
1847
+
1848
+ ### Which carrier should the server use?
1849
+
1850
+ AG-UI leaves exactly two payload names open, and they are not
1851
+ interchangeable:
1852
+
1853
+ | | Carrier | Reaches | Persisted | Replayed |
1854
+ | --- | --- | --- | --- | --- |
1855
+ | **Content** | `ACTIVITY_SNAPSHOT` | the transcript | yes | yes |
1856
+ | **Imperative** | `CUSTOM` | your page, as [`ag-ui-custom`](#host-seams-the-spa-story) | no | no |
1857
+
1858
+ ⇒ **Content has a place in the conversation and should come back. An imperative
1859
+ has no place and no meaning once acted on** — replaying "refetch the board" on
1860
+ every thread load is a bug, not a feature. If it has to survive a reload, it is
1861
+ content.
1862
+
1863
+ ### Finding out what arrived
1864
+
1865
+ An activity nobody registered for draws nothing and logs nothing — that is the
1866
+ protocol's own answer, and warning would fire on every forward-compatible
1867
+ server. But silence is hard to debug, so the names are readable:
1868
+
1869
+ ```js
1870
+ chat.unhandledActivityTypes; // ["pydantic_ai_thinking", …]
1871
+ ```
1872
+
1873
+ Note `"chart"` appears there until you call `enableCharts(["activity"])`, which
1874
+ is the honest answer to "I pushed a chart and nothing happened".
1875
+
1876
+ ## Tool-call display modes
1877
+
1878
+ How tool calls are drawn is set via the `data-tool-display` attribute (or `toolDisplay`
1879
+ property), one of `grouped` / `inline` / `minimal` / `compact` / `full` (default `grouped`; an
1880
+ absent or unknown value reads as `grouped`):
1881
+
1882
+ - `grouped` — the default. A run's consecutive tool calls fold into one quiet row, **"Worked
1883
+ through 5 steps"**, with a note such as **"1 failed"** beside it, and the calls one click down
1884
+ as `full` cards, or in the mode `data-tool-group-display` names. Anything else in the turn (text, a confirmation, a question) ends the group, and
1885
+ the next call starts a new one; a call's own rendered output does not. A group of one call shows that call's own label. See
1886
+ [The step group](#the-step-group).
1887
+ - `inline` — the lightest mode: a single status row (icon + summary, no card chrome) with the
1888
+ result behind its own toggle. Reads as one line of the answer — pairs with [the answer
1889
+ well](#the-answer-well).
1890
+ - `minimal` — tool name + status pill only.
1891
+ - `compact` — name + status, with arguments *and* result behind a single collapsed toggle.
1892
+ - `full` — arguments visible, result behind the toggle. The default before `grouped`: set
1893
+ `data-tool-display="full"` to keep per-call cards.
1894
+
1895
+ Whichever mode is on, a settled card's body holds **two labelled regions** — `Arguments` and
1896
+ `Result` (or `Error` / `Declined`) — each with its own part and each pretty-printed. They are
1897
+ never run together into one block, so where the call ends and the answer begins is always
1898
+ visible. Style them via the `tool-card-args` / `tool-card-result` parts, their headings via
1899
+ `tool-card-section-label`, and the whole body via `tool-card-body`.
1900
+
1901
+ **A card names its status as a part.** `::part()` takes no attribute selector, so a rule on
1902
+ `tool-card-status` reaches every outcome alike. The card and its status pill each carry a modifier
1903
+ beside the base part, one of `pending`, `deferred`, `done`, `error`, `declined` or `interrupted`,
1904
+ and it is replaced whenever the status changes:
1905
+
1906
+ ```css
1907
+ ag-ui-chat::part(tool-card-error) { border-color: crimson; }
1908
+ ag-ui-chat::part(tool-card-status-error) { font-weight: 700; }
1909
+ ```
1910
+
1911
+ **The attribute is live.** Changing `data-tool-display` between the four card modes restyles every
1912
+ card already in the transcript, the way `data-answer-well` does — the modes are pure visibility
1913
+ over one DOM shape, selected by the shadow CSS from the host attribute. Grouping is decided as
1914
+ each card is built: switching **to** `grouped` folds only the calls drawn afterwards, and
1915
+ switching **away** from it flattens the groups already drawn, by CSS, into exactly the cards the
1916
+ new mode builds (the group stops generating a box and its row is hidden).
1917
+
1918
+ ### Choosing how a group draws its calls
1919
+
1920
+ Under `grouped`, an opened group draws its calls as `full` cards unless the
1921
+ `data-tool-group-display` attribute (or `toolGroupDisplay` property) names another of the card
1922
+ modes: `compact`, `minimal` or `inline`. Each draws the regions the flat card of that mode draws,
1923
+ laid out as a row of the group's list rather than as a boxed card. An absent or unknown value
1924
+ reads as `full`, so nothing changes for a host that does not set it.
1925
+
1926
+ ```html
1927
+ <ag-ui-chat endpoint="/agent/" data-tool-group-display="compact"></ag-ui-chat>
1928
+ ```
1929
+
1930
+ It is read only under `grouped`. Under one of the four card modes the groups are flattened into
1931
+ that mode's cards and the inner value is ignored; switching back to `grouped` applies it again.
1932
+ It is live like the outer attribute: changing it restyles groups already in the transcript.
1933
+
1934
+ A call waiting on an approval shows its arguments and its Approve and Deny buttons in every inner
1935
+ mode, as it does in every card mode, and Approve takes focus when the group opens for it.
1936
+
1937
+ ### The step group
1938
+
1939
+ Under `grouped` the row reads, by state:
1940
+
1941
+ | State (`data-state` on the `tool-group` part) | Label | Mark |
1942
+ | --- | --- | --- |
1943
+ | `running` | "Working through 3 steps", and the running step's label as a note | spinner |
1944
+ | `waiting` | "3 steps", with "1 waiting for you" | accent dot |
1945
+ | `done` | "Worked through 3 steps" | check |
1946
+ | `error` | "Worked through 3 steps", with "1 failed" in the danger colour | error mark |
1947
+
1948
+ "N declined" is added as a note whenever a step was declined. `data-count` carries the number of
1949
+ steps and `data-open` whether the cards are showing; the head is a button with `aria-expanded`.
1950
+
1951
+ - **It opens itself while a step waits on you.** A server-side approval renders inside the gated
1952
+ call's own card, so a folded group would hide the question it is asking: the group opens when a
1953
+ step goes `deferred`, once per wait, and stays folded if you fold it again. It opens before the
1954
+ approval is drawn, so the Approve button takes focus and scrolls into view. A client-side
1955
+ confirmation and an `ask_user` question render in the answer beside the group rather than inside
1956
+ a card, so they are in view without it opening; the row still reads `waiting` ("1 waiting for
1957
+ you") while one is open, as the card carries a `data-asking` flag for the group to read, and its
1958
+ `data-status` stays `pending`.
1959
+ - **A one-step group is named after its call, and the card's head is not repeated inside.** A turn
1960
+ with exactly one call reads as that call's label ("Fill field"), and the opened list shows the
1961
+ card without its own `tool-card-head` (the row's state mark and notes carry the status). The
1962
+ Details toggle and arguments stay, except under `data-tool-group-display="minimal"`, where the
1963
+ card is only its head: there the row draws no chevron, though it still opens for an approval. A
1964
+ card display mode keeps the head. A run that grows to two calls brings both heads back.
1965
+ - **A running sub-agent does not open it.** The row keeps its spinner and names the delegation
1966
+ instead, "Delegated to researcher" (the `subAgentDelegatedTo` string).
1967
+ - **Repeated labels are told apart.** Two "Fill field" steps read "Fill field" and "Fill field
1968
+ (2)": the ordinal is an element of its own (part `tool-card-repeat`) beside the card's name, so
1969
+ `tool-card-name` is never rewritten, and a card mode hides the ordinal.
1970
+ - **Rendered output stays in view.** A tool's `render` output is placed after the group, in call
1971
+ order, rather than inside it, so a chart is never one click down. It does not end the group: a
1972
+ later call joins the group above it, which is also how a restored conversation draws the run.
1973
+
1974
+ Parts: `tool-group` (with the state beside it as `tool-group-waiting`, `tool-group-running`,
1975
+ `tool-group-done` or `tool-group-error`), `tool-group-head` (the button), `tool-group-icon`, `tool-group-label`,
1976
+ `tool-group-meta` (holding one `.tool-group-note` per note), `tool-group-list` (the cards), and
1977
+ `tool-card-repeat` on a repeated step. The focus ring is `--ag-ui-ring` and the disclosure chevron
1978
+ `--ag-ui-ico-chevron`. Strings (`strings` / `data-strings`): `toolGroupSteps`,
1979
+ `toolGroupWorking`, `toolGroupWorked` (each with `{count}`, always 2 or more), `toolGroupWaiting`,
1980
+ `toolGroupFailed`, `toolGroupDeclined` (each with `{count}`) and `toolStepRepeat` (with `{n}`).
1981
+
1982
+ ```html
1983
+ <!-- Keep per-call cards, as before grouped was the default -->
1984
+ <ag-ui-chat endpoint="/agent/" data-tool-display="full"></ag-ui-chat>
1985
+ ```
1986
+
1987
+ ### Drawing a card's body yourself
1988
+
1989
+ A thirty-field result is a wall of JSON where a host wanted a table, or a
1990
+ sentence. `formatToolPayload` is the seam: it is asked about each region of each
1991
+ card and may return a `Node` to take it over, a `string` to replace its text, or
1992
+ `null` to leave the built-in pretty-print alone.
1993
+
1994
+ ```js
1995
+ chat.formatToolPayload = (payload) => {
1996
+ if (payload.kind !== "result" || payload.toolName !== "list_orders") {
1997
+ return null; // everything else keeps the default rendering
1998
+ }
1999
+ const table = document.createElement("table");
2000
+ // ... build it from JSON.parse(payload.text)
2001
+ return table;
2002
+ };
2003
+ ```
2004
+
2005
+ Both halves come through the same hook, told apart by `kind`: `arguments`
2006
+ carries the parsed record the call was made with, `result` the raw string the
2007
+ tool returned plus the outcome it settled on. A region a formatter took over is
2008
+ marked `data-formatted`, which relaxes the preformatted whitespace the default
2009
+ JSON block relies on — a table would otherwise inherit it as mangled cell
2010
+ spacing. Whitespace only: the card's face, frame and scroll cap stay, so one long
2011
+ payload still cannot stretch the transcript, and a host wanting different
2012
+ typography restyles the `tool-card-result` part.
2013
+
2014
+ This is **presentation, not translation.** The card and the model already read
2015
+ separate copies of a tool result — the model's is maintained by `@ag-ui/client`
2016
+ from the same event — so a formatter changes what the person reads and nothing
2017
+ the agent reads. That is what makes restyling safe here, and it is also why
2018
+ *rewording* belongs on the server: renamed there, the new wording reaches the
2019
+ model's prose too, instead of leaving the card disagreeing with the answer beside
2020
+ it. A returned string is set as text, never parsed as markup — this is not a
2021
+ second HTML channel into the transcript.
2022
+
2023
+ A gated call carries the decision (`approved by you` / `declined by you`, part
2024
+ `tool-card-decision`, attribute `data-decision`) — from the client-side confirmation card and
2025
+ from the server-side approval interrupt alike. The prompt itself disappears once answered: a
2026
+ prompt and a record are different objects, and the record is the card.
2027
+
2028
+ **The annotation is session-scoped**, like the "run interrupted" notice. AG-UI carries no
2029
+ approval message — the answer rides `resume[]` as transient run input — so a reload restores the
2030
+ tool call and its result but not the note that a human waved it through. If you need "who
2031
+ approved what" durably, that is an audit concern rather than a transcript one; record it
2032
+ server-side.
2033
+
2034
+ If a tool's schema carries an `x-summary` string (use `X_SUMMARY_KEY`), the card shows it on the
2035
+ label instead of the raw tool name.
2036
+
2037
+ Every card leads with a **status icon** drawn entirely in CSS — a spinning ring while the call
2038
+ runs, then a check / cross / slash / dotted ring on success / error / decline / not finished.
2039
+ Re-theme it via custom properties (or the `tool-card-icon` part): `--ag-ui-tool-icon-done`,
2040
+ `--ag-ui-tool-icon-error`, `--ag-ui-tool-icon-declined`, `--ag-ui-tool-icon-interrupted`
2041
+ (quoted-string glyphs) and `--ag-ui-tool-spin-duration` (spinner
2042
+ speed; the spin respects `prefers-reduced-motion`).
2043
+
2044
+ ```html
2045
+ <ag-ui-chat endpoint="/agent/" data-tool-display="compact"></ag-ui-chat>
2046
+ ```
2047
+
2048
+ ---
2049
+
2050
+ ## Delegated sub-agents
2051
+
2052
+ A run that hands work to a sub-agent reads as a stall. The parent's
2053
+ `delegate_task` card sits at "running…" for the child's entire duration —
2054
+ however many tools the child calls, however long it takes — with nothing on
2055
+ screen to say anything is happening.
2056
+
2057
+ If your server narrates that, the component draws it. The wire is an ordinary
2058
+ AG-UI `CUSTOM` event named `ag_ui.subagent`, carrying:
2059
+
2060
+ | Key | Meaning |
2061
+ | --- | --- |
2062
+ | `delegationId` | the **parent's own `delegate_task` tool-call id** — not the child's run id |
2063
+ | `agent` | the child agent's name |
2064
+ | `phase` | one of `started`, `tool_call`, `tool_result`, `finished`, `failed` |
2065
+ | `status` | a pre-rendered line, ready to show |
2066
+ | `tool` | `toolCallId`, `name` and `ok`, on the two tool phases only |
2067
+
2068
+ Exactly one `started` opens a delegation and exactly one `finished` or `failed`
2069
+ closes it. `ok` is a tri-state: `null` while the child's call runs, `true` on a
2070
+ result it accepted, `false` on one that came back to it.
2071
+
2072
+ Because the key is the *parent's* call id, the surface attaches to a card that
2073
+ already exists rather than floating a second element with the same identity: one
2074
+ **collapsed row per delegation**, live, carrying the server's `status` line and
2075
+ nothing else, expanding onto the child's own tool calls. A ten-step child costs
2076
+ one row until somebody opens it, and there is no second visual language — it
2077
+ reads the way tool cards already read.
2078
+
2079
+ `status` is why the collapsed row needs no wording of its own. The structured
2080
+ keys are there for a host that would rather write its own.
2081
+
2082
+ The row shows in **every** [display mode](#tool-call-display-modes), including
2083
+ `minimal`. It sits outside the card body rather than in it, because the body is
2084
+ what the density modes hide — and a live progress line that only appeared in
2085
+ `full` would leave exactly the stall it exists to end. Same reasoning that shows
2086
+ a deferred card's arguments whatever the mode.
2087
+
2088
+ **A failure carries no exception text on this channel, deliberately** — the same
2089
+ reasoning that redacts a `RUN_ERROR`, since an exception's words are written for
2090
+ an operator. The detail rides the ordinary tool result for that delegation, which
2091
+ lands in the same card's `Result` region a few pixels below. Nothing here invents
2092
+ words the server declined to send.
2093
+
2094
+ **None of it is persisted.** A `CUSTOM` event never enters the message list, so
2095
+ nothing replays on a thread restore — which is the right half of the
2096
+ [carrier split](#which-carrier-should-the-server-use): a delegation that was live
2097
+ an hour ago is not live now, and replaying its progress would be a lie about a
2098
+ run that is over. Reload mid-run and the tool card is still there; the nested
2099
+ detail is not. That is the intended behaviour.
2100
+
2101
+ Like `ag_ui.invalidate`, this name is **routed** rather than forwarded: it draws
2102
+ itself and does not also arrive as an `ag-ui-custom` event. Every other name
2103
+ still reaches your page untouched.
2104
+
2105
+ Style it through `tool-card-subagent` (the region inside the card), `subagent`,
2106
+ `subagent-row`, `subagent-icon`, `subagent-status`, `subagent-steps`,
2107
+ `subagent-step`, `subagent-step-icon` and `subagent-step-name`. The two glyph
2108
+ states reuse the card's own `--ag-ui-tool-icon-done` / `--ag-ui-tool-icon-error`
2109
+ properties and its spinner speed, so re-theming the cards re-themes these. The
2110
+ row's own chrome comes from `subAgentWorking` and `subAgentSteps` in
2111
+ [`UiStrings`](#internationalization-i18n); everything else on the row is the
2112
+ server's text.
2113
+
2114
+ ---
2115
+
2116
+ ## Resizing the panel
2117
+
2118
+ The panel carries a grip on **every edge and every corner**, so it can be
2119
+ dragged from whichever side you are already near.
2120
+
2121
+ - `placement="page"` gets **no grips** — a full-bleed layout
2122
+ is `100vw`/`100vh` by definition, so there is nothing to drag.
2123
+ - `placement="sidebar"` keeps only the two vertical edges;
2124
+ the placement owns the height, so a horizontal edge or a corner would
2125
+ advertise a drag that does nothing.
2126
+ - Everything else gets all eight.
2127
+
2128
+ **The edge a grip does not drag is the one that stays put.** That is the whole
2129
+ model: the left grip moves the left edge and holds the right, the right grip
2130
+ does the reverse, a corner does both axes. A grip names its own edge, so no
2131
+ layout can invert it.
2132
+
2133
+ ### Dragging the edge your layout was holding still
2134
+
2135
+ A floating panel pinned bottom-right cannot grow rightward on its own — its
2136
+ right edge is what the placement fixed. So **a drag on a pinned edge moves the
2137
+ panel as well as resizing it**, and the component takes the position over by
2138
+ writing `--ag-ui-inset`, the same ownership a dragged launcher takes.
2139
+
2140
+ A grip on a free edge writes nothing but the size, exactly as before, so a host
2141
+ positioning the panel with its own rule keeps that rule until someone drags the
2142
+ edge it was holding.
2143
+
2144
+ Which edges those are belongs to *your* CSS rather than to `placement` — a
2145
+ floating panel a host right-aligns is anchored bottom-left — so the element
2146
+ probes its own geometry and reflects the result as `data-resize-anchor` (e.g.
2147
+ `bottom-right` means those two edges are fixed). Nothing in the stylesheet reads
2148
+ it any more; the element uses it to decide what a drag on a pinned edge costs,
2149
+ and which grip carries the keyboard.
2150
+
2151
+ > **The probe shrinks rather than grows.** Growing by a pixel cannot answer the
2152
+ > question at a size already resting against `max-width` or `max-height`: the
2153
+ > box does not change, no edge moves, and every clamped axis reads as pinned on
2154
+ > the side that is actually free. That was reachable with no user action at all
2155
+ > — the default panel is 380px wide against a max-width of `100vw - 48px`, so
2156
+ > any viewport under 428px was born clamped, with the grip on the wrong corner
2157
+ > and the drag inverted.
2158
+
2159
+ ### Sizes, keyboard, and parts
2160
+
2161
+ A drag writes the private `--_drag-width` / `--_drag-height` on the host, not
2162
+ your `--ag-ui-width` / `--ag-ui-height` tokens, which stay yours. The precedence
2163
+ is:
2164
+
2165
+ - **Above 600px**, the dragged size beats a host's `--ag-ui-width` /
2166
+ `--ag-ui-height`, `!important` included, so a user who resized the panel keeps
2167
+ their size. A host that enters a sized state of its own (an "expanded" class)
2168
+ calls `resetSize()` when it does, which drops the current placement's dragged
2169
+ size and its stored copy and hands control back to the host tokens. A host
2170
+ with no such state needs nothing.
2171
+ - **Per placement.** A dragged size belongs to the `placement` it was dragged in
2172
+ and is stored under it: absent and `floating` share one, and `bottom-left`,
2173
+ `sidebar`, `side` and `embedded` each keep their own (`data-side` is not a
2174
+ placement, so a left and a right sidebar share one). Switching `placement`
2175
+ swaps in the new placement's size, or its default when it has none, so a
2176
+ placement switch needs no `resetSize()`. A `collapse-to` change keeps the size.
2177
+ A size stored by a release before this one is read as floating's.
2178
+ - **At 600px or less**, the small-viewport shape ignores the dragged size
2179
+ entirely, for the placements that block covers (`floating`, `bottom-left`,
2180
+ `sidebar`, `side` and the default): the panel is full-bleed whatever was
2181
+ dragged or stored, and a window narrowed after a wide-window drag goes
2182
+ full-bleed too. `page` is already that shape, and `embedded` is left to the
2183
+ host's own box, so neither is touched by the block. `data-small-viewport="off"`
2184
+ keeps a dragged size at every width on the covered placements.
2185
+
2186
+ A dragged size is a private property, so a host should not read it back from
2187
+ the element's inline style; use `getBoundingClientRect()`, or `resetSize()` to
2188
+ clear it.
2189
+
2190
+ **A custom property alone does not leave placement in charge** — an inline custom
2191
+ property still outranks a `:host([placement=…])` rule setting the same property. So the
2192
+ component enforces the split directly: **a placement owns the axes it fixes**,
2193
+ and a dragged or persisted size is only ever applied to the ones it leaves free.
2194
+ Switching placement hands the owned axes back. Without that, a height dragged
2195
+ while floating capped a docked sidebar that had asked for `100vh`.
2196
+
2197
+ **A host rule that sizes the element wins over both.** `ag-ui-chat { flex: 1 }`
2198
+ stretches the panel to its container and the dragged width has no visible
2199
+ effect — which reads as a broken control rather than as your stylesheet winning.
2200
+ Give the element `flex: 0 1 auto` (plus `max-width: 100%`) if it lives in a flex
2201
+ container.
2202
+
2203
+ The size persists per tab (`sessionStorage`, namespaced per element like the
2204
+ collapsed and theme preferences) and is restored before the first paint.
2205
+
2206
+ **Exactly one grip is in the tab order** — the corner diagonally opposite the
2207
+ pinned one, so an arrow key changes the size and never the position. Eight
2208
+ separators between the transcript and the composer would be a keyboard
2209
+ obstacle rather than keyboard parity, and one grip already reaches both axes.
2210
+ Arrow keys resize from it (`Shift` for a larger step).
2211
+
2212
+ Each grip draws a short pill centred on its edge — a dot in a corner — on hover
2213
+ and focus as well as during a drag. **The hit area and the mark are separate on
2214
+ purpose**: the area is the whole strip, so the grip is easy to catch, while the
2215
+ mark stays small enough that it cannot be read as a border and never meets the
2216
+ panel's corner radius.
2217
+
2218
+ Every grip has its own part (`resize-handle-left`, `resize-handle-bottom-right`,
2219
+ and so on, plus `resize-handle` on all of them). Hit areas and marks are sized
2220
+ separately, so a coarser pointer can get a bigger target without a heavier mark.
2221
+
2222
+ ```css
2223
+ ag-ui-chat {
2224
+ --ag-ui-grip-corner: 20px; /* the corner squares */
2225
+ --ag-ui-grip-edge: 10px; /* the edge strips */
2226
+ --ag-ui-grip-mark-length: 28px; /* the pill drawn inside them */
2227
+ --ag-ui-grip-mark-thickness: 3px;
2228
+ }
2229
+ ```
2230
+
2231
+ ---
2232
+
2233
+ ## Markdown rendering
2234
+
2235
+ Assistant bubbles render sanitized markdown/HTML via [`marked`](https://www.npmjs.com/package/marked)
2236
+ (GitHub-flavoured, single-newline line breaks) piped through
2237
+ [DOMPurify](https://www.npmjs.com/package/dompurify). User messages stay literal text. The
2238
+ allowlist permits emphasis, code, lists, quotes, headings, links, tables, and — when `allowImages`
2239
+ is set — images; links are hardened with `target="_blank" rel="noopener noreferrer"`;
2240
+ `iframe`/`style`/scripting are excluded, as are every `data-*` and `aria-*` attribute and every
2241
+ `class` but a code fence's `language-*` hint, so model output cannot dress itself up as the
2242
+ component's own approval or tool-call chrome. The exported helper `renderMarkdown(text)` does this
2243
+ standalone. `marked` and `dompurify`
2244
+ are runtime dependencies.
2245
+
2246
+ An animated 3-dot "thinking" indicator (`role="status"`, with an aria-label) appears before the
2247
+ first token and between tool rounds, honouring `prefers-reduced-motion`. It has no public API.
2248
+
2249
+ ### Incoming-text animations
2250
+
2251
+ The `data-text-animation` attribute controls how a fully-received assistant message reveals:
2252
+ `none` (default) / `fade` (a CSS fade) / `word` (JS word-by-word via the internal `wrapWords`
2253
+ reveal). It honours `prefers-reduced-motion` (collapsing to instant).
2254
+
2255
+ ```html
2256
+ <ag-ui-chat endpoint="/agent/" data-text-animation="word"></ag-ui-chat>
2257
+ ```
2258
+
2259
+ ---
2260
+
2261
+ ## Follow-up suggestions
2262
+
2263
+ The agent can offer the *next* question, not just answer this one. Registered
2264
+ skill chips are static and host-configured, so they can say "summarize this" but
2265
+ never "want me to update the shipping address too?" after a tool has run.
2266
+
2267
+ The server pushes an `ACTIVITY_SNAPSHOT` under the `suggestions` type — with
2268
+ django-ag-ui, `suggestions_activity([...])`. The component draws each prompt as
2269
+ a chip; clicking one sends it as the user's message, exactly as if they had
2270
+ typed it.
2271
+
2272
+ ```json
2273
+ { "activityType": "suggestions",
2274
+ "content": { "prompts": ["Update the shipping address too"] } }
2275
+ ```
2276
+
2277
+ **Chips are content.** They ride the same activity envelope as charts, so they
2278
+ persist and a reload puts them back, and a set pushed under an id already on
2279
+ screen replaces that row rather than adding a second one.
2280
+
2281
+ At most **4** prompts of **120** characters each — `MAX_SUGGESTIONS` and
2282
+ `MAX_SUGGESTION_CHARS`, both exported. The server-side helper *raises* past
2283
+ those bounds while this side silently drops: that asymmetry is deliberate, since
2284
+ the producer can report the problem and the client cannot.
2285
+
2286
+ Nothing to enable. A `suggestions` activity from a server that pushes one is
2287
+ drawn; an `activity_type` this component does not know is ignored, which is what
2288
+ the open field is for.
2289
+
2290
+ ## Editing a gated call before approving it
2291
+
2292
+ AG-UI's resume payload carries `editedArgs`, and the protocol gates it on the
2293
+ agent's own `approveWithEdits` capability. The approval card can offer it:
2294
+
2295
+ ```js
2296
+ chat.approveWithEdits = true; // your server accepts editedArgs
2297
+ ```
2298
+
2299
+ **Off by default, and an assertion about your server rather than a
2300
+ negotiation** — capabilities are not on the wire this component reads, so it
2301
+ cannot check. Turned on against a server that ignores `editedArgs`, a user would
2302
+ edit arguments it silently discards, which is worse than not offering.
2303
+
2304
+ The card then shows the call's arguments as editable JSON. `editedArgs` rides
2305
+ the resume payload **only when something actually changed**, so a server can tell
2306
+ "approved as proposed" from "approved, but like this" without diffing what it
2307
+ already sent. Unparseable JSON, or JSON that is not an object, keeps the card
2308
+ open with the reason on it rather than approving the original behind the user's
2309
+ back.
2310
+
2311
+ Only offered for an interrupt naming a tool call this component holds a card
2312
+ for — the card is where the arguments still are.
2313
+
2314
+ ## Localizing the timestamps
2315
+
2316
+ There is **no `Intl` anywhere in this component**. The relative timestamps in the
2317
+ thread drawer and checkpoint panel (`"5m ago"`, `"2d ago"`) are deliberately
2318
+ locale-neutral: a component that guessed a locale would disagree with the page
2319
+ it is embedded in, and being wrong in a second language is worse than being
2320
+ neutral in one.
2321
+
2322
+ That is a good default and a bad requirement, so it is replaceable:
2323
+
2324
+ ```js
2325
+ const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
2326
+ chat.formatRelativeTime = (ts) =>
2327
+ rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
2328
+ ```
2329
+
2330
+ `relativeTime` is exported too, for a host that wants to build on the built-in
2331
+ rather than replace it.
2332
+
2333
+ ## Message actions: copy, retry, feedback
2334
+
2335
+ Every finished assistant message carries a small row of actions beneath it —
2336
+ a **sibling** of the bubble, never a child, so the buttons never join the
2337
+ message's own text.
2338
+
2339
+ - **Retry** re-asks the question. History is truncated to the most recent user
2340
+ message inclusive and the run repeats, so the agent answers what it was asked
2341
+ rather than being told its last answer was wrong.
2342
+ - **Copy** puts the message's text on the clipboard, and says so on the button.
2343
+ A refused clipboard permission is reported there too, rather than thrown.
2344
+ - **Thumbs up / down** fire `ag-ui-feedback` (wired below) and **store nothing**.
2345
+ **Off unless you ask for them** — `data-message-actions="copy,retry,feedback"`.
2346
+ Without a listener the buttons still latch, so a reader is told a rating was
2347
+ taken while nothing recorded it.
2348
+
2349
+ Retry sits on the **last** answer only. Re-running an older turn is branching,
2350
+ and for a page-driving agent editing a past turn is not neutral — those turns
2351
+ clicked buttons, and re-running turn 3 does not un-save what turn 5 saved.
2352
+
2353
+ > [!NOTE]
2354
+ > A retried turn **re-runs its tools.** The previous attempt already did what it
2355
+ > did, and this does not undo it. Confirmation still applies, so a destructive
2356
+ > tool asks again — unless the user waived it for this session with
2357
+ > *Always allow*.
2358
+
2359
+ A failed run gets the same row, with Retry and Copy and no rating: error text is
2360
+ what people paste into a bug report, but "the connection dropped" is not a
2361
+ statement about answer quality and mixing it into feedback makes that signal say
2362
+ less. This is why a dropped connection is still rendered as an **error** rather
2363
+ than demoted to a run notice — a notice "never settles, takes no action, and
2364
+ carries no controls", and a failure with a way back needs one.
2365
+
2366
+ The row can be trimmed, or removed, with `data-message-actions` — a comma list of
2367
+ the actions to keep, or `="false"` for none at all:
2368
+
2369
+ ```html
2370
+ <!-- copy only: nothing here listens for a rating, and the surface forbids re-runs -->
2371
+ <ag-ui-chat endpoint="/agent/" data-message-actions="copy"></ag-ui-chat>
2372
+ ```
2373
+
2374
+ The default is `copy,retry`. Those two work with nothing wired; the rating pair
2375
+ needs a listener, so it is asked for rather than assumed.
2376
+
2377
+ ### What Copy puts on the clipboard
2378
+
2379
+ Both flavours of the message: `text/plain` for anywhere, and `text/html` for a
2380
+ target that understands it. A table therefore pastes into a spreadsheet as
2381
+ columns and into a document as a table.
2382
+
2383
+ The plain flavour is serialised structurally rather than read off `textContent`,
2384
+ which is the obvious source and loses everything: it concatenates descendants
2385
+ with no separator, so a table arrives as one unbroken run of cells with the
2386
+ headers welded to the first row. Rows are tab separated because that is what a
2387
+ spreadsheet splits on; list items keep their bullets and numbers; a code block
2388
+ keeps its own whitespace.
2389
+
2390
+ Both flavours are taken from a copy of the message with the component's own
2391
+ buttons removed. The code blocks' copy buttons live *inside* their `pre`, so
2392
+ they are descendants of the message: before this, copying an answer containing a
2393
+ code block copied the word Copy along with it.
2394
+
2395
+ A host that drives its own bar gets the same by passing `html` alongside `text`
2396
+ to `attachMessageActions`; with `text` alone the clipboard gets plain text only,
2397
+ exactly as before.
2398
+
2399
+ ### Sizing and theming the row
2400
+
2401
+ The controls are icon-only, so the whole box is the target. It is sized from
2402
+ `--ag-ui-action-size`, with a floor rather than a fixed height so the compact
2403
+ density cannot shrink it below the 24px that keeps it reliably tappable.
2404
+
2405
+ Each control draws its own label on hover **and on keyboard focus**. That is not
2406
+ the `title` attribute repeated: a `title` never appears on focus, so an
2407
+ icon-only button is unnamed for anyone tabbing to it. On a touch device, where
2408
+ there is no hover to reveal it and it would sit over the answer, it is not
2409
+ drawn at all.
2410
+
2411
+ ```css
2412
+ ag-ui-chat {
2413
+ --ag-ui-action-size: 28px; /* the control box; floors at 24px */
2414
+ --ag-ui-action-icon-size: 15px; /* the mark inside it */
2415
+ --ag-ui-tooltip-bg: #1f2430;
2416
+ --ag-ui-tooltip-fg: #f5f6fa;
2417
+ }
2418
+ ```
2419
+
2420
+ To swap a mark for your own, style its part — `message-action-icon-copy` and its
2421
+ siblings. A slot would be the better channel and cannot be used: these repeat
2422
+ once per message, and a named slot can only be filled once.
2423
+
2424
+ It is per-action rather than one switch because the three disappear for
2425
+ different reasons. Thumbs are only useful to a host listening for
2426
+ `ag-ui-feedback`, and two buttons that lead nowhere are worse than none. Retry
2427
+ re-runs the agent, which a constrained surface may not permit. Copy is the one
2428
+ nobody objects to — and with a single switch, dropping either of the others would
2429
+ have cost it too. Nothing survives, and no row is built at all: an empty row
2430
+ still takes its margin and still announces itself as a group of actions.
2431
+
2432
+ `retryLastTurn()` is public, for a host driving its own message UI.
2433
+
2434
+ ```js
2435
+ await chat.retryLastTurn(); // false when there is nothing to ask again, a run is in flight, or a restore is loading
2436
+ ```
2437
+
2438
+ ### Storing a rating
2439
+
2440
+ `ag-ui-feedback` names the message that was rated, so a host can file the
2441
+ rating against it rather than against its text — two answers can read the same,
2442
+ and nothing about the text survives a reload:
2443
+
2444
+ | Field | Type | What it is |
2445
+ | --- | --- | --- |
2446
+ | `messageId` | `string` | The AG-UI id of the rated assistant message: the id it streamed under (`TEXT_MESSAGE_START` / `TEXT_MESSAGE_END`), or the stored message's `id` when it was restored from history. A restored answer whose store returned no id offers no rating pair, since a rating has to name a message. |
2447
+ | `threadId` | `string` | The conversation it belongs to: the element's thread when the message was drawn. |
2448
+ | `runId` | `string`, optional | The run that streamed it, as that run's `RUN_STARTED` named it. Text a sub-agent streams through the parent run carries the parent's `runId`. **Absent on a restored message** — an AG-UI message carries no run id, so there is none to give and the key is left out rather than guessed. |
2449
+ | `content` | `string` | The message's text, as rendered. |
2450
+ | `rating` | `"up"` \| `"down"` | Which button. |
2451
+
2452
+ ```js
2453
+ chat.setAttribute("data-message-actions", "copy,retry,feedback");
2454
+ chat.addEventListener("ag-ui-feedback", (e) => {
2455
+ const { messageId, threadId, runId, rating } = e.detail;
2456
+ // Keyed by message: rating the same answer again replaces the earlier rating.
2457
+ fetch(`/api/threads/${threadId}/ratings/${messageId}/`, {
2458
+ method: "PUT",
2459
+ headers: { "Content-Type": "application/json" },
2460
+ body: JSON.stringify({ rating, ...(runId === undefined ? {} : { runId }) }),
2461
+ });
2462
+ });
2463
+ ```
2464
+
2465
+ > [!NOTE]
2466
+ > **A restored message is named by whatever `id` the conversation store returns
2467
+ > for it.** The built-in browser store saves the conversation as it streamed, so
2468
+ > there a message keeps its id across a reload. With `data-threads-url` or a
2469
+ > `conversationStore` of your own, the conversation comes back from your server,
2470
+ > and a message keeps its id only if the server persists the ids it streamed:
2471
+ > one that stores the conversation under fresh ids makes a rating made live and a
2472
+ > rating made after a reload name the same answer by two different ids. Live,
2473
+ > the id is always the one on the wire.
2474
+
2475
+ ## Quoting a selection
2476
+
2477
+ Select any text in the transcript and a small **Quote** offer floats beside it.
2478
+ Taking it drops the selection into the composer as a markdown blockquote and
2479
+ leaves the caret on a fresh line under it — a quotation is how a question
2480
+ narrows to one part of an answer, so nothing is sent until you say what you are
2481
+ asking.
2482
+
2483
+ Quoting **appends**, after whatever is already typed, so a second quotation is a
2484
+ second thing being asked about rather than a replacement for the first. Long
2485
+ selections are capped at 500 characters: select-all-then-quote is a gesture the
2486
+ transcript already answers, and pasting the whole conversation back costs tokens
2487
+ to say nothing.
2488
+
2489
+ Set `data-quote-selection="false"` to turn the offer off. The `quote-selection`
2490
+ `part` styles it.
2491
+
2492
+ ### The half that matters: selection in **your** page
2493
+
2494
+ The transcript is the easy half. A chat mounted beside a table, a diff or a
2495
+ report is sitting in the surface the user actually works in — and *that*
2496
+ selection is one no hosted chat can reach.
2497
+
2498
+ `offerQuoteInPage()` extends the same select-then-offer gesture to the whole
2499
+ page. It is opt-in, because it listens on your document:
2500
+
2501
+ ```js
2502
+ const stop = chat.offerQuoteInPage(); // the whole page
2503
+ chat.offerQuoteInPage(document.querySelector("#report")); // or one region
2504
+ ```
2505
+
2506
+ For a deliberate trigger instead of a selection, `quote(text)` is the seam
2507
+ underneath:
2508
+
2509
+ ```js
2510
+ // "Ask about this row" — a button on each row of your own table.
2511
+ row.querySelector(".ask").addEventListener("click", () => {
2512
+ chat.quote(row.innerText);
2513
+ });
2514
+ ```
2515
+
2516
+ `quote()` never sends — pair it with [`sendMessage()`](#sending-from-your-own-ui)
2517
+ if you want a one-click "explain this" that skips the composer entirely.
2518
+
2519
+ > **Do not write the four-line version of `offerQuoteInPage()`.**
2520
+ > A `mouseup` listener that quotes every settled selection appends to the
2521
+ > composer on every drag the user made to *read*, to copy, or to fix a typo —
2522
+ > and it cannot tell a selection in your prose from one inside the user's own
2523
+ > half-typed `<input>`, because Chrome reports a field's internal selection
2524
+ > through `document.getSelection()` as an ordinary range over the field's
2525
+ > **wrapper**. The text reads back perfectly and nothing about the range says
2526
+ > where it came from; the only signal is `document.activeElement`. That guard,
2527
+ > plus skipping the widget's own transcript, plus retiring a fixed-position
2528
+ > affordance on scroll, is what the method is for.
2529
+
2530
+ > **Reading a selection out of a shadow tree takes care too.**
2531
+ > Engines disagree about what `document.getSelection()` reports for a selection
2532
+ > made *inside* a shadow root: WebKit rescopes the endpoints to the host element,
2533
+ > so you get the whole widget and none of the words, while Chromium hands back
2534
+ > the shadow nodes directly. `getComposedRanges` settles it, and this component
2535
+ > uses it where it exists. `quotableSelection(container, roots)` is exported if
2536
+ > you have the same problem in your own component.
2537
+
2538
+ ## Run notices: compaction and agent skills
2539
+
2540
+ Some things a run does are neither text nor a tool the user asked for — the server condensed
2541
+ earlier turns to fit the context window, or the model pulled in an agent skill. Those render as
2542
+ **run notices**: a muted one-line annotation inline in the transcript, styleable via the
2543
+ `run-notice`, `run-notice-icon` and `run-notice-text` `part`s.
2544
+
2545
+ > **Two different things are called "skills".**
2546
+ > The [prompt chips and slash palette](#skills-prompt-chips-and-slash-palette) below are a
2547
+ > **human** affordance — prompts *the user* launches. An **agent skill** is a folder of
2548
+ > instructions *the model* chooses to load mid-run. Only the second produces a run notice.
2549
+
2550
+ Neither notice needs configuration here — both appear when the server is set up to produce them.
2551
+
2552
+ **Compaction.** `django-ag-ui` emits a standard AG-UI `ACTIVITY_SNAPSHOT` with
2553
+ `activityType: "compaction"` when a compaction capability trimmed the history; the notice reports
2554
+ how many messages went. Server side, that means wrapping the capability in `CompactionObserver` —
2555
+ see [django-ag-ui's compaction guide](https://artui.github.io/django-ag-ui/compaction/). Activity
2556
+ events of any other type pass through untouched, so another producer on that channel is not
2557
+ mistaken for a compaction.
2558
+
2559
+ **Agent skills.** There is no dedicated event for these: loading a deferred capability *is* an
2560
+ ordinary `load_capability` tool call, which is what reaches the client. The component recognises
2561
+ it, renders `Using skill <id>`, and suppresses the raw tool card that would otherwise appear
2562
+ beside it — on the live stream and on restored history alike, so a reload shows the same
2563
+ transcript. A `load_capability` call with no usable id falls back to a normal tool card rather
2564
+ than being dropped, since it is still real activity.
2565
+
2566
+ Both strings are overridable like every other — `historyCompacted` (token `{count}`) and
2567
+ `usingSkill` (token `{name}`).
2568
+
2569
+ ## Skills: prompt chips and slash palette
2570
+
2571
+ Skills are pre-defined prompts the user can launch from a chip or the `/`-command palette. They are
2572
+ opt-in via two attributes:
2573
+
2574
+ ```html
2575
+ <ag-ui-chat endpoint="/agent/" data-prompt-chips="true" data-slash-commands="true"></ag-ui-chat>
2576
+ ```
2577
+
2578
+ A `Skill` is `{ name, title, description?, prompt?, sendImmediately?, chip? }`. Skills are merged
2579
+ from three sources — **backend → embed → client** (later wins by `name`):
2580
+
2581
+ - `data-skills-url` — a JSON endpoint, fetched with the element's `headers`.
2582
+ - `data-skills` — an inline JSON catalog.
2583
+ - `setSkills(skills)` — set the client catalog from JS.
2584
+
2585
+ ```js
2586
+ chat.setSkills([
2587
+ // Server-resolved: no prompt here, so picking it sends the bare "/triage"
2588
+ // token and the agent decides what it means.
2589
+ { name: "triage", title: "Triage this", chip: true },
2590
+ // Client-side: the page owns the wording and fills the placeholders.
2591
+ { name: "summarize", title: "Summarize page", prompt: "Summarize {title}.", chip: true },
2592
+ ]);
2593
+ ```
2594
+
2595
+ **Prefer omitting `prompt` for anything internal.** A skill is often where a project's workflow is
2596
+ written down most plainly, and a catalog is either a plain `GET` or sits in the page source — so
2597
+ shipping the wording to the browser publishes it. Without a `prompt` the component sends `/name`
2598
+ and the agent expands it (from the harness `Skills` capability, or your own instructions); the text
2599
+ never leaves the server. `django-ag-ui`'s `SkillRegistry` supports this by leaving `prompt` unset.
2600
+
2601
+ A skill that *does* carry a `prompt` may use `{placeholder}` tokens; the `skillContext` property
2602
+ (`() => Record<string, unknown>`) supplies the values, filled in before send. A missing placeholder
2603
+ blocks the send and shows a hint instead.
2604
+
2605
+ ```js
2606
+ chat.skillContext = () => ({ title: document.title });
2607
+ ```
2608
+
2609
+ **Picking a skill sends it.** A chip that needs a second click to do anything is a two-step
2610
+ shortcut. Set `sendImmediately: false` on a prompt-carrying skill to pre-fill the composer instead —
2611
+ useful when the user is expected to edit before sending. A server-resolved skill always sends.
2612
+
2613
+ ---
2614
+
2615
+ ## MPA durability: surviving full page reloads
2616
+
2617
+ In a multi-page app, a tool that navigates reloads the whole page and destroys the in-memory run
2618
+ loop. The package keeps the conversation continuous across that boundary with three generic
2619
+ mechanisms.
2620
+
2621
+ **1. Thread identity.** AG-UI's `thread_id` is the conversation key. It is generated once and
2622
+ persisted (so the element reattaches after a reload) by the
2623
+ [`ClientConversationStore`](src/core/conversation_store.ts).
2624
+
2625
+ **2. Durable conversation.** A pluggable `ClientConversationStore` holds the message list. The
2626
+ default [`SessionStorageStore`](src/core/conversation_store.ts) keeps everything per-tab in
2627
+ `sessionStorage`, so the chat survives full page reloads and clears on tab close. `loadMessages`
2628
+ is async-friendly, so a host can inject a server-backed store (e.g. one that rehydrates from a
2629
+ history endpoint) for cross-tab/device durability:
2630
+
2631
+ ```js
2632
+ chat.conversationStore = new MyServerBackedStore();
2633
+ ```
2634
+
2635
+ On mount the element rehydrates the transcript from the store, so the chat looks continuous —
2636
+ including tool-call cards and their results (reconstructed from the persisted `toolCalls` and `tool`
2637
+ messages), not just the text turns.
2638
+
2639
+ A tool message is saved with an **`outcome`** in its `metadata` when the call did not simply
2640
+ succeed, in the same vocabulary the wire uses (see [A tool call that failed](#a-tool-call-that-failed)),
2641
+ so a failed or declined card replays as failed or declined instead of turning green on reload. A user
2642
+ message keeps the refs of its uploads the same way, under `attachments` in its `metadata`. Both
2643
+ round-trip through `JSON.stringify` in the default store. **A store that drops `metadata` loses only
2644
+ the distinction** — the card falls back to *done*, which is what it did before, and the bubble loses
2645
+ its chips.
2646
+
2647
+ What is saved is exactly what the next request sends, so both go back to the server on every run.
2648
+ That is the protocol's own behaviour for a declared field rather than a client-side addition: a server
2649
+ validates `metadata` as an open object, and one that does not read a key ignores it — django-ag-ui
2650
+ reads `attachments` and ignores `outcome`.
2651
+
2652
+ A conversation stored by an earlier release has both keys at the top level of the message instead.
2653
+ The history replay reads them from there when the metadata has none, and a client seeded from such a
2654
+ conversation moves them into `metadata` before its first request, because `@ag-ui/client` 1.0 would
2655
+ strip them from the request as it stands. The next save writes them in the new shape.
2656
+
2657
+ **3. Resumable loop (`x-navigates` + `navigationResult`).** A tool whose schema carries
2658
+ `x-navigates: true` (use `X_NAVIGATES_KEY`; read back by [`isNavigates`](src/tools/is_navigates.ts))
2659
+ triggers a full reload. Before the handler navigates, the element writes a checkpoint
2660
+ (`{ toolCallId }`) to the store. On the next page mount it:
2661
+
2662
+ 1. restores the transcript,
2663
+ 2. completes the dangling navigating tool call by supplying a result built from the landed page
2664
+ via the overridable **`navigationResult(checkpoint)`** callback (defaults to
2665
+ `{ navigated: true, url }`; a host can return a page snapshot or post-reload validation errors
2666
+ instead),
2667
+ 3. and resumes the run loop from there.
2668
+
2669
+ The MPA round-trip becomes a clean observation point instead of a dropped conversation.
2670
+
2671
+ **A reload the run did not expect settles every open call as not finished.** A round's history is
2672
+ saved when its stream ends, before the element asks about a gated call, runs a frontend tool or
2673
+ collects a server-side approval, so a reload in that window finds the round's calls with no result
2674
+ and no run left to produce one. On restore, every call without a result (other than a checkpointed
2675
+ navigating call) is answered the way the client answers an open call before a request: its card
2676
+ settles as `interrupted` ("not finished"), and the restored conversation gains the `callNotFinished`
2677
+ result with the outcome `interrupted`, so the next request carries a result for every call and a
2678
+ later reload shows the same.
2679
+
2680
+ It is not declined, although Stop declines an open card. Pressing Stop is a person answering the
2681
+ question; a reload answers nothing. The saved history is the same whether the round was waiting on a
2682
+ person or on a frontend tool the reload killed, so a decline would be unproven for one and false for
2683
+ the other, and "not finished" is true of both. The same goes for a server-side approval left open.
2684
+
2685
+ ### Who the stored conversation belongs to (`user-key`)
2686
+
2687
+ `sessionStorage` is scoped to a tab, not to a session. It survives every same-tab navigation,
2688
+ and a logout is a navigation — so on a shared workstation, one user's transcript is still sitting
2689
+ there when the next user signs in and the chat mounts again. Transcripts routinely quote record
2690
+ data, so treat that as the default and turn it off:
2691
+
2692
+ ```html
2693
+ <ag-ui-chat endpoint="/agent/" user-key="{{ request.user.pk }}"></ag-ui-chat>
2694
+ ```
2695
+
2696
+ The value is any string that identifies the principal — a user id, an account id, a hash of one.
2697
+ It joins the storage namespace, so two principals in the same tab cannot reach each other's
2698
+ conversation, and **changing it purges everything the previous principal stored**: transcript,
2699
+ history drawer index and navigation checkpoints, for this element's namespace only. The tools they
2700
+ waived with *Always allow* are forgotten with it, so the next principal is asked again, and so is
2701
+ [`sharedState`](#host-seams-the-spa-story): whatever the agent last wrote into it for them is cleared rather than
2702
+ sent on the next principal's first run, so a host that seeds shared state assigns it again for the
2703
+ principal who arrived.
2704
+
2705
+ A handover also ends what the previous principal had in flight. Their run is stopped, and nothing
2706
+ it does afterwards is written into the next principal's storage or shared state: not the save a
2707
+ stopped run makes once its request closes, not a tool result that comes back after the Stop, not
2708
+ state already on its way. A tool call still waiting on `confirmPredicate` or its confirmation card
2709
+ does not run, one whose handler is still running draws nothing when it returns, and a navigating
2710
+ tool's checkpoint is never written into the next principal's thread. What they had typed into the
2711
+ composer and not sent is cleared with the transcript, quotations and skill templates included. A
2712
+ voice recording in progress is stopped before it is transcribed, and a clip already sent for
2713
+ transcription is dropped when its transcript comes back.
2714
+
2715
+ Set it live, from script, as part of signing out or in:
2716
+
2717
+ ```js
2718
+ chat.userKey = String(session.userId); // or "" on sign-out
2719
+ ```
2720
+
2721
+ That is why it is a live attribute rather than a connect-time one. A single-page app signs a user
2722
+ out through its own router without remounting anything, so the host naming the new principal — or
2723
+ dropping the attribute — is the only signal the element will ever get. Removing the attribute
2724
+ purges too, so a sign-out that simply clears it is safe.
2725
+
2726
+ It holds for an element that is out of the document too, such as a view a router keeps alive.
2727
+ Nothing is purged while it is detached: the handover runs when it is inserted again, against the
2728
+ key it has by then, so a key that changes and changes back while detached hands nothing over. An
2729
+ element that is never inserted again leaves the previous principal's conversation in
2730
+ `sessionStorage` under their key, where only an element naming that principal again can reach it.
2731
+
2732
+ The **first** value to arrive is treated as a host naming the user who was already there, not as a
2733
+ handover: the conversation in progress moves into the principal's namespace instead of being
2734
+ destroyed. So an element configured by an async auth handshake — the shape described in
2735
+ [Framework hosts](#framework-hosts-configure-before-you-insert) — keeps what is on screen,
2736
+ including a run still streaming and whatever is in the composer.
2737
+
2738
+ Two things it deliberately does not do. It does not scope the panel's own collapsed / dragged-size
2739
+ / theme preferences, which are this element's UI state and carry no conversation content. And it
2740
+ does not encrypt or hide anything from the page: any script on the origin can still read
2741
+ `sessionStorage`. It scopes and it purges.
2742
+
2743
+ **Without it, nothing changes** — including the carry-over above. A conversation is scoped to the
2744
+ element and to nobody in particular, and on a shared workstation it will be there for whoever signs
2745
+ in next in the same tab.
2746
+
2747
+ For a deployment that keeps history server-side, `data-threads-cache="false"` stops the local
2748
+ mirror of the message bodies as well, so choosing `data-threads-url` actually keeps transcripts off
2749
+ the client:
2750
+
2751
+ ```html
2752
+ <ag-ui-chat endpoint="/agent/" data-threads-url="/agent/threads/" data-threads-cache="false">
2753
+ </ag-ui-chat>
2754
+ ```
2755
+
2756
+ The client-only concerns (the active thread id, the navigation checkpoint) keep their local store
2757
+ either way, so reloads and navigating tools still work. What is lost is the offline fallback: when
2758
+ the thread endpoint is unreachable the transcript comes back empty rather than stale, and the
2759
+ drawer's offline list loses its previews — a preview being an excerpt of a message, which is the
2760
+ thing being kept off the client. Constructing the store yourself takes the same option:
2761
+
2762
+ ```js
2763
+ chat.conversationStore = new RemoteConversationStore(
2764
+ "/agent/threads/",
2765
+ () => ({ "X-CSRFToken": token }),
2766
+ new SessionStorageStore(),
2767
+ () => "same-origin",
2768
+ false, // cacheMessages
2769
+ );
2770
+ ```
2771
+
2772
+ A conversation store you assign yourself is never replaced by a change of `user-key`, whether you
2773
+ assign it before the element connects or after. The one exception is a plain `SessionStorageStore`
2774
+ assigned before the element connects: that is the same kind as the element's own default, which is
2775
+ all connecting can see, so the element namespaces it as its own and moves it on every key change.
2776
+ Assign it after connecting, or wrap it, to keep it yours. A store that keeps its data somewhere
2777
+ the element cannot see has to scope itself; the element purges only its own `sessionStorage`
2778
+ namespace and clears what is on screen. If your store wraps a `SessionStorageStore`, build a new
2779
+ one per principal.
2780
+
2781
+ `SessionStorageStore.purge(namespace)` is the same primitive the element uses, for a host driving
2782
+ its own store from its own sign-out path.
2783
+
2784
+ ### Mounting more than one chat on a page
2785
+
2786
+ Give each `<ag-ui-chat>` its own `id`. The storage namespace is the element's `id`, falling back to
2787
+ its `endpoint` — so two elements with no `id` against the same agent mount (a docked support panel
2788
+ and an inline page assistant, say) would resolve to the same namespace and share a thread pointer,
2789
+ a history drawer and every message key.
2790
+
2791
+ They no longer do: the first element to mount keeps the namespace, and a second is given a
2792
+ throwaway one of its own plus a console warning. That keeps the two conversations apart, but the
2793
+ throwaway namespace is minted per mount, so the second element will not restore its conversation
2794
+ across a reload until it has an `id`.
2795
+
2796
+ ---
2797
+
2798
+ ## Host seams: the SPA story
2799
+
2800
+ `<ag-ui-chat>` is a generic embedding kit; these typed seams let a host feed the agent richer
2801
+ context up front so it explores less. All are framework-free and admin-agnostic.
2802
+
2803
+ **`routeMap: RouteMap`** — a manifest of navigable routes (`{ id, path, title?, group?,
2804
+ description? }`). When set, the element exposes two built-in tools so the agent navigates by intent
2805
+ rather than by exploring:
2806
+
2807
+ - `list_routes` — read-only; lists the routes.
2808
+ - `navigate_to_route(route_id, params?)` — resolves the id to a path and navigates.
2809
+
2810
+ A route `path` may contain `:param` segments — e.g. `/projects/:id/users/:userId/`.
2811
+ `navigate_to_route` substitutes the path params (URL-encoded) and sends any leftover params as a
2812
+ query string; a missing or empty required path param throws. The resolved shape is the exported
2813
+ `RouteWithParams` type.
2814
+
2815
+ ```js
2816
+ chat.routeMap = [
2817
+ { id: "users", path: "/users", title: "Users", description: "Manage user accounts" },
2818
+ { id: "billing", path: "/billing", title: "Billing" },
2819
+ { id: "user-detail", path: "/projects/:id/users/:userId/", title: "User detail" },
2820
+ ];
2821
+ ```
2822
+
2823
+ **`getPageMap(): PageMap`** — a per-run provider returning the current page's compact actionable
2824
+ surface (field names/types/labels, button labels+handles — *not* values). It is auto-injected into
2825
+ each run's `context` as a `page_map` entry (toggle with `autoInjectPageMap`):
2826
+
2827
+ ```js
2828
+ chat.getPageMap = () => ({ fields: introspectForm(), buttons: visibleButtons() });
2829
+ ```
2830
+
2831
+ It is recomputed at the top of **every tool round**, not once per `send()` — so after the agent
2832
+ acts, the next round already sees the resulting page. Within a round the agent can pull a fresh
2833
+ view at any time with the built-in `read_page` tool, which is registered whenever this provider is
2834
+ set.
2835
+
2836
+ **Check that your server reads `context` at all — pydantic-ai's AG-UI adapter does not.** The
2837
+ auto-injected `page_map` rides in `RunAgentInput.context`, and an adapter that ignores that field
2838
+ drops it silently: nothing errors, and the model simply never sees the page. On such a backend
2839
+ `read_page` is the channel that works, and it is the one to rely on. Nothing to configure — just do
2840
+ not assume the injected copy arrived, and if the page map matters to your prompt, put it there
2841
+ server-side or let the agent call `read_page`.
2842
+
2843
+ That leaves one window: the page can move *after* a round's context was built but *before* the
2844
+ agent's tool call arrives — the user clicks a link, or presses back. Calls landing in that window
2845
+ are **refused** with a result telling the agent to call `read_page` and retry. Most would have
2846
+ missed anyway; the guard exists for the case where a same-named control on the new page matches and
2847
+ the agent would otherwise act on the wrong page without either side noticing. `read_page` and tools
2848
+ marked `x-navigates` are exempt. The guard runs in any round that offered `read_page`, including
2849
+ one you registered yourself, and is inert in a round that did not (no `getPageMap`, or a
2850
+ [place the user withheld](#where-the-user-is-the-place-chip)), since its result would name a tool
2851
+ the agent does not have.
2852
+
2853
+ **`registerPageState({ name, read, write?, schema? })`** — ergonomic sugar over `registerTool` for
2854
+ SPA app state (Redux/Zustand/signals). It auto-generates a `read_<name>` (read-only) tool and, when
2855
+ `write` is supplied, a `set_<name>` tool stamped `x-destructive`:
2856
+
2857
+ ```js
2858
+ chat.registerPageState({
2859
+ name: "cart",
2860
+ read: () => store.getState().cart,
2861
+ write: ({ items }) => store.dispatch(setCart(items)),
2862
+ schema: { type: "object", properties: { items: { type: "array" } } },
2863
+ });
2864
+ ```
2865
+
2866
+ **This is not AG-UI shared state** — that is `sharedState`, below. `registerPageState` generates two
2867
+ ordinary client tools; the agent reads or writes your store by *calling a tool*.
2868
+
2869
+ **`sharedState`** *(property)* — AG-UI **shared state**: the protocol's own state channel, sent as
2870
+ `RunAgentInput.state` on every run and replaced in place when the server streams `STATE_SNAPSHOT` /
2871
+ `STATE_DELTA`. Assign to seed it, listen for `ag-ui-state` to react:
2872
+
2873
+ ```js
2874
+ chat.sharedState = { document: "" };
2875
+
2876
+ chat.addEventListener("ag-ui-state", (e) => {
2877
+ editor.value = e.detail.state.document; // the agent rewrote it
2878
+ });
2879
+ ```
2880
+
2881
+ Server-side, a tool mutates `ctx.deps.state` and returns the snapshot as `ToolReturn` metadata —
2882
+ pydantic-ai does not emit deltas for you:
2883
+
2884
+ ```python
2885
+ @tool(registry)
2886
+ async def write_document(ctx: RunContext[AgentDeps], body: str) -> ToolReturn:
2887
+ """Replace the shared document."""
2888
+ ctx.deps.state = {**(ctx.deps.state or {}), "document": body}
2889
+ return ToolReturn(
2890
+ return_value="written",
2891
+ metadata=[StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=ctx.deps.state)],
2892
+ )
2893
+ ```
2894
+
2895
+ Use this when the agent and the page are editing **the same object** (a document, a form, a
2896
+ canvas). Because it is the page's object rather than the conversation's, **New chat keeps it**; a
2897
+ change of [`user-key`](#who-the-stored-conversation-belongs-to-user-key) clears it. Use
2898
+ `registerPageState` when the agent should *ask* for a value or *request* a change —
2899
+ the tool call is visible in the transcript and can be gated by a confirmation card, which state
2900
+ events cannot.
2901
+
2902
+ **`navigate(path): void`** *(optional)* — a host routing callback. **This single seam is what
2903
+ distinguishes an SPA from an MPA.** When set, `navigate_to_route` routes client-side (no reload) and
2904
+ the in-memory run loop simply continues — the whole resumable-loop / checkpoint machinery is
2905
+ bypassed. When unset, navigation falls back to `window.location` and the MPA reload model above
2906
+ applies.
2907
+
2908
+ ```js
2909
+ chat.navigate = (path) => router.push(path); // SPA: in-page, no reload
2910
+ // leave unset for an MPA: window.location + checkpoint/resume
2911
+ ```
2912
+
2913
+ Route map + `navigate()` and the reload model are the same feature seen from two ends.
2914
+
2915
+ **`ag-ui-run-finished`** *(event)* — an interaction has ended, and here is what ran in it.
2916
+ `detail: { threadId, tools: readonly ToolRun[], invalidated, outcome }` (typed `RunFinishedDetail`),
2917
+ where each `ToolRun` is `{ name, side: "server" | "client" }` in settle order. `threadId` is the
2918
+ thread the run belonged to, which is not always the one on screen: a run that a new chat or a
2919
+ thread switch stopped still reports once its request closes. `outcome` is how the interaction
2920
+ ended: `"completed"`, `"stopped"` (Stop, a new chat or a thread switch cut it off) or `"failed"`
2921
+ (the agent reported an error). Ignore an `outcome` you do not know: more may be added.
2922
+
2923
+ **This is the seam for a host that renders data the agent can change.** A server-side tool writes
2924
+ without your page's knowledge: nothing else the element dispatches implies "something may have moved
2925
+ underneath you", so a page that fetched its data on mount has no reason to refetch and quietly goes
2926
+ stale. Approve a server-side booking on a calendar and the row exists while the calendar keeps
2927
+ showing the week it loaded.
2928
+
2929
+ ```js
2930
+ chat.addEventListener("ag-ui-run-finished", (e) => {
2931
+ // A "client" tool ran in your own handler, so you already know what it did.
2932
+ if (e.detail.tools.some((tool) => tool.side === "server")) {
2933
+ void refetchBoard();
2934
+ }
2935
+ });
2936
+ ```
2937
+
2938
+ It fires **once per interaction**, not once per tool round, and it fires on completion, error and
2939
+ cancellation alike — a partial write is still a write. A capability load (an agent skill activating)
2940
+ is not counted: it moves nothing a host renders.
2941
+
2942
+ A run a switch stopped reports `outcome: "stopped"` whichever thread is on screen when it does,
2943
+ so a listener that marks attention or counts completions tells a cancelled run's late event from a
2944
+ finished one by `outcome`, not by `threadId`.
2945
+
2946
+ **Deferring a switch until the run is over** keys on `running` and `data-running`, never on this
2947
+ event: it fires for a stopped run too, late, and a stale one can arrive while the next run is live.
2948
+ `data-running` toggles only for the live run, and there is no running-change event, so watch the
2949
+ attribute:
2950
+
2951
+ ```js
2952
+ let queued = null;
2953
+ const release = () => {
2954
+ if (!chat.running && queued !== null) {
2955
+ const id = queued;
2956
+ queued = null;
2957
+ void chat.switchThread(id);
2958
+ }
2959
+ };
2960
+ new MutationObserver(release).observe(chat, { attributes: true, attributeFilter: ["data-running"] });
2961
+ myBar.onPick = (id) => {
2962
+ if (chat.running) {
2963
+ queued = id;
2964
+ } else {
2965
+ void chat.switchThread(id);
2966
+ }
2967
+ };
2968
+ ```
2969
+
2970
+ `sharedState` above is the richer channel and this is not a replacement for it — but it is not a
2971
+ substitute the other way round either, because shared state requires the *agent* to emit
2972
+ `STATE_SNAPSHOT`, which is not the host's decision to make. Use state when the two ends edit one
2973
+ object; use this when your page owns the data and just needs to know it moved.
2974
+
2975
+ **`ag-ui-custom`** *(event)* — the agent sent an AG-UI `CUSTOM` event.
2976
+ `detail: { name, value }` (typed `CustomAgentDetail`), both verbatim and uninterpreted.
2977
+
2978
+ ```js
2979
+ chat.addEventListener("ag-ui-custom", (e) => {
2980
+ if (e.detail.name === "invalidate") {
2981
+ void refetch(e.detail.value);
2982
+ }
2983
+ // Any other name: no listener, nothing happens. That is the intended outcome.
2984
+ });
2985
+ ```
2986
+
2987
+ `CUSTOM` is one of exactly two AG-UI carriers whose payload name is an open
2988
+ string the protocol does not enumerate, and it is the **imperative** one:
2989
+ something for your page to *do*. Its sibling `ACTIVITY_SNAPSHOT` carries
2990
+ transcript **content**, which is why an activity is materialised into a message,
2991
+ persisted with the thread and replayed on restore, and this is not.
2992
+
2993
+ **That asymmetry is the rule for choosing between them.** Content has a place in
2994
+ the conversation and should replay. An imperative has no place and no meaning
2995
+ once acted on — replaying "refetch the board" on every thread load is a bug, not
2996
+ a feature. If it must survive a reload, it belongs on the other carrier.
2997
+
2998
+ The element takes no view of what a name means and forwards every one, so a name
2999
+ it has never heard of reaches you unchanged. A host with no listener for a name
3000
+ simply ignores it, which is the graceful outcome an open field exists for.
3001
+
3002
+ **`ag-ui-invalidate`** *(event)* — the agent named resources its write moved.
3003
+ `detail: { keys, reason }` (typed `InvalidateDetail`).
3004
+
3005
+ One `CUSTOM` name routed to its own event so you do not have to string-match;
3006
+ every other name still arrives as `ag-ui-custom`. It fires **as each
3007
+ announcement arrives**, during the run, and the same keys ride
3008
+ `ag-ui-run-finished` again at the end as `invalidated`, de-duplicated.
3009
+
3010
+ > **Do not reload the page on this.** The user was probably typing. An
3011
+ > agent-triggered reload or a blind refetch into a live form destroys unsaved
3012
+ > input, and from their side the page threw their work away on its own. Check
3013
+ > first, and offer rather than act:
3014
+
3015
+ ```js
3016
+ chat.addEventListener("ag-ui-invalidate", (e) => {
3017
+ if (formIsDirty()) {
3018
+ showBanner("This data changed. Refresh when you're ready.", e.detail.keys);
3019
+ return;
3020
+ }
3021
+ refetch(e.detail.keys); // e.detail.keys → ["orders", "orders/42"]
3022
+ });
3023
+ ```
3024
+
3025
+ **Keys are opaque and matching is exact.** `orders/42` does not imply `orders` —
3026
+ a prefix rule would be the component guessing at a scheme it does not own, and
3027
+ `orders/1` would match `orders/11`. A server that wants the collection refreshed
3028
+ names it. Your own matching may be hierarchical, because in your vocabulary the
3029
+ scheme is known; that is what TanStack query keys are built for.
3030
+
3031
+ Already listening on `ag-ui-run-finished`? Upgrading is one line, and the `else`
3032
+ is the whole compatibility story:
3033
+
3034
+ ```js
3035
+ if (detail.invalidated.length > 0) refetchOnly(detail.invalidated);
3036
+ else if (detail.tools.some((t) => t.side === "server")) refetchEverything();
3037
+ ```
3038
+
3039
+ | Server | Client | Result |
3040
+ | --- | --- | --- |
3041
+ | old | old | coarse refetch, as today |
3042
+ | new | old | the `CUSTOM` event is ignored; coarse refetch still fires |
3043
+ | old | new | `invalidated` is empty; the `else` branch runs |
3044
+ | new | new | precise, and live during the run |
3045
+
3046
+ Nothing negotiates and nothing handshakes, which is what makes this shippable
3047
+ across repos with independent release cadences.
3048
+
3049
+ **Note:** it reaches **the page that started the run**, during the run. There is one
3050
+ response stream per run and no channel to anybody else's browser.
3051
+
3052
+ ### Where the user is: the place chip
3053
+
3054
+ The composer can say where in your app the user is writing from: a chip in its tool row, between the
3055
+ attach and voice controls and Send, naming the page, tab or record on screen. The same read is sent
3056
+ to the agent as one context entry, so the place the user sees is the place the agent is told.
3057
+
3058
+ **`getPlace: () => Place | null`** returns a `Place`: `{ label, detail?, icon?, href? }`. The chip
3059
+ draws the label, the detail after a dot, and the icon (an image URL; a pin when absent). The agent
3060
+ gets a `place` context entry whose value is the JSON of the label, detail and href; the icon is
3061
+ never sent. The href identifies the place; without one, the label and detail do.
3062
+
3063
+ ```js
3064
+ chat.getPlace = () => ({
3065
+ label: `Order #${order.number}`,
3066
+ detail: "Orders",
3067
+ href: location.pathname,
3068
+ });
3069
+ ```
3070
+
3071
+ - **With no `getPlace`, a route map stands in.** The `routeMap` route whose path matches
3072
+ `location.pathname` names the place: its `title` (else its `id`) as the label, its `group` as the
3073
+ detail, and the path as the href. A host with a route map gets the chip without writing anything.
3074
+ With neither, no chip is drawn and nothing is sent; set `getPlace = () => null` to opt a
3075
+ route-mapped host out.
3076
+ - **When it is read.** Before every tool round, so an agent that navigates mid-run is told the new
3077
+ page on its next round; and on connect, on expand, when the composer gains focus, on `popstate`
3078
+ and `hashchange`, and when the conversation on screen changes (`switchThread`, the drawer,
3079
+ `newChat`): the chip always names the live page, never the page a thread started on. Between runs
3080
+ it shows the last read. A single-page app calls **`refreshPlace()`** after its router moves,
3081
+ since `pushState` fires no event.
3082
+ - **The entry rides after your `getContext`.** It is appended to whatever `getContext` returns, so a
3083
+ host that replaces `getContext` still sends it.
3084
+ - **The eye toggle withholds it.** Pressing it leaves the chip in place, dashed and struck through
3085
+ (`data-excluded` on the `place` part), and the next round sends no `place` entry. It withholds the
3086
+ page too: that round also drops the `page_map` entry, whatever `getContext` returned, and does not
3087
+ offer `read_page`. Tools you register yourself, such as `registerPageState`'s, stay as you
3088
+ registered them. The choice holds while the same place is read, lapses when a different place is
3089
+ read, is not persisted, and cannot be lifted by the host.
3090
+ - **Placement.** The same position in every placement, on a phone and in the greeting layout; the
3091
+ label truncates with an ellipsis before it can push Send off the row, and the chip's title
3092
+ carries it in full. Nothing shows while the panel is collapsed. The chip is separate from the
3093
+ `above-composer` slot, and filling the slot never hides it.
3094
+ - **Styling and words.** Parts `place`, `place-icon`, `place-label`, `place-detail` and
3095
+ `place-toggle`; the colours come from the existing tokens. The tooltip and the toggle's labels are
3096
+ the `placeShared` and `placeWithheld` (token `{label}`), `placeStopSharing` and `placeShareAgain`
3097
+ strings.
3098
+
3099
+ ## Resuming a run
3100
+
3101
+ When the server persists run checkpoints (django-ag-ui's `step_store`), a run
3102
+ that stopped part-way can be **continued** rather than restarted. Point the
3103
+ component at the run index and a checkpoints button (an arrow turning back) appears in the header:
3104
+
3105
+ ```html
3106
+ <ag-ui-chat endpoint="/agent/" data-runs-url="/agent/runs/"></ag-ui-chat>
3107
+ ```
3108
+
3109
+ The panel lists runs the server marked **continuable** — those with a saved
3110
+ snapshot to seed from. A run that never reached a provider-valid boundary has
3111
+ none, so it isn't offered: resuming it would start from nothing.
3112
+
3113
+ A row leads with the run's **first user message**, from the index's `preview`
3114
+ field, and shows when it started beside it. That is what makes the list a list of
3115
+ conversations: a time is not an identity, since two runs a minute apart both read
3116
+ "just now", and a run id is not something a person recognises. Where the server
3117
+ sends no preview — an older index, or a run that opened on an image with no
3118
+ caption — the row falls back to the time plus the first eight characters of the
3119
+ id (full id on hover, for correlating with server logs), and so does a row whose
3120
+ preview another row repeats: words shared with a second row identify neither, and
3121
+ one board tends to be asked about more than once. Either way a run that branched
3122
+ from another is marked, so a fork doesn't read as a duplicate of its parent.
3123
+
3124
+ Type the next turn in the composer, then pick a row:
3125
+
3126
+ - **Resume** — continue that run.
3127
+ - **Fork** — branch it, leaving the original untouched.
3128
+
3129
+ Both send to the matching server endpoint and stream into the same transcript. The
3130
+ turn you typed is drawn there ahead of the answer and announced with `ag-ui-submit`,
3131
+ exactly as a send is, so a host that keeps its own record of what the user said sees
3132
+ continued turns too.
3133
+
3134
+ Picking a row with an empty composer says so above the input and puts the caret
3135
+ there, rather than closing the panel over nothing: a continuation sends **only**
3136
+ the new turn, so with nothing typed there is nothing to send.
3137
+
3138
+ Picking a row while an answer is still streaming — the conversation's own, or
3139
+ another continuation's — is refused the same way: the hint asks you to wait or
3140
+ stop it, and what you typed stays in the composer for the pick after. The run in
3141
+ flight is not cancelled for you, since a continuation starting over it would
3142
+ leave two answers streaming into one transcript with Stop reaching only one.
3143
+
3144
+ Picking a row while a stored conversation is still loading is refused the same
3145
+ way, with a hint asking you to wait for the load: a continuation sends only the
3146
+ new turn, so it must not start before the history it continues is on screen. The
3147
+ wording is the `continueWhileLoading` string.
3148
+
3149
+ ### One URL, three endpoints
3150
+
3151
+ `data-runs-url` is the only thing to configure. `resume/<id>/` and `fork/<id>/`
3152
+ are siblings of the index — django-ag-ui mounts all three under one prefix
3153
+ whenever a step store is set — so they're derived, and there's no way to end up
3154
+ with a half-configured set.
3155
+
3156
+ ### The client contract, handled for you
3157
+
3158
+ Those endpoints expect a request carrying a **fresh run id** and **only the new
3159
+ turn**: the server supplies the prior turns from the snapshot, so re-sending
3160
+ them would duplicate the conversation.
3161
+
3162
+ The component satisfies that structurally rather than by remembering a rule. A
3163
+ continuation runs on its own short-lived agent, built pointing at the resume
3164
+ endpoint and seeded with **no** history — so "only the new turn" is the only
3165
+ thing it *can* send, and the fresh run id comes free because a new agent mints
3166
+ one. Nothing of the conversation's history goes out on its request.
3167
+
3168
+ A resumed run is a normal run in every other respect: frontend tools execute,
3169
+ approval interrupts render their card, `headers` are re-read per request so
3170
+ a rotated CSRF token or JWT still reaches the endpoint, it carries and updates
3171
+ [shared state](#host-seams-the-spa-story) like any other run, it is bounded by
3172
+ `data-max-tool-rounds`, and Stop ends it. Its client is built by the same
3173
+ construction as the conversation's own, differing only in the endpoint and the
3174
+ empty seed.
3175
+
3176
+ The continued exchange joins the conversation. Its agent holds only the new turn
3177
+ and the answer, and a store keeps one message list per thread, so each save
3178
+ writes the conversation as it stood when you picked the row with the exchange
3179
+ after it — through the same `saveMessages` as any other run, and as far as it
3180
+ got if it was stopped. A reload then replays the exchange, and the next message
3181
+ you send, now to `endpoint` again, carries it together with the shared state the
3182
+ continuation left. What is saved is what the transcript shows: a fork's exchange
3183
+ follows the conversation it was picked from, in that same thread.
3184
+
3185
+ If the index can't be reached, the panel shows its empty state rather than an
3186
+ error — a history affordance that fails is empty, not broken.
3187
+
3188
+ ## File uploads
3189
+
3190
+ Set **`data-attachments-url`** (django-ag-ui's `AttachmentsView`) to let the user attach files
3191
+ to a message. A button, drag-and-drop, and **paste** appear on the composer; each picked file uploads
3192
+ out-of-band (multipart, with the element's `headers`) and shows a chip in a pending tray —
3193
+ `uploading` (with a progress bar) → `ready`, or `error` with a retry. On send, the ready files'
3194
+ **refs** ride on the user bubble as read-only chips and the agent reads their contents
3195
+ server-side via the `read_attachment` tool. The wire stays vanilla AG-UI: only lightweight refs
3196
+ (`{ id, name, mime, size }`) travel, never the bytes, in the user message's `metadata` under
3197
+ `attachments` — the one place on a message `@ag-ui/client` 1.0 does not strip an unknown key from.
3198
+ django-ag-ui reads them from there as of 0.63.0; an older one reads only the top-level key 1.0
3199
+ removes, so against it the agent is never told a file was attached.
3200
+
3201
+ ### Pasting
3202
+
3203
+ A paste carrying files puts them on the tray, the same way the picker and a drop
3204
+ do — a screenshot straight from the clipboard, or a file copied in the file
3205
+ manager.
3206
+
3207
+ Two rules keep it from stealing a paste that was never about files. Text pastes
3208
+ carry no files at all, so ordinary pasting is untouched. And the default is
3209
+ prevented **only when the clipboard carries no text**: copying a rich selection
3210
+ that happens to contain an image puts both on the clipboard, and swallowing the
3211
+ words someone meant to paste in order to attach a picture they did not is the
3212
+ worse of the two failures.
3213
+
3214
+ A pasted blob that arrives with no filename — some engines hand one over that
3215
+ way — is given one, rather than reaching the server as an empty `filename` and
3216
+ showing in the tray as a chip with no label.
3217
+
3218
+ ```html
3219
+ <ag-ui-chat
3220
+ endpoint="/agent/"
3221
+ data-attachments-url="/agent/attachments/"
3222
+ data-attachment-accept="image/*,application/pdf,text/plain"
3223
+ data-attachment-max-bytes="10485760"
3224
+ ></ag-ui-chat>
3225
+ ```
3226
+
3227
+ Client-side `accept` / size checks are an instant-feedback nicety — **the server is
3228
+ authoritative**. Refs persist on the message, so a restored conversation re-renders its chips.
3229
+ Without the attribute the affordance stays hidden and the chat is text-only.
3230
+
3231
+ The built-in handler sends the element's `headers` / `getHeaders` with every upload, and honours
3232
+ `credentials="include"` — with the caveat that it is an `XMLHttpRequest` (for real progress
3233
+ events), whose cookie switch is two-state: `include` turns it on, every other value leaves it off.
3234
+ See [Authenticating requests](#authenticating-requests).
3235
+
3236
+ **Swapping the upload transport.** The built-in multipart `POST` is just the default
3237
+ `uploadHandler`. Set your own to use a different transport — a resumable
3238
+ [`tus-js-client`](https://github.com/tus/tus-js-client) adapter, direct-to-S3 multipart, etc.
3239
+ — without touching the tray, the chips, or the AG-UI wire (refs are transport-agnostic). The
3240
+ handler is `(file, onProgress) => Promise<AttachmentRef>`; when set, the affordance appears
3241
+ even with no `data-attachments-url`, and your handler owns its own endpoint and headers:
3242
+
3243
+ ```js
3244
+ import { Upload } from "tus-js-client";
3245
+
3246
+ chat.uploadHandler = (file, onProgress) =>
3247
+ new Promise((resolve, reject) => {
3248
+ const up = new Upload(file, {
3249
+ endpoint: "/tus/",
3250
+ headers: chat.headers,
3251
+ onProgress: (sent, total) => onProgress(sent / total),
3252
+ onError: reject,
3253
+ onSuccess: () =>
3254
+ resolve({ id: up.url.split("/").pop(), name: file.name, mime: file.type, size: file.size }),
3255
+ });
3256
+ up.start();
3257
+ });
3258
+ ```
3259
+
3260
+ The server side is the matching half: the agent reads bytes by ref id, so point the
3261
+ `read_attachment` store at wherever your transport persisted them (django-ag-ui's
3262
+ `AttachmentStore` is the seam). The refs themselves never change shape.
3263
+
3264
+ ---
3265
+
3266
+ ## Public API surface
3267
+
3268
+ Everything below is re-exported from the package root ([`src/index.ts`](src/index.ts)), except
3269
+ `AgUiChat` and `defineChat`, which are the `./chat` entry. The
3270
+ vendored bundle ([`src/bundle.ts`](src/bundle.ts)) carries both. Internal modules import from leaf
3271
+ paths.
3272
+
3273
+ ### Element & registration
3274
+
3275
+ | Export | Kind | Summary |
3276
+ | --- | --- | --- |
3277
+ | `AgUiChat` | class | The `<ag-ui-chat>` Custom Element (`./chat`). |
3278
+ | `defineChat()` | function | Register the element (`./chat`); a no-op if it is already registered, and throws if another class holds the tag. |
3279
+ | `MessageRole` | type | Role of a rendered chat message. |
3280
+ | `SubmitDetail` | type | `detail` shape of the submit event. |
3281
+ | `ThreadDetail` | type | `detail` shape of the `ag-ui-thread` event (`{ threadId, previousThreadId, reason }`). |
3282
+ | `ToggleDetail` | type | `detail` shape of the `ag-ui-toggle` event (`{ collapsed }`). |
3283
+ | `MaximizeDetail` | type | `detail` shape of the `ag-ui-maximize` event (`{ maximized }`). |
3284
+ | `BarHeightDetail` | type | `detail` shape of the `ag-ui-bar-height` event (`{ height }`). |
3285
+ | `DockWidthDetail` | type | `detail` shape of the `ag-ui-dock-width` event (`{ width, side }`). |
3286
+ | `ThreadTitleDetail` | type | `detail` shape of the `ag-ui-thread-title` event (`{ threadId, title }`). |
3287
+ | `UnreadDetail` | type | `detail` shape of the `ag-ui-unread` event (`{ unread, threadIds }`). |
3288
+ | `AttachmentsDetail` | type | `detail` shape of the `ag-ui-attachments` event (`{ attachments, pending }`). |
3289
+ | `StateDetail` | type | `detail` shape of the `ag-ui-state` event (`{ state }`). |
3290
+
3291
+ ### AG-UI client & agent
3292
+
3293
+ | Export | Kind | Summary |
3294
+ | --- | --- | --- |
3295
+ | `AgUiClient` | class | Orchestration layer over an AG-UI `AbstractAgent`. |
3296
+ | `AgUiClientConfig` / `AgUiClientHandlers` / `AgUiRunInputs` | type | Client config, lifecycle handlers, per-run input providers. |
3297
+ | `AgUiToolCall` / `ToolExecution` / `ExecuteTool` | type | Tool-call shape, execution result, executor signature. |
3298
+ | `AgUiMessageIds` | type | Which message `AgUiClientHandlers.onTextEnd` completed: its `messageId`, and the `runId` its `RUN_STARTED` named. |
3299
+ | `ToolOutcome` | type | The `TOOL_OUTCOME` values as a union. |
3300
+ | `toolStatusFromOutcome` | function | Map a wire outcome to the card status it settles into; anything absent or unrecognised is a success. |
3301
+ | `ConnectionLostError` | class | Raised (→ `onError`) when a run's stream closes with no terminal AG-UI event. |
3302
+ | `createHttpAgent(options)` | function | Default agent factory (wraps `HttpAgent`). |
3303
+ | `AgentFactory` / `HttpAgentOptions` | type | Factory signature and its options. |
3304
+ | `ResolveInterrupts` | type | Resolver for server-side-tool approval interrupts (one decision per interrupt). |
3305
+ | `InterruptResponse` | type | One interrupt's answer: `resolved` (with an optional payload) or `cancelled`. |
3306
+
3307
+ ### Tools & flags
3308
+
3309
+ | Export | Kind | Summary |
3310
+ | --- | --- | --- |
3311
+ | `ClientToolRegistry` | class | Per-element tool registry. |
3312
+ | `ClientTool` | type | A frontend tool declaration. |
3313
+ | `isDestructive(parameters)` | function | Read the `x-destructive` flag. |
3314
+ | `isNavigates(parameters)` | function | Read the `x-navigates` flag. |
3315
+ | `createPageActionTools(enabled, resolveTarget)` | function | Build the opt-in `scroll_to` / `drag_and_drop` tools. |
3316
+ | `PAGE_ACTIONS` | const | The page-action opt-in tokens (`scroll` / `drag`). |
3317
+ | `createChatSurfaceTools(surface)` | function | Build the opt-in `read_chat_surface` / `move_chat` / `minimise_chat` / `restore_chat` tools, which let the agent move the panel it is speaking from. |
3318
+ | `ChatSurface` | type | The narrow port those tools drive — `describeSurface` / `moveTo` / `setCollapsed`. The element satisfies it. |
3319
+ | `ChatSurfaceReport` | type | What `describeSurface()` answers: placement, collapsed, collapseTo, collapsible, movable, draggable, fullBleed, and the panel's box against the viewport it sits in. |
3320
+ | `ChatCorner` | type | `"top-left"` / `"top-right"` / `"bottom-left"` / `"bottom-right"` — the argument to `moveTo`. |
3321
+ | `CHAT_CORNERS` | const | Those four, as a list. |
3322
+ | `isChatCorner(value)` | function | Whether a string names one of them. |
3323
+ | `ResolvePageTarget` | type | `(target) => HTMLElement \| null` — the page-target resolver. |
3324
+ | `X_DESTRUCTIVE_KEY` / `X_NAVIGATES_KEY` | const | The JSON-Schema extension keys. |
3325
+ | `parseToolCatalog(data)` | function | Parse a fetched `data-tools-url` catalog into a `Record<string, ToolCatalogEntry>` — whole entries, not bare summaries, so a caller can reach `description` too. Malformed input yields an empty map rather than throwing. |
3326
+ | `ToolCatalogEntry` | type | One row of that catalog. |
3327
+ | `prettifyToolName(name)` | function | Last fallback of the tool-card label chain (`delete_record` reads as *Delete record*). |
3328
+
3329
+ ### Host seams
3330
+
3331
+ | Export | Kind | Summary |
3332
+ | --- | --- | --- |
3333
+ | `createRouteTools(...)` | function | Build the built-in `route.*` tools. |
3334
+ | `Route` / `RouteMap` | type | Navigable-route shapes. |
3335
+ | `RouteWithParams` | type | A route resolved with `:param` path segments + leftover query params. |
3336
+ | `createPageMapContext(...)` | function | Build the per-run `page_map` context entry. |
3337
+ | `PageMap` | type | The compact page-surface shape. |
3338
+ | `Place` | type | Where the user is: what `getPlace` returns and the chip draws. |
3339
+ | `createPageStateTools(binding)` | function | Build `read_<name>` / `set_<name>` tools. |
3340
+ | `PageState` | type | A page-state binding declaration. |
3341
+ | `Skill` | type | A launchable prompt (chip / `/`-command). |
3342
+ | `RunFinishedDetail` / `ToolRun` | type | `ag-ui-run-finished` detail: the tools an interaction ran, which side ran them, and how it ended. |
3343
+ | `CustomAgentDetail` | type | `ag-ui-custom` detail: an AG-UI `CUSTOM` event's `name` and `value`, verbatim. |
3344
+ | `InvalidateDetail` | type | `ag-ui-invalidate` detail: the resource `keys` that moved, and the `reason`. |
3345
+ | `FeedbackDetail` | type | `ag-ui-feedback` detail: the rated message's `messageId`, its `threadId`, the `runId` that streamed it (live only), its `content` and the `rating`. See [Storing a rating](#storing-a-rating). |
3346
+ | `relativeTime` | function | The built-in locale-neutral timestamp formatter (`"5m ago"`), for a host building on it. |
3347
+ | `RelativeTimeFormatter` | type | What `formatRelativeTime` takes: an epoch-ms timestamp in, row text out. |
3348
+ | `renderSuggestionChips` | function | Draw a `suggestions` activity as chips that send themselves; `null` when nothing survives. |
3349
+ | `suggestionPrompts` | function | The usable prompts in a `suggestions` activity's content, bounded and trimmed. |
3350
+ | `attachMessageActions` | function | Give a finished bubble its action row (copy, and feedback when a handler is passed). |
3351
+ | `messageActionBar` | function | The empty action row for a bubble, created if it has none — the shared shell both callers use. |
3352
+ | `MessageActionsOptions` | type | What `attachMessageActions` takes: `strings`, a `text` source, an optional `html` source for the rich clipboard flavour, an optional `onFeedback`. |
3353
+ | `ActivityRenderer` | type | Draws one activity from its `content`. Pure: it runs again on every restore. |
3354
+ | `ActivityRegistration` | type | What `registerActivityRenderer` takes: `type`, `render`, and an optional `removedNotice`. |
3355
+
3356
+ ### Durability
3357
+
3358
+ | Export | Kind | Summary |
3359
+ | --- | --- | --- |
3360
+ | `SessionStorageStore` | class | Default per-tab conversation store. |
3361
+ | `RemoteConversationStore` | class | Server-backed store over a `data-threads-url` endpoint. |
3362
+ | `ClientConversationStore` | type | The persistence seam. |
3363
+ | `ThreadMeta` | type | A thread-drawer row (`{ threadId, title, updatedAt, preview, unread? }`). |
3364
+ | `NavigationCheckpoint` | type | The pre-reload checkpoint marker. |
3365
+ | `RunIndex` | class | Reads a `data-runs-url` run index and derives its resume / fork endpoints. |
3366
+ | `RunRow` | type | One run index row (`{ run_id, thread_id, parent_run_id, started_at, continuable, preview? }`). |
3367
+ | `CheckpointMenu` | class | The *Continue a run* panel. |
3368
+ | `CheckpointVerb` | type | `"resume" \| "fork"`. |
3369
+
3370
+ ### Attachments
3371
+
3372
+ | Export | Kind | Summary |
3373
+ | --- | --- | --- |
3374
+ | `uploadAttachment(file, options)` | function | The built-in upload (multipart, progress) → `AttachmentRef`. |
3375
+ | `UploadOptions` | type | `{ url, headers?, credentials?, onProgress?, signal? }`. `credentials` is spelled as a fetch mode but carried by `XMLHttpRequest.withCredentials`, so only `"include"` is distinguishable. |
3376
+ | `UploadHandler` | type | `(file, onProgress, signal?) => Promise<AttachmentRef>` — the `uploadHandler` swap seam (tus / S3). The signal fires when the tray removes a chip or the element is torn down; a handler that honours it aborts its own transport, so a cancelled upload leaves no orphaned file on the server. |
3377
+ | `AttachmentRef` | type | The durable upload ref (`{ id, name, mime, size, url? }`). |
3378
+ | `messageAttachments(message)` | function | Read the refs a restored user message carries: from its `metadata`, or from the top level where an earlier release stored them. |
3379
+
3380
+ ### Voice input
3381
+
3382
+ | Export | Kind | Summary |
3383
+ | --- | --- | --- |
3384
+ | `transcribeAudio(audio, options)` | function | The built-in transcription POST (multipart) → the transcript text. |
3385
+ | `TranscribeOptions` | type | `{ url, headers?, credentials? }` — `credentials` as fetch's own cookie mode. |
3386
+ | `TranscribeHandler` | type | `(audio) => Promise<string>` — the `transcribeHandler` swap seam (Web Speech, direct-to-provider). |
3387
+
3388
+ ### UI & DOM primitives
3389
+
3390
+ | Export | Kind | Summary |
3391
+ | --- | --- | --- |
3392
+ | `ToolCallCard` | class | A live tool-call card for the transcript. |
3393
+ | `ToolCallStatus` / `SettledStatus` / `ToolDisplayMode` | type | Card lifecycle states + display mode. |
3394
+ | `ToolGroupDisplayMode` | type | How an opened step group draws its calls (`TOOL_GROUP_DISPLAY`'s values). |
3395
+ | `ToolPayloadFormatter` | type | Draws one region of a card's body (`AgUiChat.formatToolPayload`); `null` falls through to the built-in pretty-print. |
3396
+ | `ToolPayload` | type | The region being drawn: `arguments` (the parsed record) or `result` (the raw string and its outcome). |
3397
+ | `ToolCallCardOptions` | type | Per-card wiring beyond name / args / label / strings — currently `formatPayload`. |
3398
+ | `requestConfirmation(host, request, options?)` | function | Append the inline confirmation card to the transcript. |
3399
+ | `ConfirmationRequest` | type | What the card displays. |
3400
+ | `ConfirmationOptions` | type | `{ signal?, strings?, onAlwaysAllow? }` — abort resolves the card as declined; `strings` localizes it; passing `onAlwaysAllow` is what adds the third button. |
3401
+ | `UiStrings` | type | The flat table of every user-facing string. |
3402
+ | `DEFAULT_UI_STRINGS` | const | The English defaults (the override floor). |
3403
+ | `mergeUiStrings(overrides)` | function | Merge a partial override over the defaults. |
3404
+ | `renderMarkdown(text, options?)` | function | Render sanitized markdown/HTML (marked + DOMPurify). |
3405
+ | `RenderMarkdownOptions` | type | `{ allowImages? }` — opt `<img>` back into the sanitized output. |
3406
+ | `requestApproval(host, request, options?)` | function | Append the inline approval card that gates a server-side tool. |
3407
+ | `ApprovalRequest` | type | What that card displays (`{ message?, toolName?, args? }`). |
3408
+ | `ApprovalOptions` | type | `{ signal?, strings?, onEdit? }` — abort resolves the card as denied; `strings` localizes it; passing `onEdit` offers the call's arguments for editing and is called only when they actually changed. |
3409
+ | `ApprovalRenderer` | type | Replace the built-in approval card outright (`AgUiChat.approvalRenderer`). |
3410
+ | `requestQuestion(host, request, options?)` | function | Append the inline `ask_user` card (radios and/or free text). |
3411
+ | `QuestionRequest` | type | What that card asks. |
3412
+ | `QuestionOptions` | type | `{ signal?, strings? }` — abort resolves it with an empty answer. |
3413
+ | `QuestionRenderer` | type | Replace the built-in question card outright (`AgUiChat.askUserRenderer`). |
3414
+ | `renderChart(spec)` | function | Draw one spec as a self-contained block, or `null` when it says nothing. |
3415
+ | `chartSpecFrom(value)` | function | Read an arbitrary payload into a `ChartSpec`, or `null` if it cannot be drawn honestly. |
3416
+ | `ChartSpec` / `ChartSeries` / `ChartKind` | type | A chart as data, one named series, and how it is drawn. |
3417
+ | `attachQuoteOffer(options)` | function | The page-side select-then-quote offer, with its guards. `AgUiChat.offerQuoteInPage()` is the one-line form. |
3418
+ | `PageQuoteOffer` / `PageQuoteOfferOptions` | type | The live offer (`{ element, detach }`) and what it takes. |
3419
+ | `quotableSelection(container, roots, near?)` | function | The current selection when it lies inside `container`, read through the shadow-aware API where the engine has one. `near` is where the gesture ended, used to pick the line the offer hangs from. |
3420
+ | `QuotableSelection` | type | `{ text, rect }` — what was selected, and where it sits. |
3421
+ | `asQuote(text)` | function | Shape text as a markdown blockquote with a blank line after it. |
3422
+ | `MAX_QUOTE_CHARS` | const | The cap a quotation is truncated to (500). |
3423
+ | `typeInto` / `highlightThenClick` / `pressThenClick` / `selectOption` / `toggleControl` / `scrollIntoCenterView` / `flash` / `focusWithFlash` / `prefersReducedMotion` | function | Animation primitives. |
3424
+ | `showHighlightOverlay` | function | Ring a host-page element from an overlay drawn outside it, optionally dimming everything else or flowing a gradient round it. Returns a function that removes it. |
3425
+ | `HighlightOverlayOptions` | type | Options for `showHighlightOverlay`. |
3426
+ | `fillField` / `clickElement` / `pressButton` / `selectControl` / `setControlValue` / `toggleCheckbox` | function | DOM-driver primitives. |
3427
+ | `setNativeValue` / `setNativeChecked` | function | Set a control via its native prototype setter (React-controlled inputs). |
3428
+ | `TypeOptions` / `HighlightClickOptions` / `PressOptions` / `SelectOptions` / `ToggleOptions` / `FlashOptions` / `ScrollOptions` / `FillFieldOptions` / `TextLikeElement` / `RingOwnerOptions` | type | Primitive option shapes. `RingOwnerOptions` is the `chat` the ring-drawing ones share; see [Ring colour](#ring-colour). |
3429
+
3430
+ ### Constants
3431
+
3432
+ | Export | Summary |
3433
+ | --- | --- |
3434
+ | `ELEMENT_TAG` | The registered tag name (`ag-ui-chat`). |
3435
+ | `SUBMIT_EVENT` | The submit CustomEvent name. |
3436
+ | `THREAD_EVENT` | The conversation-changed CustomEvent name (`ag-ui-thread`). |
3437
+ | `THREAD_TITLE_EVENT` | The thread-title CustomEvent name (`ag-ui-thread-title`). |
3438
+ | `TOGGLE_EVENT` | The collapse-toggle CustomEvent name (`ag-ui-toggle`). |
3439
+ | `MAXIMIZE_EVENT` | The maximized-state CustomEvent name (`ag-ui-maximize`). |
3440
+ | `BAR_HEIGHT_EVENT` | The bottom bar's height CustomEvent name (`ag-ui-bar-height`). |
3441
+ | `DOCK_WIDTH_EVENT` | The docked panel's width CustomEvent name (`ag-ui-dock-width`). |
3442
+ | `UNREAD_EVENT` | The unread-count CustomEvent name (`ag-ui-unread`). |
3443
+ | `RUN_FINISHED_EVENT` | The interaction-finished CustomEvent name (`ag-ui-run-finished`). |
3444
+ | `CUSTOM_AGENT_EVENT` | The agent-`CUSTOM` CustomEvent name (`ag-ui-custom`). |
3445
+ | `INVALIDATE_EVENT` | The resource-invalidation CustomEvent name (`ag-ui-invalidate`). |
3446
+ | `FEEDBACK_EVENT` | The message-rating CustomEvent name (`ag-ui-feedback`). |
3447
+ | `SUGGESTIONS_ACTIVITY_TYPE` | The `activity_type` carrying follow-up prompts (`suggestions`). |
3448
+ | `MAX_SUGGESTIONS` | Most prompts one push draws (4). Mirrors the server's cap. |
3449
+ | `MAX_SUGGESTION_CHARS` | Longest one prompt may be (120). Mirrors the server's cap. |
3450
+ | `INVALIDATE_CUSTOM_NAME` | The AG-UI `CUSTOM` `name` that carries one (`ag_ui.invalidate`). |
3451
+ | `ATTACHMENT_EVENT` | The attachments-changed CustomEvent name (`ag-ui-attachments`). |
3452
+ | `STATE_EVENT` | The shared-state CustomEvent name (`ag-ui-state`). |
3453
+ | `CHART_ACTIVITY_TYPE` | The `ACTIVITY_SNAPSHOT` type a server sets to push a chart. |
3454
+ | `CHART_TOOL_NAME` | The name the built-in chart tool registers under (`render_chart`). |
3455
+ | `COMPACTION_ACTIVITY_TYPE` | The `ACTIVITY_SNAPSHOT` type reporting a trimmed history. |
3456
+ | `LOAD_CAPABILITY_TOOL` | The agent-side capability-loading tool's name. |
3457
+ | `MESSAGE_ROLE` | Message role constants. |
3458
+ | `MESSAGE_ACTIONS` | The message-action tokens `data-message-actions` selects by (`copy` / `retry` / `feedback`). |
3459
+ | `TOOL_CALL_STATUS` | Tool-call card status constants. |
3460
+ | `TOOL_DISPLAY` | Tool-call display-mode constants (`inline` / `minimal` / `compact` / `full`). |
3461
+ | `TOOL_GROUP_DISPLAY` | How an opened step group draws its calls under `grouped` (`inline` / `minimal` / `compact` / `full`). |
3462
+ | `TOOL_OUTCOME` | How a tool call ended, as a server states it on `TOOL_CALL_RESULT` (`success` / `failed` / `denied`). |
3463
+ | `X_CONFIRM_KEY` | Confirmation-prompt key: on a tool's JSON Schema for a client-side confirmation, and in an AG-UI interrupt's `metadata` for a server-side approval. |
3464
+ | `X_SUMMARY_KEY` | JSON-Schema key carrying a short tool-card label. |
3465
+ | `MAX_TOOL_ROUNDS` | Upper bound on tool-call → re-run rounds per send. |
3466
+ | `VERSION` | The package version string. |
3467
+
3468
+ ---
3469
+
3470
+ ## Theming, density, and placement
3471
+
3472
+ The chat shell is styled inside its Shadow DOM and exposes a large set of `--ag-ui-*` CSS custom
3473
+ properties (colors, status, surface, spacing, layout), so you theme it from outside without
3474
+ piercing the shadow boundary. Set them anywhere above the element and they inherit in — on the
3475
+ element itself, on a wrapper, or on `:root` for a whole page. The closest declaration wins, the
3476
+ way any inherited CSS property behaves:
3477
+
3478
+ ```css
3479
+ /* All three work. The most specific one that applies wins. */
3480
+ :root { --ag-ui-accent: #4f46e5; } /* whole page */
3481
+ .chat-dock { --ag-ui-accent: #0f766e; } /* one region */
3482
+ ag-ui-chat { --ag-ui-accent: #b91c1c; } /* one widget */
3483
+ ```
3484
+
3485
+ > Until 0.20.x the defaults were declared on `:host`, which set them *on the element* — and an
3486
+ > element's own value beats anything inherited from an ancestor, so only the `ag-ui-chat { … }`
3487
+ > form did anything and the wrapper form silently did nothing. The defaults now sit behind an
3488
+ > internal alias, so all three forms work.
3489
+
3490
+ A few of the knobs, at Quiet's light values:
3491
+
3492
+ ```css
3493
+ ag-ui-chat {
3494
+ --ag-ui-accent: #232326; /* #f0f0f2 in the dark scheme */
3495
+ --ag-ui-bg: #ffffff; /* #161618 */
3496
+ --ag-ui-fg: #1b1b1d; /* #ececee */
3497
+ --ag-ui-radius: 12px;
3498
+ --ag-ui-header-btn-size: 30px; /* the header's controls; 44px on touch */
3499
+ --ag-ui-header-gap: 4px;
3500
+
3501
+ /* What is drawn on top of the accent and danger fills. Unset, the one on
3502
+ the accent is white or black by the accent's luminance (see Text on the
3503
+ accent below); set it to pin a colour of your own. */
3504
+ --ag-ui-on-accent: #ffffff;
3505
+ --ag-ui-on-danger: #ffffff;
3506
+
3507
+ /* Layout */
3508
+ --ag-ui-width: 380px;
3509
+ --ag-ui-height: 560px;
3510
+ --ag-ui-inset: auto 24px 24px auto;
3511
+ --ag-ui-shadow: 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 32px -16px rgb(0 0 0 / 0.22);
3512
+ }
3513
+ ```
3514
+
3515
+ `--ag-ui-radius` is the theme's corner radius. It rounds the panel's frame and the corners inside
3516
+ the panel that have no radius of their own: the answer well, the conversation list's *New chat*
3517
+ button and filter field, and the *Undo* on a run notice. The frame is the exception where it meets
3518
+ the edge of the window. `page` and `sidebar` draw it square, and so does every placement but
3519
+ `embedded` on a small viewport, while the corners inside keep the theme's radius, since none of them
3520
+ meets that edge. `--ag-ui-panel-radius` sets the frame on its own, in any placement. Each theme
3521
+ gives the floating frame a radius of its own (`--ag-ui-frame-radius`: 14px in Quiet against 12px
3522
+ inside), and a host's `--ag-ui-radius` still sets both.
3523
+
3524
+ ### Themes
3525
+
3526
+ `theme` is up to two words, in any order, matched as words (`~=`):
3527
+
3528
+ ```
3529
+ theme = [name] [scheme]
3530
+ name = quiet | soft | ledger | classic | <a word of your own>
3531
+ scheme = light | dark | auto default: light
3532
+ ```
3533
+
3534
+ | Value | Result |
3535
+ | --- | --- |
3536
+ | *(absent)* | Quiet, light |
3537
+ | `dark` | Quiet, dark |
3538
+ | `auto` | Quiet, following the system's `prefers-color-scheme` |
3539
+ | `soft`, `soft dark`, `ledger auto` | that theme in that scheme |
3540
+ | `classic`, `classic dark` | the look from before themes, for a host that pins it |
3541
+ | `acme` | a word the component does not ship adds no rules, so this is Quiet, ready for your `[theme~="acme"]` CSS |
3542
+ | `soft acme` | starts from Soft; your `[theme~="acme"]` rules layer on top |
3543
+
3544
+ The built-in themes:
3545
+
3546
+ | Theme | Look |
3547
+ | --- | --- |
3548
+ | `quiet` | The default. Neutral graphite on white, no bubble for the agent's turn, hairlines, status as an icon. Set one brand accent and the user's turn, the send button and the links follow it. |
3549
+ | `soft` | Rounded and tinted: surfaces mixed from the accent, pill chips, a deep frame shadow. Violet by default. |
3550
+ | `ledger` | Dense and editorial: paper and ink, square corners, serif for what the agent says and monospace for the chrome. |
3551
+ | `classic` | The look before themes: the indigo header, the accent user bubble, the status pill and the round launcher. Pin it to keep that look. |
3552
+
3553
+ **The default scheme is `light`, not `auto`**, because the widget sits on your page and only you
3554
+ know that page's scheme. A light admin screen on a machine in dark mode would otherwise get a dark
3555
+ panel on a white page. If your page follows the system, say `auto`.
3556
+
3557
+ Each theme is three seeds, `--ag-ui-bg`, `--ag-ui-fg` and `--ag-ui-accent`, each declared with
3558
+ `light-dark()` so the scheme word picks the half. Every other colour is mixed from them with
3559
+ `color-mix(in oklch, ...)` -- hover, borders, the secondary text inks, washes, the user's turn (the
3560
+ accent at 12% over the background) -- and every derived value still reads its own public token
3561
+ first, so each stays an override point. The tokens the themes add:
3562
+
3563
+ - **Inks and lines:** `--ag-ui-ink-2`, `--ag-ui-ink-3`, `--ag-ui-line`, `--ag-ui-line-strong`.
3564
+ - **Surfaces:** `--ag-ui-wash`, `--ag-ui-accent-ink`, `--ag-ui-tint`, `--ag-ui-tint-2`.
3565
+ - **Type:** `--ag-ui-text-xs`, `--ag-ui-text-sm`, `--ag-ui-text-md`, `--ag-ui-text-lg`,
3566
+ `--ag-ui-leading`, `--ag-ui-serif`.
3567
+ - **Frame:** `--ag-ui-frame-radius`, `--ag-ui-frame-shadow`. These are what a theme sets for the
3568
+ frame; `--ag-ui-panel-radius` and `--ag-ui-shadow` stay the final say, and are what a docked
3569
+ placement squares or flattens.
3570
+ - **Charts:** `--ag-ui-chart-1` to `--ag-ui-chart-6`. A host's value beats the theme's.
3571
+ - **Status marks:** `--ag-ui-ico-check`, `--ag-ui-ico-error`, `--ag-ui-ico-declined`, mask images
3572
+ drawn in the state's colour, and `--ag-ui-ico-chevron`, the step group's disclosure chevron.
3573
+ - **Focus:** `--ag-ui-ring`, the step group head's focus outline. Unset, it is two parts accent
3574
+ to three parts ink (`--ag-ui-fg`), which holds 3:1 against the panel for any accent.
3575
+
3576
+ The font is part of the theme: Quiet, Soft and Ledger set their own stack (Inter, then the system
3577
+ UI font), and Classic inherits the page's font as the component always did. To take the page's font
3578
+ under any theme, set `--ag-ui-font` to the page's family, or to the page's own font token:
3579
+
3580
+ ```css
3581
+ ag-ui-chat { --ag-ui-font: var(--body-font); } /* or the family itself */
3582
+ ```
3583
+
3584
+ `--ag-ui-font: inherit` does not do this. A CSS-wide keyword in a custom property applies to the
3585
+ custom property itself, so it takes the `--ag-ui-font` of the parent, which nothing sets, and the
3586
+ theme's stack wins.
3587
+
3588
+ #### Text on the accent
3589
+
3590
+ `--ag-ui-on-accent`, unless you set it, is picked from the accent's own luminance: white on an
3591
+ accent darker than WCAG relative luminance 0.179, black on a lighter one, which is the better of
3592
+ the two on every colour (never under 4.58:1). It is computed with relative colour syntax with
3593
+ channel arithmetic, behind `@supports` for that exact expression: Chrome 119, Firefox 128, Safari
3594
+ 18.
3595
+
3596
+ **The fallback.** Two browsers at or above the floor cannot compute it: Firefox 120 to 127, which
3597
+ lack relative colour syntax, and Safari 17.5 and 17.6, which shipped an earlier draft that rejects
3598
+ this expression. They keep a fixed colour per theme instead:
3599
+
3600
+ | Theme | Without relative colour syntax |
3601
+ | --- | --- |
3602
+ | `quiet`, `soft`, `ledger` | the scheme's: white in the light scheme, near-black (`#111111`) in the dark one |
3603
+ | `classic`, `code` | white in both schemes, the colour they always drew |
3604
+
3605
+ On every built-in accent but code's the fixed colour is on the same side as the computed one, so
3606
+ those browsers see the same contrast. On code's green the computed pick is black (8.3:1) and the
3607
+ fallback white (2.5:1), as it always was. What they miss is the correction for a host's accent: a
3608
+ mid-tone brand accent can fall under 4.5:1 there, under any theme. If you support those browsers
3609
+ and set a mid-tone accent, set `--ag-ui-on-accent` yourself. There is no script path.
3610
+
3611
+ #### Keeping a brand accent on the header
3612
+
3613
+ Quiet draws the header, the launcher and the tool names in the panel's own colours, so a host that
3614
+ sets only `--ag-ui-accent` gets a neutral header. To put the accent back without leaving Quiet, set
3615
+ the surfaces to the accent and the text on them to a colour that reads on it. The same recipe holds
3616
+ under every other theme:
3617
+
3618
+ ```css
3619
+ ag-ui-chat {
3620
+ --ag-ui-header-bg: #3d5afe;
3621
+ --ag-ui-header-fg: #ffffff;
3622
+ --ag-ui-launcher-bg: #3d5afe;
3623
+ --ag-ui-launcher-fg: #ffffff;
3624
+ --ag-ui-tool-fg: #3d5afe;
3625
+ }
3626
+ ```
3627
+
3628
+ - **The header buttons follow `--ag-ui-header-fg`, under every theme.** Unset, each theme draws
3629
+ them its own way at 0.85 opacity: Quiet, Soft and Ledger in their third ink, Classic and Code in
3630
+ the header text, each on its own hover square. Once you set the header text they draw in it at
3631
+ full opacity, and the hover square is the opposite of that text at about a fifth (a darker square
3632
+ under white text, a lighter one under dark text), so a hovered button reads at least what your
3633
+ header pair reads. A theme word of your own starts from Quiet, so it holds there too. No
3634
+ `::part(header-button)` rule is needed. The opposite is computed with relative
3635
+ colour syntax; the browsers without it (see the fallback above) mix the square from the text
3636
+ itself, which on a mid-tone header can take the hovered glyph under your pair (white on
3637
+ `#3d5afe`: 3.75:1).
3638
+ - **`--ag-ui-header-btn-hover` sets the hover square outright** under every theme, whether or not
3639
+ the header text is set, and is drawn exactly as given. For a square that lightens under white
3640
+ text instead, at the cost of contrast on a mid-tone header:
3641
+
3642
+ ```css
3643
+ ag-ui-chat { --ag-ui-header-btn-hover: color-mix(in srgb, #ffffff 18%, transparent); }
3644
+ ```
3645
+ - **`--ag-ui-tool-fg` colours every tool call**, in a step group as well as in the card displays.
3646
+ Unset, a grouped call is drawn in the second ink (`--ag-ui-ink-2`) under every theme.
3647
+ - Pick the pair yourself: `--ag-ui-header-fg` is not derived from the header colour the way
3648
+ `--ag-ui-on-accent` is from the accent.
3649
+
3650
+ #### Overriding a theme, lightest layer first
3651
+
3652
+ Four layers, each documented and each lighter than the next. Reach for the first one that does
3653
+ the job.
3654
+
3655
+ ##### (a) Tokens
3656
+
3657
+ Any `--ag-ui-*` token, on the element or any ancestor, as above. Per theme, from your own CSS:
3658
+
3659
+ ```css
3660
+ body { --ag-ui-accent: #0f766e; } /* accent only: the rest follows */
3661
+ ag-ui-chat[theme~="soft"] { --ag-ui-accent: #c2410c; --ag-ui-frame-radius: 12px; }
3662
+ ```
3663
+
3664
+ A page rule always beats the theme's own value, because the theme only ever sets the private
3665
+ alias behind the public token.
3666
+
3667
+ ##### (b) Parts
3668
+
3669
+ `::part()` reaches the structural elements, as listed in [Parts and slots](#parts-and-slots):
3670
+
3671
+ ```css
3672
+ ag-ui-chat::part(header) { background: linear-gradient(#f2f7f6, transparent); }
3673
+ ```
3674
+
3675
+ ##### (c) A theme of your own
3676
+
3677
+ Name it, and style the name. An unknown word adds no built-in rules, so it starts from Quiet (or
3678
+ from the built-in theme beside it):
3679
+
3680
+ ```html
3681
+ <ag-ui-chat theme="acme dark"></ag-ui-chat>
3682
+ ```
3683
+
3684
+ ```css
3685
+ ag-ui-chat[theme~="acme"] {
3686
+ --ag-ui-bg: light-dark(#fbfaf7, #10161c);
3687
+ --ag-ui-fg: light-dark(#14212b, #e4ebf0);
3688
+ --ag-ui-accent: light-dark(#0b5cad, #6cb2ff);
3689
+ --ag-ui-frame-radius: 4px;
3690
+ }
3691
+ ```
3692
+
3693
+ Use `light-dark()` in the seeds and the scheme word, and the theme toggle, work for your theme
3694
+ too.
3695
+
3696
+ ##### (d) `extraStyles`: the last mile
3697
+
3698
+ > **Unstable.** `extraStyles` reaches the class names inside the shadow root, and those are not a
3699
+ > public API: they may change in any minor release. Layers (a) to (c) are the supported ones; use
3700
+ > this for what they cannot reach, and expect to revisit it on upgrade.
3701
+
3702
+ ```ts
3703
+ const sheet = new CSSStyleSheet();
3704
+ sheet.replaceSync(`.message--assistant { padding: 10px 12px; border-radius: 10px; }`);
3705
+ chat.extraStyles = [sheet];
3706
+ ```
3707
+
3708
+ - `extraStyles: readonly CSSStyleSheet[]`, per element, default `[]`. Two elements can carry
3709
+ different sheets.
3710
+ - Adopted after the built-in sheet, so a rule of equal specificity wins.
3711
+ - Setting it re-adopts at once. The array is copied, so changing the one you passed does nothing
3712
+ until you set it again; changing a sheet's rules does apply, since the sheet itself is shared.
3713
+ - Constructed sheets are not subject to `style-src`, so this works under the same strict CSP as
3714
+ the built-in sheet. There is no string form and no `<style>` injection.
3715
+
3716
+ ### Small viewports
3717
+
3718
+ At **600px wide and below** every placement but `embedded` becomes one full-bleed
3719
+ shape: edge to edge, no radius, no shadow, no resize grips. A phone is not an
3720
+ eighth placement — it is an override that collapses the others onto one of them.
3721
+ The host picked a placement for the desktop it was designing, and a 380x560
3722
+ panel with a 24px margin is not a smaller version of that decision, it is most
3723
+ of the screen with a frame drawn round it.
3724
+
3725
+ `embedded` is left alone deliberately: it sits in a box you sized and placed, and
3726
+ only you know whether that column should become the whole screen.
3727
+
3728
+ The [greeting layout](#the-greeting-on-an-empty-page) changes shape here too, and
3729
+ it is the one part of this that `embedded` gets as well, because it moves nothing
3730
+ about the box you placed: an empty conversation keeps its composer at the foot of
3731
+ the panel, with the starters against it and the greeting over the space above
3732
+ them, instead of centring all three together.
3733
+
3734
+ The corner placements still rest at their launcher, so a full-bleed panel is
3735
+ something the user opens rather than something they are given.
3736
+
3737
+ **To keep your desktop layout at every width**, set `data-small-viewport="off"`.
3738
+ That exists because the *trigger* is the one part of this you cannot reach: every
3739
+ value the override sets is a `--ag-ui-*` token you can re-state, but a media
3740
+ query cannot read a custom property, so the breakpoint itself is a literal.
3741
+
3742
+ **A size or inset token set at every width restates the phone shape too.** The phone
3743
+ block reads `--ag-ui-inset`, `--ag-ui-width`, `--ag-ui-height` and `--ag-ui-max-height`
3744
+ before it falls back to the full-bleed values, so a host rule such as the one below keeps
3745
+ its 420x640 panel with a 24px margin on a phone, and the panel runs off the screen's left
3746
+ edge. Put the desktop sizes above the breakpoint and the phone shape needs nothing:
3747
+
3748
+ ```css
3749
+ /* Wrong on a phone: the tokens apply at every width. */
3750
+ ag-ui-chat {
3751
+ --ag-ui-inset: auto 24px 24px auto;
3752
+ --ag-ui-width: 420px;
3753
+ --ag-ui-height: 640px;
3754
+ --ag-ui-max-height: 80vh;
3755
+ }
3756
+
3757
+ /* Right: desktop only. At 600px and below the panel is full-bleed with no reset. */
3758
+ @media (min-width: 601px) {
3759
+ ag-ui-chat {
3760
+ --ag-ui-inset: auto 24px 24px auto;
3761
+ --ag-ui-width: 420px;
3762
+ --ag-ui-height: 640px;
3763
+ --ag-ui-max-height: 80vh;
3764
+ }
3765
+ }
3766
+ ```
3767
+
3768
+ Setting the tokens at every width stays right for a host that wants a non-full-bleed phone
3769
+ panel, a bottom sheet for example. The size a user dragged is a separate matter; see
3770
+ [Sizes, keyboard, and parts](#sizes-keyboard-and-parts) for its precedence.
3771
+
3772
+ The breakpoint is a width rather than a pointer test, and that is on purpose: a
3773
+ touch laptop is coarse-pointered and wide, a narrow desktop window is
3774
+ fine-pointered and small. Width decides the layout; the pointer decides which
3775
+ controls make sense.
3776
+
3777
+ ### The conversation list
3778
+
3779
+ The chat-history list slides over the transcript at every width and under every
3780
+ placement, a full page included. Opening it moves nothing underneath: the
3781
+ conversation, the centred greeting and the composer are exactly where they were.
3782
+ While it is open it is a modal dialog: focus moves into it, Tab stays inside it,
3783
+ and Escape, its close control or a click on the backdrop beside it closes it and
3784
+ returns focus to where it was. `openThreads()` and `closeThreads()` do the same
3785
+ from your own chrome.
3786
+
3787
+ The list also grows a filter once there are eight or more conversations in it
3788
+ — above that a search box is worth having, below it it is a control asking to be
3789
+ used on a list you can already read in one glance. It matches the title **and**
3790
+ the preview, because the title is often the model's one-line summary and the
3791
+ phrase you remember is as likely to be inside the conversation as on it, and it
3792
+ filters what the drawer already holds rather than going back to the server for a
3793
+ list that is already in memory. `data-threads-search="always"` shows it over any
3794
+ list and `"never"` hides it, for a host whose lists are always short or always long.
3795
+
3796
+ The rows are grouped by when they were last active: **Today**, **Previous 7 days**
3797
+ and **Older**, counted from local midnight. A group with no rows is not drawn,
3798
+ and a list that falls in one group draws no heading at all. A conversation with no
3799
+ readable date goes under Older. A filtered list keeps its groups. The headings
3800
+ are not in the Tab order, are styled as `::part(drawer-group-label)`, and take
3801
+ their text from the `threadsToday`, `threadsPreviousWeek` and `threadsOlder`
3802
+ strings. Every drawer has them: the slide-over, the bar's popover and the popover
3803
+ beside your own button.
3804
+
3805
+ ### Reserving the space your own chrome occupies
3806
+
3807
+ A fixed placement covers the viewport it is given, and it does not know about
3808
+ your sticky header. Tell it which edges are already spent and every placement
3809
+ does its own arithmetic:
3810
+
3811
+ ```css
3812
+ ag-ui-chat {
3813
+ --ag-ui-viewport-inset-top: 64px; /* your nav bar */
3814
+ }
3815
+ ```
3816
+
3817
+ `page` and `full` inset by all four edges; `sidebar` and `side` by three, leaving
3818
+ the docked edge free; `floating` adds them to its own margins.
3819
+ **The heights follow on their own** — that is the point of these rather than
3820
+ restating `--ag-ui-inset` per placement, which leaves you to keep
3821
+ `--ag-ui-height` and `--ag-ui-max-height` in step by hand and overflows the panel
3822
+ off the bottom of the screen the one time you forget.
3823
+
3824
+ Four longhands rather than one shorthand because a custom property is a token
3825
+ stream and CSS cannot index one; the height arithmetic needs the vertical pair on
3826
+ its own. Any CSS length works — `px`, `rem`, `env(safe-area-inset-*)`, or a
3827
+ `calc()` combining them; the widget reads the resolved value rather than the text
3828
+ you wrote, so the number it clamps against is the one the stylesheet uses:
3829
+
3830
+ ```css
3831
+ ag-ui-chat {
3832
+ --ag-ui-viewport-inset-top: env(safe-area-inset-top);
3833
+ --ag-ui-viewport-inset-bottom: env(safe-area-inset-bottom);
3834
+ }
3835
+ ```
3836
+
3837
+ If your chrome changes height — a bar that wraps at narrow widths — measure it
3838
+ and publish the value, since no CSS length tracks it:
3839
+
3840
+ ```js
3841
+ new ResizeObserver(() => {
3842
+ document.documentElement.style.setProperty("--bar-h", `${bar.offsetHeight}px`);
3843
+ }).observe(bar);
3844
+ ```
3845
+
3846
+ ```css
3847
+ ag-ui-chat { --ag-ui-viewport-inset-top: var(--bar-h, 0px); }
3848
+ ```
3849
+
3850
+ `--ag-ui-edge-gutter` (default `24px`) is the gap a resting `floating` panel
3851
+ keeps between itself and that box. The size cap subtracts the same one, so a
3852
+ panel grown to its limit reaches the far edge of the usable box and no further —
3853
+ set it to `0` for a panel flush against the corner.
3854
+
3855
+ `--ag-ui-anchor-gap` (default `8px`) is the gap between the panel and the button
3856
+ it rises from under `collapse-to="none"`; see [Collapsing to a bottom
3857
+ bar](#collapsing-to-a-bottom-bar).
3858
+
3859
+ `--ag-ui-keyboard-inset` overrides the lift an on-screen keyboard earns. The
3860
+ widget measures the hidden band and publishes it as
3861
+ `--ag-ui-visual-viewport-inset-bottom`; state this one instead to outrank that
3862
+ measurement, or set it to `0px` to opt out of the lift entirely.
3863
+
3864
+ `--ag-ui-keyboard-inset-top` does the same at the top. To show a field a
3865
+ keyboard would cover, a mobile browser pans the visible area down the page, and
3866
+ a panel anchored to the screen (`page`, `full`, `side`, `sidebar`, and at phone
3867
+ width the corner placements too) moves down with it. An `embedded` panel renders
3868
+ in your own box rather than against the screen, so it does not move at any width.
3869
+ The widget publishes the pan as `--ag-ui-visual-viewport-inset-top`, and the
3870
+ panel moves by as much of it as goes past your `--ag-ui-viewport-inset-top`,
3871
+ since the pan scrolls a reserved bar away with the page. State
3872
+ `--ag-ui-keyboard-inset-top` to outrank that distance, or `0px` to keep the
3873
+ panel below your reserved top.
3874
+
3875
+ `--ag-ui-viewport-height` and `--ag-ui-viewport-width` state the usable box
3876
+ outright, for the case where no viewport-percentage length describes it. An
3877
+ on-screen keyboard is the one that matters: it changes neither `vh` nor `dvh` nor
3878
+ `svh` on any current mobile browser, so a full-bleed panel has to be told the
3879
+ visual viewport's height rather than deriving it. The widget does that itself;
3880
+ where it has nothing to report, the panel falls back to `100dvh` rather than
3881
+ `100vh`, because iOS Safari resolves `vh` to the screen with its bars collapsed
3882
+ and a panel sized from it runs under them.
3883
+
3884
+ Marks are variables too, so one vocabulary covers a re-theme rather than
3885
+ leaving half the transcript in the built-in set: `--ag-ui-tool-icon-done` /
3886
+ `-error` / `-declined` for tool status, and `--ag-ui-disclosure-collapsed` /
3887
+ `--ag-ui-disclosure-expanded` for every expandable row.
3888
+
3889
+ ```css
3890
+ ag-ui-chat {
3891
+ --ag-ui-disclosure-collapsed: "+";
3892
+ --ag-ui-disclosure-expanded: "-";
3893
+ }
3894
+ ```
3895
+
3896
+ ### Where to put the variables
3897
+
3898
+ There is one vocabulary — the `--ag-ui-*` names above — and it works from any ancestor. What
3899
+ differs is only *which* declaration wins, and that is ordinary CSS inheritance:
3900
+
3901
+ ```css
3902
+ /* A whole page or design-system scope. */
3903
+ :root { --ag-ui-accent: var(--brand-600); --ag-ui-radius: 4px; }
3904
+
3905
+ /* One region — the widget picks this up through the wrapper. */
3906
+ aside.support-dock { --ag-ui-accent: #0f766e; }
3907
+
3908
+ /* One widget. Beats both of the above, because it targets the element. */
3909
+ ag-ui-chat#support { --ag-ui-accent: #b91c1c; }
3910
+
3911
+ /* Set at runtime with el.style.setProperty(...) — an inline style beats all of the above. */
3912
+ ```
3913
+
3914
+ Two that do not work:
3915
+
3916
+ ```css
3917
+ /* ::part() reaches structural elements, not variables — a custom property set
3918
+ here applies to that part's own subtree, not to the whole shell. */
3919
+ ag-ui-chat::part(panel) { --ag-ui-accent: #b91c1c; }
3920
+
3921
+ /* The internal --_* aliases are private and unversioned; they are renamed
3922
+ without notice. Always set the public --ag-ui-* name. */
3923
+ ag-ui-chat { --_accent: #b91c1c; }
3924
+ ```
3925
+
3926
+ The preset attributes below sit *underneath* anything you declare: a `theme="dark"` widget still
3927
+ honours an explicit `--ag-ui-bg` from your CSS, so you can adopt a preset and correct one token
3928
+ rather than re-declaring the whole palette.
3929
+
3930
+ `--ag-ui-accent` reaches further than the widget: it also colours the rings the DOM driver draws
3931
+ on **your** page. Each primitive reads it from the computed style of the element it is about to
3932
+ touch, so setting it on `:root` (or on any ancestor of the elements the agent drives) themes the
3933
+ highlights too. Without it they fall back to the package indigo.
3934
+
3935
+ For the common cases there are three CSS-reactive **preset attributes** (no JS API), so you don't
3936
+ have to hand-tune the variables:
3937
+
3938
+ - `theme` — a theme name and a scheme; see [Themes](#themes).
3939
+ - `density` — `comfortable` (default) / `compact`.
3940
+ - `placement` — `floating` (default) / `sidebar` / `page` / `embedded`. `embedded` drops the
3941
+ fixed positioning and z-index so the widget sits in normal document flow; `page` is a
3942
+ full-screen [centred reading column](#page-placement) for a route of its own.
3943
+
3944
+ Four, because those are the four shapes that differ structurally: a corner panel, a docked
3945
+ rail, a surface that owns the screen, and a thing in your layout. Three older values --
3946
+ `bottom-left`, `side` and `full` -- still parse and still work, and are no longer documented:
3947
+ each is a variant of one of the four rather than a shape of its own. `full` is `page` with
3948
+ `--ag-ui-content-max-width: none`, `bottom-left` is `floating` with a different
3949
+ `--ag-ui-inset`, and `side` is `sidebar` that collapses to the floating launcher instead of
3950
+ an edge rail. Nothing warns and nothing breaks; there is simply less to choose between.
3951
+
3952
+ **`embedded` fills the box your page gives it, so give it one.** It is the placement app-shell
3953
+ layouts reach for, and a grid or flex item defaults to `min-height: auto` — which lets a growing
3954
+ transcript push the composer off the bottom of the window instead of scrolling inside the panel. The
3955
+ fix belongs to the containing element, not to the widget:
3956
+
3957
+ ```css
3958
+ .assistant-pane { min-height: 0; overflow: hidden; } /* the box the element is given */
3959
+ ```
3960
+
3961
+ ```html
3962
+ <ag-ui-chat endpoint="/agent/" theme="dark" density="compact" placement="sidebar"></ag-ui-chat>
3963
+ ```
3964
+
3965
+ See [`src/ui/shell/token_styles.ts`](src/ui/shell/token_styles.ts) for the full variable list and
3966
+ [`src/ui/placement/`](src/ui/placement/) for the placement presets. The
3967
+ [`demo/`](demo/) live playground (`node demo/mock-server.mjs`) flips theme, density, placement,
3968
+ text-animation, tool-display, and the answer well live from a single page, and demos the
3969
+ streamed thoughts region, the mic, and the header theme toggle.
3970
+
3971
+ It binds every interface and prints the addresses this machine can be reached on, so you can open the playground on a phone — which is the only way to see the small-viewport layout with a real on-screen keyboard rather than a resized desktop window. `HOST=127.0.0.1` keeps it to this machine.
3972
+
3973
+ ### Parts and slots
3974
+
3975
+ For styling beyond the `--ag-ui-*` variables, every structural element exposes a `part` so you can
3976
+ reach it from outside the Shadow DOM with `::part()` — no shadow piercing. The part names are
3977
+ **public API** (additions are non-breaking; renames are breaking):
3978
+
3979
+ ```css
3980
+ ag-ui-chat::part(panel) { border-radius: 0; }
3981
+ ag-ui-chat::part(header) { background: #111; }
3982
+ ag-ui-chat::part(send) { text-transform: uppercase; }
3983
+ ag-ui-chat::part(tool-card) { font-family: var(--my-mono); }
3984
+ ```
3985
+
3986
+ Every part, by the feature it belongs to. Spelled out rather than abbreviated: the list used to
3987
+ read `tool-card` *plus* `-icon` / `-name`, which is compact and is also how an entire feature went
3988
+ missing from it for two releases. A test reads this table and compares it with the parts the
3989
+ component sets, so a new one cannot ship undocumented.
3990
+
3991
+ | Feature | Parts |
3992
+ | --- | --- |
3993
+ | Shell | `panel`, `header`, `title`, `icon`, `header-controls`, `messages`, `empty`, `greeting`, `pending`, `stopped`, `jump-latest`, and one per resize grip: `resize-handle` plus `resize-handle-top`, `resize-handle-bottom`, `resize-handle-left`, `resize-handle-right`, `resize-handle-top-left`, `resize-handle-top-right`, `resize-handle-bottom-left`, `resize-handle-bottom-right` |
3994
+ | Header buttons | `header-button` on each, plus `history-button`, `checkpoints-button`, `new-button`, `maximize-button`, `collapse-button`, `theme-toggle` |
3995
+ | Collapsed widget | `launcher`, `launcher-icon`, `launcher-badge`, `rail-label`, `bar` (the [bottom bar](#collapsing-to-a-bottom-bar)'s wrapper, around the launcher), `bar-place` (the place beside the bar's title; its text carries `place-label` and `place-detail`), `bar-history` |
3996
+ | Answers | `answer` (the per-turn group), `message` (plus `message-user`, `message-assistant`), `code-copy` |
3997
+ | Reasoning | `thoughts`, `thoughts-toggle`, `thoughts-body`, `thoughts-label` |
3998
+ | Follow-up suggestions | `suggestions`, `suggestion-chip` |
3999
+ | Message actions | `message-actions`, `message-action` (plus `message-action-retry`, `message-action-copy`, `message-action-up`, `message-action-down`), and the icon holder inside each: `message-action-icon` (plus `message-action-icon-retry`, `message-action-icon-copy`, `message-action-icon-up`, `message-action-icon-down`) |
4000
+ | Queued messages | `queued`, `queued-chip` |
4001
+ | Run notices | `run-notice` (plus `run-notice-interrupted`, `run-notice-attachment-pending`, `run-notice-compaction`, `run-notice-skill`, `run-notice-history-replaced`, `run-notice-chart-undrawable`, `run-notice-surface`, `run-notice-not-connected`), `run-notice-icon`, `run-notice-text`, `run-notice-undo` |
4002
+ | Tool cards | `tool-card` (plus one status modifier: `tool-card-pending`, `tool-card-deferred`, `tool-card-done`, `tool-card-error`, `tool-card-declined`, `tool-card-interrupted`), `tool-card-head`, `tool-card-icon`, `tool-card-name`, `tool-card-status` (plus `tool-card-status-pending`, `tool-card-status-deferred`, `tool-card-status-done`, `tool-card-status-error`, `tool-card-status-declined`, `tool-card-status-interrupted`), `tool-card-decision`, `tool-card-toggle`, `tool-card-body`, `tool-card-section` (plus `tool-card-args-section`, `tool-card-result-section`), `tool-card-section-label` (plus `tool-card-args-label`, `tool-card-result-label`), `tool-card-args`, `tool-card-result`, `tool-card-approval`, `tool-card-subagent`, `tool-card-repeat` |
4003
+ | Tool-step group | `tool-group` (plus one state modifier: `tool-group-waiting`, `tool-group-running`, `tool-group-done`, `tool-group-error`), `tool-group-head`, `tool-group-icon`, `tool-group-label`, `tool-group-meta`, `tool-group-list` |
4004
+ | Delegated sub-agents | `subagent`, `subagent-row`, `subagent-icon`, `subagent-status`, `subagent-steps`, `subagent-step`, `subagent-step-icon`, `subagent-step-name` |
4005
+ | Client-side confirmation | `confirm`, `confirm-body`, `confirm-args`, `confirm-actions`, `confirm-button` (plus `confirm-confirm`, `confirm-cancel`, `confirm-always`) |
4006
+ | Server-side approval | `approval`, `approval-body`, `approval-actions`, `approval-button` (plus `approval-approve`, `approval-deny`), `approval-edit`, `approval-args`, `approval-error` |
4007
+ | Typed question | `question`, `question-body`, `question-options`, `question-choice`, `question-choice-text`, `question-radio`, `question-input`, `question-actions`, `question-button` |
4008
+ | Composer | `composer`, `composer-surface`, `composer-tools`, `input`, `send`, `attach-button`, `voice-button` |
4009
+ | Place chip | `place` (carries `data-excluded` while the user withholds the place), `place-icon`, `place-label`, `place-detail`, `place-toggle` |
4010
+ | Attachments | `attachment-tray`, `attachment-chips` (the read-only chips on sent bubbles), and the shared chip parts `attachment-chip`, `attachment-chip-icon`, `attachment-chip-name`, `attachment-chip-size`, `attachment-chip-bar`, `attachment-chip-bar-fill`, `attachment-chip-retry`, `attachment-chip-remove` |
4011
+ | Skills | `skill-chips`, `skill-chip`, `skill-palette`, `skill-item`, `skill-item-title`, `skill-item-desc`, `skill-item-token`, `skill-hint` (the composer hint: a skill’s missing placeholders, and a run continuation picked with nothing typed or while a run is in flight) |
4012
+ | Thread drawer | `drawer`, `drawer-backdrop`, `drawer-panel`, `drawer-header`, `drawer-title`, `drawer-new`, `drawer-close`, `drawer-filter`, `drawer-list`, `drawer-empty`, `drawer-group-label`, `drawer-row`, `drawer-row-select`, `drawer-row-title`, `drawer-row-time`, `drawer-row-preview`, `drawer-row-actions`, `drawer-row-rename`, `drawer-row-delete`, `drawer-rename-input`, `drawer-confirm`, `drawer-confirm-label`, `drawer-confirm-yes`, `drawer-confirm-no` |
4013
+ | Charts | `chart-block`, `chart-title`, `chart-legend` |
4014
+ | Checkpoints panel | `checkpoints`, `checkpoints-header`, `checkpoints-title`, `checkpoints-list`, `checkpoints-empty`, `checkpoint-row`, `checkpoint-label`, `checkpoint-time`, `checkpoint-id`, `checkpoint-branch`, `checkpoint-action` (plus `checkpoint-resume`, `checkpoint-fork`) |
4015
+
4016
+ > **Hiding `::part(header)` hides the controls inside it.** The history, checkpoints, new-chat,
4017
+ > theme and collapse buttons are all children of the header, so a host that renders its own title
4018
+ > bar and does `ag-ui-chat::part(header) { display: none }` loses thread switching entirely. Every
4019
+ > one of them has an imperative equivalent, so your own chrome can drive them:
4020
+ >
4021
+ > | Control | Method |
4022
+ > | --- | --- |
4023
+ > | History drawer | `chat.openThreads()` |
4024
+ > | Checkpoints panel | `chat.openCheckpoints()` |
4025
+ > | New chat | `chat.newChat()` |
4026
+ > | Collapse | `chat.toggleCollapsed()` / `chat.setCollapsed(bool)` |
4027
+ > | Theme toggle | `chat.toggleTheme()` |
4028
+ >
4029
+ > ```js
4030
+ > myHeaderButton.onclick = () => chat.openThreads();
4031
+ > ```
4032
+ >
4033
+ > The built-in buttons call exactly these methods, so the two routes cannot drift. If you only want
4034
+ > to restyle the header, prefer `::part(header)` styling or the `header-actions` slot over hiding it.
4035
+ >
4036
+ > To drop single controls instead, hide their parts:
4037
+ > `ag-ui-chat::part(history-button), ag-ui-chat::part(new-button) { display: none }`.
4038
+ > `display: none` removes them from the accessibility tree and the tab order as well.
4039
+
4040
+ Coarse **slots** let you replace whole regions with your own markup (project light-DOM children
4041
+ with a matching `slot=`):
4042
+
4043
+ | Slot | Where |
4044
+ | --- | --- |
4045
+ | `icon` | A header brand icon, before the title. |
4046
+ | `header-actions` | Extra controls between the title and the built-in buttons. |
4047
+ | `greeting` | The greeting's text, above the empty state, where the [greeting layout](#the-greeting-on-an-empty-page) is on. Replaces the text only; `empty` and the starters still render beneath it. |
4048
+ | `empty` | The empty-state shown before any message. |
4049
+ | `above-composer` | Directly above the composer, below the skill chips, hint, queued turns and attachment tray. Takes no space when empty, and moves with the composer in the [greeting layout](#the-greeting-on-an-empty-page). |
4050
+ | `footer` | Below the composer. |
4051
+ | `launcher` | The collapsed widget's mark — the floating launcher, or the sidebar rail. |
4052
+ | `icon-send` / `icon-stop` | The composer button's two glyphs (idle and mid-run). |
4053
+ | `icon-attach` / `icon-voice` | The paperclip and mic glyphs. |
4054
+ | `icon-history` / `icon-checkpoints` / `icon-new` / `icon-maximize` / `icon-collapse` | The header controls' glyphs. A slotted `icon-collapse` wins over `data-collapse-icon`. |
4055
+
4056
+ ```html
4057
+ <ag-ui-chat endpoint="/agent/">
4058
+ <img slot="icon" src="/logo.svg" alt="" />
4059
+ <button slot="header-actions" onclick="openHelp()">?</button>
4060
+ </ag-ui-chat>
4061
+ ```
4062
+
4063
+ ### Header and launcher icon
4064
+
4065
+ Give the header a brand icon with either the `icon` slot (any markup) or the `data-icon-url`
4066
+ convenience attribute (an `<img>`); the slot wins when both are set, and with neither the header
4067
+ stays icon-less. The same icon seam feeds the collapsed sidebar rail. Size it via
4068
+ `--ag-ui-icon-size` (default `22px`) and round it with `--ag-ui-icon-radius` (default `4px`).
4069
+
4070
+ ```html
4071
+ <ag-ui-chat endpoint="/agent/" data-icon-url="/logo.png"></ag-ui-chat>
4072
+ ```
4073
+
4074
+ ### Sidebar placement
4075
+
4076
+ `placement="sidebar"` is a full-height **docked** panel that slides open/closed and collapses to a
4077
+ slim **icon rail** rather than the [floating launcher](#collapsing-to-the-launcher) — the same
4078
+ element, shaped by the placement. It docks right by default; `data-side="left"` docks it left. The
4079
+ panel slides out through the edge it docks against. Collapse state reuses the `collapsed` attribute
4080
+ (persisted per-tab), and the rail carries `aria-expanded`. The slide honours
4081
+ `prefers-reduced-motion`.
4082
+
4083
+ ```html
4084
+ <ag-ui-chat endpoint="/agent/" placement="sidebar" data-side="left"></ag-ui-chat>
4085
+ ```
4086
+
4087
+ It overlays the page by default (no host-layout coupling). To make the host content reflow around
4088
+ it instead, set `--ag-ui-position: static` and place the element in your own grid/flex layout.
4089
+
4090
+ ### Page placement
4091
+
4092
+ `placement="page"` turns the widget into a full-screen chat **page**: a full-bleed background with
4093
+ the conversation in a centred reading column (default ~820px, set via `--ag-ui-content-max-width`).
4094
+ The assistant turn spans the column width while the user message stays a right-aligned pill. Unlike
4095
+ `full` (edge-to-edge, left-aligned), it's the layout you want for a dedicated `/chat` route. Pairs
4096
+ naturally with the [answer well](#the-answer-well).
4097
+
4098
+ ```html
4099
+ <ag-ui-chat endpoint="/agent/" placement="page" data-answer-well></ag-ui-chat>
4100
+ ```
4101
+
4102
+ #### The greeting on an empty page
4103
+
4104
+ Until the first message is sent, a page shows a greeting with the composer centred beneath it,
4105
+ and the composer moves to the foot of the page once the conversation has something in it. On a
4106
+ phone it starts at the foot, for the reason below.
4107
+
4108
+ ```html
4109
+ <ag-ui-chat endpoint="/agent/" placement="page" user-name="Ada"></ag-ui-chat>
4110
+ ```
4111
+
4112
+ - **The text** is `Hello, {name}`, filled from `user-name`, or `Hello there` when there is no
4113
+ name. Both are string-table keys, `greeting` and `greetingNoName`, so `strings` and
4114
+ `data-strings` translate them like everything else. `user-name` is live: a name that arrives
4115
+ after an auth handshake replaces the greeting on screen.
4116
+ - **Your own greeting** goes in `slot="greeting"`, which replaces the text and nothing else. A
4117
+ brand mark belongs there too. The `empty` slot and `data-starters` still render beneath it.
4118
+ - **Styling**: the `greeting` part, plus `--ag-ui-greeting-font` (default: the widget's font) and
4119
+ `--ag-ui-greeting-size` (default `clamp(1.5rem, 4vw, 2.25rem)`, scaling with the viewport rather
4120
+ than stepping at a breakpoint).
4121
+ - **`data-empty`** is present on the element while the conversation is empty, for chrome of your
4122
+ own that should react to the same state.
4123
+ - **Which placements**: on by default for `page`, which is a route of its own. `data-greeting="off"`
4124
+ restores the plain layout there. `embedded` opts in with `data-greeting` (any value but `off`),
4125
+ for an app shell that gives the panel a page-sized box. The corner placements and the sidebar
4126
+ never show it: a panel opened from a launcher is already mid-task.
4127
+ - **On a phone** the composer stays at the foot, the starters (or your own `empty` content) sit
4128
+ against it, and the greeting takes the middle of what they leave — the middle of the
4129
+ transcript when there are none. Centring is a shape for a screen with room to spare; at
4130
+ [600px wide and below](#small-viewports) the room is what the on-screen keyboard takes, and a
4131
+ composer centred over an empty band sits halfway up what is left of the screen, with the
4132
+ prompts that start a conversation nowhere near the field that sends one.
4133
+ `data-small-viewport="off"` keeps the centred shape at every width, as it keeps the rest of
4134
+ the desktop layout.
4135
+
4136
+ The composer's rows stay centred while you type, while a draft grows and while attachments are
4137
+ added, because the centring is two equal flexible halves either side of them and growth splits
4138
+ into both. Nothing in the DOM moves, so focus, the caret and IME composition are unaffected.
4139
+
4140
+ **Sending is what moves it**, not typing, and the composer slides to the foot of the page over
4141
+ `--ag-ui-motion` (a single frame under reduced motion). Everything else that changes the
4142
+ conversation snaps: a restored conversation mounts docked, picking a thread from the list or
4143
+ continuing a run docks at once, and a new chat returns to the centre at once. While a slow
4144
+ conversation store is still fetching, the page stays docked with the greeting hidden, so a
4145
+ restored conversation never paints the empty layout first.
4146
+
4147
+ ### The answer well
4148
+
4149
+ Each assistant turn renders inside one `.answer` group (part `answer`) that holds its streamed
4150
+ text, tool cards, and pending indicator — so a turn that calls tools reads as a single answer
4151
+ rather than a string of loose siblings. Add the boolean `data-answer-well` attribute to box that
4152
+ group in a bordered, padded "well"; without it the layout is the flat stack as before. The well is
4153
+ pure CSS and turn-scoped — no JS API — and themeable via `--ag-ui-well-bg`, `--ag-ui-well-border`
4154
+ and `--ag-ui-well-radius` (and `::part(answer)`).
4155
+
4156
+ The well's corners default to `--ag-ui-radius`, 12px unless you set it, in every placement. The
4157
+ panel's own frame is different: `page` and `sidebar` draw it square because it meets the edge of
4158
+ the window, but nothing inside the panel does, so the well stays round there too. Set
4159
+ `--ag-ui-well-radius` to round the well differently from the rest of the panel.
4160
+
4161
+ ```html
4162
+ <ag-ui-chat endpoint="/agent/" data-answer-well></ag-ui-chat>
4163
+ ```
4164
+
4165
+ ### Model reasoning (thoughts)
4166
+
4167
+ When a reasoning model streams its chain-of-thought (django-ag-ui forwards it as AG-UI reasoning
4168
+ events — enable a thinking budget via `MODEL_SETTINGS`, see its docs), the element renders a muted,
4169
+ collapsible **thoughts region** (part `thoughts`) at the top of the current answer group. It opens
4170
+ while the model reasons and folds away on the answer's first token; the reader can reopen it. The
4171
+ web component handles the `REASONING_*` event family (and the retired `THINKING_*`, which
4172
+ `@ag-ui/client` still converts onto it, with a console warning, during its deprecation window), so
4173
+ no client config is needed — the thoughts appear whenever the server forwards reasoning.
4174
+
4175
+ The header is a toggle only once reasoning has text: a provider that opens and closes reasoning
4176
+ without sending any (redacted thinking, or summaries off) gets a plain label with no body, and no
4177
+ `thoughts-toggle` part. When reasoning ends the header settles to how long it took, string
4178
+ `thoughtFor` (`"Thought for {seconds}s"`, the sum of the turn's reasoning rounds); under a second it
4179
+ reads `thoughts`. A tool call likewise offers its details only over something: with no arguments
4180
+ and a blank result its toggle, and a one-step group's row, are not offered until a result with text
4181
+ arrives.
4182
+
4183
+ ### The composer
4184
+
4185
+ The composer is one bordered surface (part `composer-surface`) that owns the border and the focus
4186
+ ring: the field sits on top and grows with what is typed until it hits its ceiling and scrolls,
4187
+ and a tool row (part `composer-tools`) sits underneath with the paperclip and mic as quiet icon
4188
+ buttons on the left and a circular **Send** on the right. Send is icon-only — its accessible name
4189
+ still comes from the `send` / `stop` [strings](#internationalization-i18n) — and it becomes the
4190
+ Stop control mid-run by swapping its glyph, so nothing moves when a run starts.
4191
+
4192
+ ```css
4193
+ ag-ui-chat {
4194
+ --ag-ui-composer-radius: 14px;
4195
+ --ag-ui-composer-max-height: 40vh; /* where the growing field starts scrolling */
4196
+ --ag-ui-tool-btn-size: 30px; /* the paperclip / mic hit targets */
4197
+ --ag-ui-send-size: 30px; /* the send circle */
4198
+ --ag-ui-glyph-size: 18px;
4199
+ --ag-ui-glyph-stroke: 1.75;
4200
+ }
4201
+ ```
4202
+
4203
+ Every glyph is a slot with the built-in mark as its fallback (`icon-send`, `icon-stop`,
4204
+ `icon-attach`, `icon-voice`), so projecting your own icon set never means restyling a character.
4205
+
4206
+ ### Motion
4207
+
4208
+ One duration and two curves drive every collapse, expand and slide-over, so the whole widget
4209
+ settles as one thing:
4210
+
4211
+ ```css
4212
+ ag-ui-chat {
4213
+ --ag-ui-motion: 0.28s;
4214
+ --ag-ui-ease: cubic-bezier(0.32, 0.72, 0, 1); /* the settle */
4215
+ --ag-ui-ease-pop: cubic-bezier(0.34, 1.36, 0.64, 1); /* the arrival, with overshoot */
4216
+ }
4217
+ ```
4218
+
4219
+ Under `prefers-reduced-motion: reduce` the duration collapses to a single frame: states still
4220
+ change, nothing travels. Set `--ag-ui-motion: 0s` to switch the animation off outright.
4221
+
4222
+ ### Voice input
4223
+
4224
+ Set `data-transcribe-url` (django-ag-ui's `TranscribeView`) to reveal a mic button in the
4225
+ composer (part `voice-button`). Click it to record via `MediaRecorder`, click again to stop — the
4226
+ clip is POSTed to the endpoint and the returned transcript is dropped into the textarea. Swap the
4227
+ transport with a custom `transcribeHandler` — `(audio: Blob) => Promise<string>` — to use a
4228
+ different STT endpoint or a browser Web Speech adapter without touching the button; when set, the
4229
+ mic appears even with no `data-transcribe-url`.
4230
+
4231
+ ```html
4232
+ <ag-ui-chat endpoint="/agent/" data-transcribe-url="/agent/transcribe/"></ag-ui-chat>
4233
+ ```
4234
+
4235
+ ### Theme toggle
4236
+
4237
+ `theme` is a plain attribute you can set yourself, and a host can always drop its own switch into
4238
+ `slot="header-actions"`. For convenience, the boolean `data-theme-toggle` attribute adds a built-in
4239
+ light⇄dark toggle to the header (part `theme-toggle`) that flips the scheme word of `theme` and
4240
+ persists the choice per tab. Only the scheme changes: `soft` becomes `soft dark`, `soft dark`
4241
+ becomes `soft light`, and `auto` becomes the opposite of what the system shows, an explicit
4242
+ choice. With no scheme word the toggle flips the scheme the panel is drawn in, so a host rule
4243
+ that sets the element's `color-scheme` (to follow the page's own dark mode) is flipped from,
4244
+ and the written word then holds against that rule. The toggle's glyph follows a `theme` the host
4245
+ writes later too, so a host's own picker and the toggle never disagree. Only the scheme is saved, too:
4246
+ on the next load it is put beside whatever theme name the markup carries, so `dark` chosen on a
4247
+ `soft` page restores as `classic dark` on a `classic` one. Off by default so it never competes with
4248
+ a host-supplied control.
4249
+
4250
+ ```html
4251
+ <ag-ui-chat endpoint="/agent/" data-theme-toggle></ag-ui-chat>
4252
+ ```
4253
+
4254
+ ---
4255
+
4256
+ ## Internationalization (i18n)
4257
+
4258
+ Every user-facing string — labels, placeholders, `aria-label`s, and `title` tooltips — is read
4259
+ from a flat `UiStrings` table, so a non-English host can translate the widget without forking it.
4260
+ Override any subset; the rest fall back to the English defaults. Two equivalent seams:
4261
+
4262
+ ```js
4263
+ // As a property (merged over the defaults):
4264
+ chat.strings = { send: "Senden", inputPlaceholder: "Frag mich…", stop: "Stopp" };
4265
+ ```
4266
+
4267
+ ```html
4268
+ <!-- Or inline, as JSON (the property wins key-by-key when both are set): -->
4269
+ <ag-ui-chat endpoint="/agent/" data-strings='{"send": "Senden", "inputPlaceholder": "Frag mich…"}'></ag-ui-chat>
4270
+ ```
4271
+
4272
+ Set `strings` / `data-strings` **before** the element connects (they resolve on mount). A few keys
4273
+ are templates carrying `{token}` placeholders the widget fills in — e.g. `minutesAgo`
4274
+ (`"{n}m ago"`), `confirmRun` (`"Run “{tool}”?"`), `tooLarge` (`"Too large (max {size})"`). Keep the
4275
+ token verbatim when translating. The full key list and English defaults live in
4276
+ [`src/ui/ui_strings.ts`](src/ui/ui_strings.ts) (exported as `DEFAULT_UI_STRINGS`); `mergeUiStrings`
4277
+ is exported too if you want to compute a complete table yourself.
4278
+
4279
+ ---
4280
+
4281
+ ## Building the bundle
4282
+
4283
+ The build is driven by [esbuild](esbuild.config.mjs) plus `tsc` for type declarations:
4284
+
4285
+ ```bash
4286
+ make build # node esbuild.config.mjs && tsc -p tsconfig.build.json
4287
+ ```
4288
+
4289
+ This produces, into `dist/`:
4290
+
4291
+ - `index.js` — the ESM library build; `@ag-ui/*` are left **external** so npm consumers dedupe
4292
+ them.
4293
+ - `agents-web-components.bundle.js` — the **vendored** ESM bundle, every dependency inlined and
4294
+ minified, suitable for direct `<script type="module">` embedding.
4295
+ - `index.d.ts` (+ source maps) — type declarations; emitted `.js` import specifiers are preserved
4296
+ so consumers resolve types without extra flags.
4297
+
4298
+ There is **no CSS file to load**. The styles are string constants adopted into
4299
+ the shadow root at construction, so they ship inside the JS and cannot leak into
4300
+ the host page. Restyle through the [CSS custom properties](#theming-density-and-placement) and the
4301
+ `part` attributes, not a stylesheet override.
4302
+
4303
+ Other workflow targets (all identical in name to the sibling Python packages):
4304
+
4305
+ | Target | What it does |
4306
+ | --- | --- |
4307
+ | `make test` | Vitest with a 100% line + branch + function + statement coverage gate. |
4308
+ | `make lint` | `biome check .` + `tsc --noEmit`. |
4309
+ | `make format` | `biome format --write .`. |
4310
+ | `make demo` | Build, then serve the live playground (`demo/themes/index.html`) on port 5173 via `demo/mock-server.mjs`. |
4311
+
4312
+ ---
4313
+
4314
+ ## Moving from `@artooi/ag-ui-web-component`
4315
+
4316
+ The old package is replaced, not upgraded: the tags, events, parts, slots and `--ag-ui-*` custom
4317
+ properties keep their names, so markup and stylesheets carry over untouched. What changes is the
4318
+ package, how the element is defined, and a handful of removals.
4319
+
4320
+ **Install the new package and remove the old one.** Never keep both on a page: each defines the
4321
+ `<ag-ui-chat>` tag, and the second to load cannot claim it.
4322
+
4323
+ ```bash
4324
+ npm uninstall @artooi/ag-ui-web-component
4325
+ npm install @artooi/agents-web-components
4326
+ ```
4327
+
4328
+ A vendored bundle is renamed too: `dist/agents-web-components.bundle.js` replaces
4329
+ `dist/ag-ui-web-component.bundle.js`.
4330
+
4331
+ **Define the element.** `defineAgUiChat` is now `defineChat`, and it lives on the `./chat` entry.
4332
+ Importing the package still registers nothing, so the call stays explicit:
4333
+
4334
+ ```js
4335
+ // before
4336
+ import { defineAgUiChat } from "@artooi/ag-ui-web-component";
4337
+ defineAgUiChat();
4338
+
4339
+ // after
4340
+ import { defineChat } from "@artooi/agents-web-components/chat";
4341
+ defineChat();
4342
+ ```
4343
+
4344
+ The bundle exports it too (`import { defineChat } from "@artooi/agents-web-components/bundle"`).
4345
+ `defineChat()` does nothing when `AgUiChat` already holds the tag, and throws, naming the tag,
4346
+ when another class holds it, which is how a second copy of the element shows itself.
4347
+
4348
+ **The first deploy forgets what the browser stored.** Every storage key now starts with
4349
+ `agents-web-components` instead of `ag-ui-chat`, and nothing migrates the old keys. The first page
4350
+ load after the move starts from the defaults: the open thread, the collapsed state, the panel
4351
+ size, the launcher and dock position and the theme are gone once, and so is the drawer's list of
4352
+ threads, unless `data-threads-url` is set, in which case the server still supplies it.
4353
+ A host that reads the old keys itself must read the new root.
4354
+
4355
+ **Removed names.** Each is gone; most have a replacement that behaves the same:
4356
+
4357
+ | Removed | Use instead |
4358
+ | --- | --- |
4359
+ | `defineAgUiChat` | `defineChat`, from `./chat` |
4360
+ | `createStateHookTools`, `StateHook` | `createPageStateTools`, `PageState` |
4361
+ | the element's `registerStateHook` method | `registerPageState` |
4362
+ | `AgUiClient.annotatedMessages` | `AgUiClient.messages` |
4363
+ | the `noResult` string in `UiStrings` | nothing: `callNotFinished` answers a call that never returned |
4364
+
4365
+ A string table that still sets `noResult` stops type-checking; delete the entry.
4366
+
4367
+ **Server-side rendering.** The element extends `HTMLElement` and some helpers read the DOM, so
4368
+ evaluating the package in Node throws. Import it on the client only: from a client-side module, a
4369
+ dynamic `import()` behind a browser check, or your framework's client-only boundary. The package
4370
+ declares `sideEffects: false`, so an unused import is dropped, but that is a bundling property,
4371
+ not a promise that the modules load without a DOM.
4372
+
4373
+ ---
4374
+
4375
+ ## Compatibility
4376
+
4377
+ | Component | Floor | Tested |
4378
+ | --- | --- | --- |
4379
+ | Node (tooling/tests only) | 22 | 22, 24 |
4380
+ | Browsers (runtime target) | Chrome 123, Firefox 120, Safari 17.5 | Chromium |
4381
+ | `@ag-ui/client`, `@ag-ui/core` | 1.0 | 1.0 |
4382
+ | django-ag-ui (server) | 0.63.0 | — |
4383
+
4384
+ The shipped artefact targets evergreen browsers (Shadow DOM, Custom Elements v1, ES2022). The
4385
+ browser floor is set by `light-dark()`, which every theme declares its colours with: below Chrome
4386
+ 123, Firefox 120 or Safari 17.5 those colours do not resolve. Node is only the build/test runtime,
4387
+ not a runtime target.
4388
+
4389
+ The server floor is a wire contract rather than a dependency. A tool call's outcome and a user
4390
+ message's attachment refs travel in `metadata`, since `@ag-ui/client` 1.0 strips every key its
4391
+ schemas do not declare. Against an older django-ag-ui the chat still runs, but the agent is never
4392
+ told about an attached file and a failed or declined server-side tool call settles as done.
4393
+
4394
+ ---
4395
+
4396
+ ## License
4397
+
4398
+ [MIT](LICENSE) © Artur Veres