@kitn.ai/ui 0.20.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (500) hide show
  1. package/README.md +44 -45
  2. package/dist/components/artifact-card.d.ts +51 -0
  3. package/dist/components/artifact.d.ts +1 -1
  4. package/dist/components/attachments.d.ts +2 -2
  5. package/dist/components/audio-visualizer/index.d.ts +3 -3
  6. package/dist/components/audio-visualizer/labs/lab-choreography.d.ts +59 -0
  7. package/dist/components/audio-visualizer/labs/lab-shaders.d.ts +22 -0
  8. package/dist/components/audio-visualizer/labs/lab-visualizer.d.ts +18 -0
  9. package/dist/components/audio-visualizer/variant-aurora.d.ts +2 -2
  10. package/dist/components/audio-visualizer/variant-bar.d.ts +2 -2
  11. package/dist/components/audio-visualizer/variant-custom.d.ts +2 -2
  12. package/dist/components/audio-visualizer/variant-grid.d.ts +1 -1
  13. package/dist/components/audio-visualizer/variant-radial.d.ts +1 -1
  14. package/dist/components/audio-visualizer/variant-wave.d.ts +1 -1
  15. package/dist/components/card-fallback.d.ts +12 -2
  16. package/dist/components/card-renderer.d.ts +63 -2
  17. package/dist/components/chat-container.d.ts +3 -0
  18. package/dist/components/chat-scope-picker.d.ts +2 -2
  19. package/dist/components/chat-thread.d.ts +68 -18
  20. package/dist/components/choice-card.d.ts +3 -28
  21. package/dist/components/composer-dom.d.ts +1 -1
  22. package/dist/components/composer-highlight.d.ts +1 -1
  23. package/dist/components/composer-history.d.ts +1 -1
  24. package/dist/components/composer.d.ts +1 -1
  25. package/dist/components/confirm-card.d.ts +3 -18
  26. package/dist/components/context.d.ts +8 -4
  27. package/dist/components/conversation-item.d.ts +1 -1
  28. package/dist/components/conversation-list.d.ts +4 -3
  29. package/dist/components/embed.d.ts +3 -3
  30. package/dist/components/file-tree.d.ts +11 -22
  31. package/dist/components/form-widgets.d.ts +1 -1
  32. package/dist/components/form.d.ts +3 -44
  33. package/dist/components/link-preview.d.ts +3 -3
  34. package/dist/components/loader.d.ts +30 -48
  35. package/dist/components/message-skills.d.ts +4 -0
  36. package/dist/components/message.d.ts +84 -21
  37. package/dist/components/model-switcher.d.ts +2 -2
  38. package/dist/components/prompt-input.d.ts +2 -2
  39. package/dist/components/response-compare-types.d.ts +11 -2
  40. package/dist/components/response-compare.d.ts +3 -3
  41. package/dist/components/source.d.ts +12 -3
  42. package/dist/components/tasks-card.d.ts +3 -31
  43. package/dist/components/thread.d.ts +17 -5
  44. package/dist/components/toast.d.ts +2 -2
  45. package/dist/components/tool-classify.d.ts +8 -0
  46. package/dist/components/tool-types.d.ts +29 -1
  47. package/dist/components/tool.d.ts +3 -3
  48. package/dist/components/use-card-resolution.d.ts +1 -1
  49. package/dist/create-tween-D3Y5qiij.js +338 -0
  50. package/dist/create-tween-DGKY1FDY.js +341 -0
  51. package/dist/create-tween-paho5Rh-.js +7 -0
  52. package/dist/custom-elements.json +1681 -454
  53. package/dist/elements/agent-card.js +1 -1
  54. package/dist/elements/artifact.js +1 -1
  55. package/dist/elements/attachments.js +1 -1
  56. package/dist/elements/audio-visualizer.js +1 -1
  57. package/dist/elements/avatar.js +1 -1
  58. package/dist/elements/badge.js +1 -1
  59. package/dist/elements/button.js +1 -1
  60. package/dist/elements/card.js +1 -1
  61. package/dist/elements/cards.js +1 -1
  62. package/dist/elements/chain-of-thought.js +1 -1
  63. package/dist/elements/chat-scope-picker.js +1 -1
  64. package/dist/elements/chat-types.d.ts +70 -10
  65. package/dist/elements/chat-workspace.js +1 -1
  66. package/dist/elements/chat.js +1 -1
  67. package/dist/elements/checkpoint.js +1 -1
  68. package/dist/elements/choice.js +1 -1
  69. package/dist/elements/chunks/{Icon-Tj58ES_d.js → Icon-C-2LvGDG.js} +1 -1
  70. package/dist/elements/chunks/{action-icons-ns1Fnedj.js → action-icons-CaYoImEk.js} +1 -1
  71. package/dist/elements/chunks/arrow-left-Bb46H7w9.js +1 -0
  72. package/dist/elements/chunks/artifact-BBIscOxJ.js +1 -0
  73. package/dist/elements/chunks/{attachments-mCB5F1Fq.js → attachments-BB56zoji.js} +1 -1
  74. package/dist/elements/chunks/audio-visualizer-BxOx4THz.js +1 -0
  75. package/dist/elements/chunks/{badge-B4Zuajl2.js → badge-SxLSZ19R.js} +1 -1
  76. package/dist/elements/chunks/{button-ZWNif3--.js → button-CmkUkz6c.js} +1 -1
  77. package/dist/elements/chunks/card-renderer-Db4ffkWv.js +1 -0
  78. package/dist/elements/chunks/card-tags-D8lZ-C_U.js +1 -0
  79. package/dist/elements/chunks/chat-thread-B3Ks-cKr.js +1 -0
  80. package/dist/elements/chunks/check-C8desc_h.js +1 -0
  81. package/dist/elements/chunks/chevron-down-DKtc2HNB.js +1 -0
  82. package/dist/elements/chunks/chevron-right-Dw4GPhwL.js +1 -0
  83. package/dist/elements/chunks/choice-card-Q9cYYDyM.js +1 -0
  84. package/dist/elements/chunks/circle-BeokBsvB.js +1 -0
  85. package/dist/elements/chunks/circle-check-wJSqt859.js +1 -0
  86. package/dist/elements/chunks/{circle-x-B7faJFlY.js → circle-x-D0MEfxOM.js} +1 -1
  87. package/dist/elements/chunks/code-block-C8VzAraE.js +1 -0
  88. package/dist/elements/chunks/{collapsible-DM1Ckd4e.js → collapsible-Cvj61obE.js} +1 -1
  89. package/dist/elements/chunks/{composer-BguS0QL_.js → composer-D86ztyL0.js} +2 -2
  90. package/dist/elements/chunks/confirm-card-BdyDAGLw.js +1 -0
  91. package/dist/elements/chunks/context-WHDl-xSx.js +1 -0
  92. package/dist/elements/chunks/conversation-list-CdR14_Y4.js +1 -0
  93. package/dist/elements/chunks/create-tween-C_A_B638.js +7 -0
  94. package/dist/elements/chunks/{default-input-Cqqilg9t.js → default-input-CSZqUM81.js} +1 -1
  95. package/dist/elements/chunks/define-BzPnNsXg.js +1 -0
  96. package/dist/elements/chunks/{disclosure-CIZ5c8nO.js → disclosure-DVKsoc1F.js} +1 -1
  97. package/dist/elements/chunks/{download-Dsbg3ste.js → download-BjA54IMJ.js} +1 -1
  98. package/dist/elements/chunks/{dropdown-BDd1-Zgi.js → dropdown-BjVVHWXH.js} +1 -1
  99. package/dist/elements/chunks/{ellipsis-Dw6qrss-.js → ellipsis-BNedmqfi.js} +1 -1
  100. package/dist/elements/chunks/embed-DQsl4XEq.js +1 -0
  101. package/dist/elements/chunks/{external-link-Vt74zAoL.js → external-link-Di-dw2CU.js} +1 -1
  102. package/dist/elements/chunks/{file-text-CZrZF6_v.js → file-text-B3sAODCV.js} +1 -1
  103. package/dist/elements/chunks/{file-tree-DF4okKyo.js → file-tree-Dgto-bMn.js} +1 -1
  104. package/dist/elements/chunks/{folder-glP5rha4.js → folder-D6rMadFV.js} +1 -1
  105. package/dist/elements/chunks/form-CILxvhQ7.js +1 -0
  106. package/dist/elements/chunks/hover-card-BtiF4XiU.js +1 -0
  107. package/dist/elements/chunks/{icon-BLb8beoi.js → icon-DKkmze3y.js} +1 -1
  108. package/dist/elements/chunks/{info-CIsv92JK.js → info-CWRjZkuy.js} +1 -1
  109. package/dist/elements/chunks/{input-Dds1kFyt.js → input--QJBK6BA.js} +1 -1
  110. package/dist/elements/chunks/{kbd-De_xbgi9.js → kbd-C3zuKh8R.js} +1 -1
  111. package/dist/elements/chunks/{link-i4r_k7z4.js → link-BL5JA7Q0.js} +1 -1
  112. package/dist/elements/chunks/link-preview-CU37bbXh.js +1 -0
  113. package/dist/elements/chunks/{loader-BmQH4s3V.js → loader-XmpyxyRH.js} +1 -1
  114. package/dist/elements/chunks/{markdown-Zi21WhUz.js → markdown-3Gv5UZ_o.js} +1 -1
  115. package/dist/elements/chunks/message-CnBA2OUt.js +1 -0
  116. package/dist/elements/chunks/message-X5NZbq-W.js +1 -0
  117. package/dist/elements/chunks/message-square-CnpAm_P0.js +1 -0
  118. package/dist/elements/chunks/{minimize-2--ifeCGrl.js → minimize-2-DwyrUTcM.js} +1 -1
  119. package/dist/elements/chunks/{model-switcher-B94YAKsM.js → model-switcher-CclaJEHt.js} +1 -1
  120. package/dist/elements/chunks/{overlay-kD8dwRv_.js → overlay-Cepd-KcS.js} +1 -1
  121. package/dist/elements/chunks/{paperclip-q21dYOSK.js → paperclip-CdwKjrlu.js} +1 -1
  122. package/dist/elements/chunks/{progress-bar-BrAIIaaJ.js → progress-bar-DMVXBD3b.js} +1 -1
  123. package/dist/elements/chunks/{prompt-suggestion-Bjn2K-fR.js → prompt-suggestion-Di_WZE_5.js} +1 -1
  124. package/dist/elements/chunks/reasoning-D0jMch5u.js +1 -0
  125. package/dist/elements/chunks/{resizable-Dp4JExql.js → resizable-1znbL1Gv.js} +1 -1
  126. package/dist/elements/chunks/{rotate-cw-CFWWkpj2.js → rotate-cw-DVhhgx8n.js} +1 -1
  127. package/dist/elements/chunks/{scroll-area-Bb0kHH1j.js → scroll-area-Bs4bFS1P.js} +1 -1
  128. package/dist/elements/chunks/scroll-button-Bet5NLc4.js +1 -0
  129. package/dist/elements/chunks/{separator-9kRgr1X9.js → separator-C5Gh8qGC.js} +1 -1
  130. package/dist/elements/chunks/{settings-B8lmec4D.js → settings-5vmEFg0K.js} +1 -1
  131. package/dist/elements/chunks/{settings-group-I0hEuXiN.js → settings-group-BWLB38kz.js} +1 -1
  132. package/dist/elements/chunks/{share-C3lTiYGt.js → share-BZ3G_DaB.js} +1 -1
  133. package/dist/elements/chunks/skeleton-DafPJQTp.js +1 -0
  134. package/dist/elements/chunks/slots-CIw9RlAe.js +1 -0
  135. package/dist/elements/chunks/source-D_qj_6uJ.js +1 -0
  136. package/dist/elements/chunks/{star-CSJtGxgv.js → star-Cuv7j2IE.js} +1 -1
  137. package/dist/elements/chunks/{store-CHyW95TP.js → store-C1StPHfW.js} +1 -1
  138. package/dist/elements/chunks/tasks-card-EyoBZfpA.js +1 -0
  139. package/dist/elements/chunks/{text-shimmer-Cn9TmNpI.js → text-shimmer-D9gRSh34.js} +1 -1
  140. package/dist/elements/chunks/textarea-DuhtHfcm.js +1 -0
  141. package/dist/elements/chunks/{thumbs-up-PNVVThEF.js → thumbs-up-B9hwv43w.js} +1 -1
  142. package/dist/elements/chunks/{toast-store-DKbQn3Wn.js → toast-store-Bm3DUEDe.js} +1 -1
  143. package/dist/elements/chunks/{tool-DjTRpIpl.js → tool-CDtW7L0z.js} +1 -1
  144. package/dist/elements/chunks/tooltip-eB226mGK.js +1 -0
  145. package/dist/elements/chunks/{triangle-alert-DsHTHCPm.js → triangle-alert-C1SNzTqK.js} +1 -1
  146. package/dist/elements/chunks/use-card-resolution-YZh2MvI5.js +1 -0
  147. package/dist/elements/chunks/{variant-aurora-Dc4nQUia.js → variant-aurora-Cj1F7D0l.js} +2 -2
  148. package/dist/elements/chunks/{variant-custom-y_3kVQhS.js → variant-custom-T9gpt7QE.js} +1 -1
  149. package/dist/elements/chunks/{variant-wave-D5OR1-9Z.js → variant-wave-CxU9z-TE.js} +2 -2
  150. package/dist/elements/chunks/{video-B7vmenoC.js → video-CK4R7Rus.js} +1 -1
  151. package/dist/elements/chunks/x-D1qx0aLd.js +1 -0
  152. package/dist/elements/coachmark.js +1 -1
  153. package/dist/elements/code-block.js +1 -1
  154. package/dist/elements/command.js +1 -1
  155. package/dist/elements/compare.js +1 -1
  156. package/dist/elements/composer.js +1 -1
  157. package/dist/elements/confirm-card.js +1 -1
  158. package/dist/elements/context-meter.js +1 -1
  159. package/dist/elements/conversation-list.js +1 -1
  160. package/dist/elements/default-input.d.ts +7 -7
  161. package/dist/elements/define.d.ts +1 -0
  162. package/dist/elements/dialog.js +1 -1
  163. package/dist/elements/disclosure.d.ts +1 -1
  164. package/dist/elements/editable-label.js +1 -1
  165. package/dist/elements/element-data-types.d.ts +107 -0
  166. package/dist/elements/embed.js +1 -1
  167. package/dist/elements/empty.js +1 -1
  168. package/dist/elements/feedback-bar.js +1 -1
  169. package/dist/elements/file-tree.js +1 -1
  170. package/dist/elements/file-upload.js +1 -1
  171. package/dist/elements/form.js +1 -1
  172. package/dist/elements/hover-card.js +1 -1
  173. package/dist/elements/icon.js +1 -1
  174. package/dist/elements/image.js +1 -1
  175. package/dist/elements/input.js +1 -1
  176. package/dist/elements/kbd.js +1 -1
  177. package/dist/elements/link-preview.js +1 -1
  178. package/dist/elements/loader.js +1 -1
  179. package/dist/elements/markdown.js +1 -1
  180. package/dist/elements/menu.js +1 -1
  181. package/dist/elements/message-skills.js +1 -1
  182. package/dist/elements/message.js +1 -1
  183. package/dist/elements/model-switcher.js +1 -1
  184. package/dist/elements/nav.js +1 -1
  185. package/dist/elements/notice.js +1 -1
  186. package/dist/elements/pane-group.js +1 -1
  187. package/dist/elements/pane.js +1 -1
  188. package/dist/elements/popover.js +1 -1
  189. package/dist/elements/progress-bar.js +1 -1
  190. package/dist/elements/prompt-dock.js +1 -1
  191. package/dist/elements/prompt-input.js +1 -1
  192. package/dist/elements/prompt-suggestions.js +1 -1
  193. package/dist/elements/reasoning.js +1 -1
  194. package/dist/elements/register.d.ts +8 -6
  195. package/dist/elements/remote.js +1 -1
  196. package/dist/elements/resizable.js +1 -1
  197. package/dist/elements/response-stream.js +2 -2
  198. package/dist/elements/screen.js +1 -1
  199. package/dist/elements/scroll-area.js +1 -1
  200. package/dist/elements/scroll-button.js +1 -1
  201. package/dist/elements/search.js +1 -1
  202. package/dist/elements/segmented.js +1 -1
  203. package/dist/elements/separator.js +1 -1
  204. package/dist/elements/setting-item.js +1 -1
  205. package/dist/elements/settings-group.js +1 -1
  206. package/dist/elements/skeleton.js +1 -1
  207. package/dist/elements/slots.d.ts +16 -0
  208. package/dist/elements/source.js +1 -1
  209. package/dist/elements/status.js +1 -1
  210. package/dist/elements/switch.js +1 -1
  211. package/dist/elements/tabs.js +1 -1
  212. package/dist/elements/tasks.js +1 -1
  213. package/dist/elements/text-shimmer.js +1 -1
  214. package/dist/elements/thinking-bar.js +1 -1
  215. package/dist/elements/thread.js +1 -1
  216. package/dist/elements/toast.js +1 -1
  217. package/dist/elements/tool.js +1 -1
  218. package/dist/elements/tooltip.js +1 -1
  219. package/dist/elements/validate-messages.d.ts +25 -0
  220. package/dist/elements/voice-input.js +1 -1
  221. package/dist/elements/voice-output.js +1 -1
  222. package/dist/elements.d.ts +2725 -214
  223. package/dist/index.d.ts +163 -125
  224. package/dist/index.js +6336 -5802
  225. package/dist/index.server.d.ts +5 -0
  226. package/dist/index.server.js +5446 -4937
  227. package/dist/kai-provider.es.d.ts +5 -0
  228. package/dist/kai-provider.es.js +79 -77
  229. package/dist/kai.es.d.ts +5 -0
  230. package/dist/kai.es.js +1 -1
  231. package/dist/llms/llms-full.txt +762 -148
  232. package/dist/llms/llms.txt +57 -5
  233. package/dist/mcp.es.js +4010 -770
  234. package/dist/primitives/card-component-types.d.ts +11 -0
  235. package/dist/primitives/card-data-types.d.ts +196 -0
  236. package/dist/primitives/card-host.d.ts +1 -1
  237. package/dist/primitives/card-recovery.d.ts +3 -3
  238. package/dist/primitives/card-registry.d.ts +4 -14
  239. package/dist/primitives/card-resolution.d.ts +1 -1
  240. package/dist/primitives/card-routing.d.ts +1 -1
  241. package/dist/primitives/card-tags.d.ts +11 -0
  242. package/dist/primitives/card-validate-cards.d.ts +57 -0
  243. package/dist/primitives/card-validate-generator.testlib.d.ts +20 -0
  244. package/dist/primitives/card-validate-schemas.d.ts +17 -0
  245. package/dist/primitives/card-validate.d.ts +24 -0
  246. package/dist/primitives/create-kai-chat.d.ts +3 -3
  247. package/dist/primitives/embed-providers.d.ts +1 -1
  248. package/dist/primitives/link-preview.d.ts +1 -1
  249. package/dist/primitives/message-feedback.d.ts +2 -2
  250. package/dist/primitives/toast-store.d.ts +1 -1
  251. package/dist/react/index.d.ts +717 -299
  252. package/dist/react/use-kai-chat.d.ts +8 -4
  253. package/dist/react.d.ts +5 -0
  254. package/dist/react.js +417 -316
  255. package/dist/register-impl-bzrZ44ft.js +145 -0
  256. package/dist/remote/host-embed.d.ts +1 -1
  257. package/dist/remote/index.d.ts +2 -2
  258. package/dist/remote/provider-runtime.d.ts +2 -2
  259. package/dist/remote/provider.d.ts +2 -2
  260. package/dist/remote/validate.d.ts +1 -1
  261. package/dist/remote/wire.d.ts +1 -1
  262. package/dist/schemas/artifact.schema.json +92 -0
  263. package/dist/schemas/embed.schema.json +0 -2
  264. package/dist/schemas/from-tool-call.d.ts +48 -0
  265. package/dist/schemas/index.d.ts +48 -0
  266. package/dist/schemas/link.schema.json +0 -2
  267. package/dist/schemas/provider-subsets.d.ts +136 -0
  268. package/dist/schemas/registry.d.ts +140 -0
  269. package/dist/schemas/tool-defs.d.ts +121 -0
  270. package/dist/schemas.d.ts +5 -0
  271. package/dist/schemas.js +717 -0
  272. package/dist/solid-BCJUQqwS.js +22750 -0
  273. package/dist/solid-BdENBcEq.js +20108 -0
  274. package/dist/solid.d.ts +66 -0
  275. package/dist/solid.js +261 -0
  276. package/dist/solid.server.d.ts +5 -0
  277. package/dist/solid.server.js +261 -0
  278. package/dist/state/index.d.ts +14 -4
  279. package/dist/state/messages.d.ts +8 -3
  280. package/dist/state/mock.d.ts +46 -0
  281. package/dist/state/parts.d.ts +85 -0
  282. package/dist/state/stream.d.ts +15 -9
  283. package/dist/state.d.ts +5 -0
  284. package/dist/state.js +253 -84
  285. package/dist/ui/action-icons.d.ts +1 -1
  286. package/dist/ui/collapsible.d.ts +11 -6
  287. package/dist/ui/dropdown.d.ts +47 -42
  288. package/dist/ui/pane-group.d.ts +1 -1
  289. package/dist/variant-aurora-1wMcXRUS.js +381 -0
  290. package/dist/variant-aurora-BKHHiOgL.js +391 -0
  291. package/dist/{variant-aurora-DVtGqVH6.js → variant-aurora-DOb_o4pC.js} +1 -1
  292. package/dist/{variant-custom-Dif-IV4u.js → variant-custom-CJGB0Kbw.js} +1 -1
  293. package/dist/variant-custom-Co0CkXJF.js +200 -0
  294. package/dist/variant-custom-DWSSgmd2.js +170 -0
  295. package/dist/variant-wave-C3-45cL6.js +218 -0
  296. package/dist/{variant-wave-DbfNdvSi.js → variant-wave-CEODq-9q.js} +1 -1
  297. package/dist/variant-wave-jdXBnCUZ.js +208 -0
  298. package/dist/wire/chunk.d.ts +171 -0
  299. package/dist/wire/consume.d.ts +26 -0
  300. package/dist/wire/encode.d.ts +148 -0
  301. package/dist/wire/formats/anthropic.d.ts +2 -0
  302. package/dist/wire/formats/openai.d.ts +5 -0
  303. package/dist/wire/index.d.ts +14 -0
  304. package/dist/wire/read.d.ts +26 -0
  305. package/dist/wire/sink-helpers.d.ts +21 -0
  306. package/dist/wire/sse.d.ts +33 -0
  307. package/dist/wire.d.ts +5 -53
  308. package/dist/wire.js +898 -0
  309. package/frameworks/react/index.tsx +183 -154
  310. package/frameworks/react/runtime.tsx +21 -1
  311. package/frameworks/react/use-kai-chat.tsx +11 -1
  312. package/llms-full.txt +762 -148
  313. package/llms.txt +57 -5
  314. package/package.json +61 -8
  315. package/src/agent-tooling/archetypes.ts +96 -0
  316. package/src/agent-tooling/integrations/anthropic.ts +412 -0
  317. package/src/agent-tooling/integrations/cloudflare.ts +136 -52
  318. package/src/agent-tooling/integrations/langgraph.ts +67 -13
  319. package/src/agent-tooling/integrations/mastra.ts +120 -12
  320. package/src/agent-tooling/integrations/mock.ts +43 -8
  321. package/src/agent-tooling/integrations/ollama.ts +61 -24
  322. package/src/agent-tooling/integrations/openai.ts +87 -0
  323. package/src/agent-tooling/integrations/openrouter.ts +56 -8
  324. package/src/agent-tooling/integrations/pi.ts +79 -23
  325. package/src/agent-tooling/integrations/pydantic-ai.ts +24 -2
  326. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +84 -13
  327. package/src/agent-tooling/mcp/manifest.ts +206 -25
  328. package/src/agent-tooling/mcp/tools/debug.ts +13 -7
  329. package/src/agent-tooling/mcp/tools/reference.ts +285 -6
  330. package/src/agent-tooling/mcp/tools/scaffold.ts +3345 -675
  331. package/src/agent-tooling/registry.ts +17 -0
  332. package/src/agent-tooling/types.ts +229 -1
  333. package/src/components/artifact-card.tsx +120 -0
  334. package/src/components/audio-visualizer/index.tsx +9 -0
  335. package/src/components/audio-visualizer/labs/lab-choreography.ts +187 -0
  336. package/src/components/audio-visualizer/labs/lab-shaders.ts +363 -0
  337. package/src/components/audio-visualizer/labs/lab-visualizer.tsx +102 -0
  338. package/src/components/audio-visualizer/shader-canvas.tsx +31 -1
  339. package/src/components/card-fallback.tsx +42 -10
  340. package/src/components/card-renderer.tsx +146 -9
  341. package/src/components/chat-container.tsx +4 -0
  342. package/src/components/chat-thread.tsx +118 -51
  343. package/src/components/choice-card.tsx +17 -29
  344. package/src/components/composer.tsx +12 -2
  345. package/src/components/confirm-card.tsx +24 -20
  346. package/src/components/context.tsx +12 -4
  347. package/src/components/conversation-list.tsx +18 -2
  348. package/src/components/file-tree.tsx +11 -22
  349. package/src/components/file-upload.tsx +14 -8
  350. package/src/components/form.tsx +14 -47
  351. package/src/components/loader.tsx +39 -12
  352. package/src/components/message-skills.tsx +4 -0
  353. package/src/components/message.tsx +294 -46
  354. package/src/components/reasoning.tsx +92 -5
  355. package/src/components/response-compare-types.ts +25 -0
  356. package/src/components/response-compare.tsx +4 -9
  357. package/src/components/source.tsx +50 -24
  358. package/src/components/tasks-card.tsx +10 -33
  359. package/src/components/thread.tsx +84 -42
  360. package/src/components/toast.tsx +11 -5
  361. package/src/components/tool-classify.ts +26 -0
  362. package/src/components/tool-types.ts +31 -1
  363. package/src/elements/agent-card.tsx +11 -11
  364. package/src/elements/artifact.tsx +15 -7
  365. package/src/elements/attachments.tsx +6 -4
  366. package/src/elements/avatar.tsx +1 -1
  367. package/src/elements/button.tsx +3 -3
  368. package/src/elements/card.tsx +2 -2
  369. package/src/elements/cards.tsx +109 -14
  370. package/src/elements/chain-of-thought.tsx +14 -10
  371. package/src/elements/chat-scope-picker.tsx +9 -7
  372. package/src/elements/chat-types.ts +59 -6
  373. package/src/elements/chat-workspace.tsx +50 -9
  374. package/src/elements/chat.tsx +64 -3
  375. package/src/elements/choice.tsx +8 -8
  376. package/src/elements/coachmark.tsx +1 -1
  377. package/src/elements/command.tsx +8 -23
  378. package/src/elements/compare.tsx +7 -6
  379. package/src/elements/compiled.css +1 -1
  380. package/src/elements/composer.tsx +8 -8
  381. package/src/elements/confirm-card.tsx +9 -9
  382. package/src/elements/context-meter.tsx +4 -18
  383. package/src/elements/conversation-list.tsx +14 -9
  384. package/src/elements/default-input.tsx +3 -3
  385. package/src/elements/define.tsx +178 -43
  386. package/src/elements/editable-label.tsx +17 -2
  387. package/src/elements/element-data-types.ts +112 -0
  388. package/src/elements/element-meta.json +1596 -304
  389. package/src/elements/element-types.d.ts +2636 -211
  390. package/src/elements/feedback-bar.tsx +1 -1
  391. package/src/elements/file-tree.tsx +1 -1
  392. package/src/elements/file-upload.tsx +1 -1
  393. package/src/elements/form.tsx +14 -6
  394. package/src/elements/input.tsx +1 -1
  395. package/src/elements/kbd.tsx +1 -1
  396. package/src/elements/menu.tsx +10 -29
  397. package/src/elements/message-skills.tsx +7 -9
  398. package/src/elements/message.tsx +200 -32
  399. package/src/elements/model-switcher.tsx +5 -3
  400. package/src/elements/pane.tsx +1 -1
  401. package/src/elements/prompt-dock.tsx +8 -8
  402. package/src/elements/prompt-input.tsx +10 -10
  403. package/src/elements/prompt-suggestions.tsx +7 -4
  404. package/src/elements/reasoning.tsx +1 -1
  405. package/src/elements/register.ts +9 -0
  406. package/src/elements/resizable.globals.d.ts +36 -0
  407. package/src/elements/resizable.tsx +24 -13
  408. package/src/elements/response-stream.tsx +1 -1
  409. package/src/elements/scroll-button.tsx +1 -1
  410. package/src/elements/search.tsx +6 -1
  411. package/src/elements/segmented.tsx +5 -11
  412. package/src/elements/separator.tsx +1 -1
  413. package/src/elements/slots.ts +57 -24
  414. package/src/elements/source.tsx +21 -22
  415. package/src/elements/switch.tsx +3 -3
  416. package/src/elements/tasks.tsx +5 -4
  417. package/src/elements/thread.tsx +36 -4
  418. package/src/elements/toast.tsx +3 -2
  419. package/src/elements/tool.tsx +1 -1
  420. package/src/elements/validate-messages.ts +76 -0
  421. package/src/elements/voice-input.tsx +7 -7
  422. package/src/elements/voice-output.tsx +4 -4
  423. package/src/index.ts +141 -6
  424. package/src/primitives/card-component-types.ts +63 -0
  425. package/src/primitives/card-data-types.ts +312 -0
  426. package/src/primitives/card-recovery.ts +13 -2
  427. package/src/primitives/card-registry.tsx +34 -23
  428. package/src/primitives/card-schemas/artifact.schema.json +92 -0
  429. package/src/primitives/card-schemas/embed.schema.json +0 -2
  430. package/src/primitives/card-schemas/link.schema.json +0 -2
  431. package/src/primitives/card-tags.ts +61 -0
  432. package/src/primitives/card-validate-cards.ts +255 -0
  433. package/src/primitives/card-validate-generator.testlib.ts +38 -0
  434. package/src/primitives/card-validate-schemas.ts +58 -0
  435. package/src/primitives/card-validate.ts +48 -20
  436. package/src/primitives/create-tween.ts +54 -2
  437. package/src/primitives/message-feedback.ts +3 -2
  438. package/src/primitives/use-audio-analysis.ts +20 -1
  439. package/src/primitives/use-sequencer.ts +20 -1
  440. package/src/schemas/from-tool-call.ts +151 -0
  441. package/src/schemas/index.ts +340 -0
  442. package/src/schemas/provider-subsets.ts +538 -0
  443. package/src/schemas/registry.ts +355 -0
  444. package/src/schemas/tool-defs.ts +492 -0
  445. package/src/solid.ts +150 -0
  446. package/src/state/index.ts +27 -1
  447. package/src/state/messages.ts +21 -4
  448. package/src/state/mock.ts +211 -0
  449. package/src/state/parts.ts +295 -0
  450. package/src/state/stream.ts +56 -58
  451. package/src/ui/collapsible.tsx +17 -5
  452. package/src/ui/dropdown.tsx +46 -12
  453. package/src/ui/overlay.tsx +10 -4
  454. package/src/wire/chunk.ts +201 -0
  455. package/src/wire/consume.ts +440 -0
  456. package/src/wire/encode.ts +474 -0
  457. package/src/wire/formats/anthropic.ts +267 -0
  458. package/src/wire/formats/openai.ts +187 -0
  459. package/src/wire/index.ts +52 -0
  460. package/src/wire/read.ts +152 -0
  461. package/src/wire/sink-helpers.ts +50 -0
  462. package/src/wire/sse.ts +140 -0
  463. package/dist/create-tween-hvpmSuwW.js +0 -7
  464. package/dist/elements/chunks/arrow-left-DQocUFvy.js +0 -1
  465. package/dist/elements/chunks/audio-visualizer-BqNy7dfm.js +0 -1
  466. package/dist/elements/chunks/card-BkHbp4BA.js +0 -1
  467. package/dist/elements/chunks/chat-thread-CkRN6pZ7.js +0 -1
  468. package/dist/elements/chunks/check-B9-gn6SS.js +0 -1
  469. package/dist/elements/chunks/chevron-down-BNsxc2hk.js +0 -1
  470. package/dist/elements/chunks/chevron-right-Hv00StBO.js +0 -1
  471. package/dist/elements/chunks/circle-0e3KJTDr.js +0 -1
  472. package/dist/elements/chunks/circle-check-BNAS0JEy.js +0 -1
  473. package/dist/elements/chunks/code-block-BFUHn6Va.js +0 -1
  474. package/dist/elements/chunks/context-Cb7k4F-v.js +0 -1
  475. package/dist/elements/chunks/conversation-list-CvoAkWhN.js +0 -1
  476. package/dist/elements/chunks/create-tween-oVvAIG8r.js +0 -7
  477. package/dist/elements/chunks/define-D1din7Ld.js +0 -1
  478. package/dist/elements/chunks/hover-card-Cmm6Q53m.js +0 -1
  479. package/dist/elements/chunks/message-BqyXDN3-.js +0 -1
  480. package/dist/elements/chunks/message-feedback-Djr3z6Yb.js +0 -1
  481. package/dist/elements/chunks/message-square-vFAgbDaX.js +0 -1
  482. package/dist/elements/chunks/reasoning-DTpCGtnY.js +0 -1
  483. package/dist/elements/chunks/scroll-button-B0cH5fXf.js +0 -1
  484. package/dist/elements/chunks/skeleton-DSXW5Yya.js +0 -1
  485. package/dist/elements/chunks/slots--V19HwUb.js +0 -1
  486. package/dist/elements/chunks/textarea-CUwqAX0G.js +0 -1
  487. package/dist/elements/chunks/tooltip-CgBZCVKp.js +0 -1
  488. package/dist/elements/chunks/use-card-resolution-BSu_XHjH.js +0 -1
  489. package/dist/elements/chunks/x-GRUeeQFT.js +0 -1
  490. package/dist/host-embed.d.ts +0 -33
  491. package/dist/kai-provider.d.ts +0 -2
  492. package/dist/origin.d.ts +0 -5
  493. package/dist/provider-runtime.d.ts +0 -27
  494. package/dist/register-impl-DQyejdfb.js +0 -145
  495. package/dist/validate.d.ts +0 -4
  496. package/dist/version.d.ts +0 -4
  497. package/src/components/component-meta.json +0 -4550
  498. package/src/elements/card-media.jpg +0 -0
  499. package/src/elements/resizable.d.ts +0 -27
  500. package/src/elements/video-poster.jpg +0 -0
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { Tool } from './types';
3
- import { Placement, Framework } from '../../types';
4
- import type { Integration, Archetype } from '../../types';
3
+ import { Placement, Framework, SECRET_ENV_VAR } from '../../types';
4
+ import type { Integration } from '../../types';
5
5
  import {
6
6
  getArchetype,
7
7
  getIntegration,
@@ -12,16 +12,34 @@ import {
12
12
  /**
13
13
  * scaffold — the keystone tool. Composes a working chat surface from four axes:
14
14
  *
15
- * useCase (archetype) × integration × placement × framework
15
+ * components × integration × placement × framework
16
16
  *
17
17
  * and emits three labeled blocks an AI consumer can paste straight in:
18
- * (1) Front-end — the archetype's kai-* components, rendered for the chosen
18
+ * (1) Front-end — the surface's kai-* components, rendered for the chosen
19
19
  * framework and sized for the placement, wired with the
20
20
  * `messages` property + `kai-submit` per the Streaming recipe.
21
21
  * (2) Backend — the integration's route template for the framework (with a
22
22
  * language-aware fallback when there's no exact match).
23
23
  * (3) Run note — how to run it + the env vars to set.
24
24
  *
25
+ * THE SURFACE AXIS IS A COMPONENTS LIST, NOT AN ARCHETYPE ID, AND THAT IS THE
26
+ * POINT OF THIS FILE'S SHAPE.
27
+ *
28
+ * It used to be an archetype id, and that made the six presets the whole of the
29
+ * expressible space: `agentic` and `workspace` differ by nothing except which
30
+ * components compose, so a builder who wanted both — a resizable artifact pane
31
+ * that ALSO renders the tool calls that produced the artifact — could not ask for
32
+ * it, and neither could `create-kai`'s feature multi-select. Adding a seventh
33
+ * preset for every such combination is 2^n presets in the limit.
34
+ *
35
+ * So `renderSurface({ framework, components, integration })` is the renderer, the
36
+ * archetypes are DATA over it (a preset is a named components list plus a default
37
+ * placement), and there is exactly one of each. `create-kai` imports the same
38
+ * function rather than growing a second one — a parallel renderer is the specific
39
+ * failure this extraction exists to prevent, which is why
40
+ * `assertPresetsAreData` in scripts/verify-scaffold-compiles.mjs asserts that a
41
+ * preset request and a components request emit byte-identical surfaces.
42
+ *
25
43
  * The handler is called directly in tests (bypassing MCP's zod validation), so it
26
44
  * validates `useCase` + `integration` against the registry itself and returns
27
45
  * graceful, self-correcting error text when either is unknown.
@@ -41,7 +59,7 @@ interface PlacementStyle {
41
59
  /** one-line human description of the layout */
42
60
  note: string;
43
61
  /** optional extra comment lines describing an alternative layout form */
44
- altNote?: string;
62
+ altNote?: string[];
45
63
  }
46
64
 
47
65
  // The chat element must fill its container. In a `display: flex; flex-direction:
@@ -50,14 +68,59 @@ interface PlacementStyle {
50
68
  const FLEX_FILL = 'flex: 1; min-height: 0;';
51
69
  const BLOCK_FILL = 'height: 100%; width: 100%;';
52
70
 
71
+ /**
72
+ * full-page, and it has to be true in a STOCK starter — not just in an empty page.
73
+ *
74
+ * It used to be `height: 100dvh; width: 100%`, which is full-page only if nothing
75
+ * above it interferes, and in the templates consumers actually run something
76
+ * always does. Two frameworks hit it independently:
77
+ *
78
+ * · Vite's `react-ts` template ships `#root { max-width: 1280px; margin: 0 auto;
79
+ * padding: 2rem; text-align: center }`, so the chat was capped, inset by 2rem
80
+ * and had its text centred — inherited straight through the shadow boundary.
81
+ * · The official TanStack Start starter renders a Header (73px) and a Footer
82
+ * (181px) around every route in `__root.tsx`, so a 100dvh sibling put the
83
+ * composer 13px BELOW the fold at 1280x800.
84
+ *
85
+ * `position: fixed; inset: 0` is the one fix that works for both without a
86
+ * per-framework patch: it takes the surface out of flow entirely, so an ancestor's
87
+ * width cap, padding and flex centring stop applying and a sibling header/footer
88
+ * stops consuming height. `text-align: start` is still needed because text-align
89
+ * INHERITS regardless of positioning.
90
+ *
91
+ * `z-index` matters as much as the positioning, and is the same 1000 the other two
92
+ * fixed placements already use. Without it the surface stacks at `auto`, and the
93
+ * TanStack starter's header is `sticky top-0 z-50` — so the chat would sit at the
94
+ * right geometry and still have the top 73px of its thread painted over. Half-
95
+ * covered is the worst outcome available: either the chat owns the viewport or it
96
+ * does not.
97
+ *
98
+ * The trade is stated in the emitted comment rather than hidden: this overlays
99
+ * whatever the starter draws around it, nav included. A consumer who wants the chat
100
+ * inside their layout wants `placement: 'inline'`, which is what that placement is for.
101
+ */
102
+ const FULL_PAGE: PlacementStyle = {
103
+ style:
104
+ 'position: fixed; inset: 0; display: flex; flex-direction: column; ' +
105
+ 'text-align: start; z-index: 1000;',
106
+ chatFill: FLEX_FILL,
107
+ note: 'fills the viewport (fixed, inset 0)',
108
+ altNote: [
109
+ 'FULL PAGE MEANS FULL PAGE: `position: fixed; inset: 0` is deliberate, not a stray overlay.',
110
+ '`height: 100dvh` is full-page only in an empty document, and stock starters are not empty —',
111
+ "Vite's react-ts template caps #root at 1126px, centres its text and border-boxes it, and the",
112
+ 'TanStack Start starter wraps every route in a Header + Footer that pushed the composer 13px',
113
+ 'below the fold at 1280x800. Fixed positioning escapes both, `text-align: start` undoes the',
114
+ 'inherited centring, and z-index keeps a sticky header from painting over the thread.',
115
+ 'This DOES cover the chrome around it (nav included) — that is what full-page means here.',
116
+ 'Want the chat to sit INSIDE your own layout instead? Use placement: "inline".',
117
+ ],
118
+ };
119
+
53
120
  function placementStyle(placement: string): PlacementStyle {
54
121
  switch (placement) {
55
122
  case 'full-page':
56
- return {
57
- style: 'height: 100dvh; width: 100%; display: flex; flex-direction: column;',
58
- chatFill: FLEX_FILL,
59
- note: 'fills the viewport (100dvh)',
60
- };
123
+ return FULL_PAGE;
61
124
  case 'inline':
62
125
  return {
63
126
  style: 'width: 100%; max-width: 720px; height: 540px; margin: 0 auto; display: flex; flex-direction: column;',
@@ -73,9 +136,10 @@ function placementStyle(placement: string): PlacementStyle {
73
136
  'border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index: 1000;',
74
137
  chatFill: FLEX_FILL,
75
138
  note: 'full-height side panel, docked to the trailing edge (100dvh)',
76
- altNote:
139
+ altNote: [
77
140
  'In-flow alternative (push content instead of overlay): drop `position`/`z-index` and ' +
78
- 'make this a `flex: 0 0 380px` column inside a `display: flex` row at `height: 100dvh`.',
141
+ 'make this a `flex: 0 0 380px` column inside a `display: flex` row at `height: 100dvh`.',
142
+ ],
79
143
  };
80
144
  case 'docked-widget':
81
145
  // The bottom-right floating bubble — rounded, elevated, fixed size.
@@ -90,101 +154,840 @@ function placementStyle(placement: string): PlacementStyle {
90
154
  default:
91
155
  // Unknown placement falls back to full-page (full height) rather than the bubble,
92
156
  // so a future Placement enum member doesn't silently render as a widget.
93
- return {
94
- style: 'height: 100dvh; width: 100%; display: flex; flex-direction: column;',
95
- chatFill: FLEX_FILL,
96
- note: 'fills the viewport (100dvh)',
97
- };
157
+ return FULL_PAGE;
98
158
  }
99
159
  }
100
160
 
101
- // ── suggestions + mock streaming ───────────────────────────────────────────────
161
+ // ── suggestions ───────────────────────────────────────────────────────────────
102
162
 
103
163
  /** Default starter prompts so the suggestions feature always shows. */
104
164
  const DEFAULT_SUGGESTIONS = ["What's new?", 'How can you help?'];
105
165
 
106
- /** A canned assistant reply the mock integration streams back token-by-token. */
107
- const MOCK_REPLY =
108
- "Hi! I'm a local preview — no backend or API key needed. Swap `integration` for a real provider (openrouter, ollama, …) and I'll talk to a real model.";
109
-
110
166
  /** Render a string[] as a JS array literal (JSON-quoted — keeps apostrophes readable). */
111
167
  function jsArray(items: string[]): string {
112
168
  return '[' + items.map((s) => JSON.stringify(s)).join(', ') + ']';
113
169
  }
114
170
 
171
+ // ── streaming: import the adapter, do not re-hand-roll it ─────────────────────
172
+
115
173
  /**
116
- * The shared client-side mock stream body, parameterised by how each framework
117
- * commits a messages update. Two operations keep the contract correct:
118
- * - `commitInitial(expr)` appends the user + empty-assistant pair.
119
- * - `commitMap(mapBody)` replaces messages with `prev.map((m) => mapBody)`
120
- * each framework supplies how `prev` resolves (the React functional updater,
121
- * or the live local variable for html/vue/svelte) so the streamed content is
122
- * applied to the LATEST array, never a stale snapshot.
123
- * Each commit produces a NEW array (and a new object for the streamed message)
124
- * so kai-chat re-renders per chunk.
125
- *
126
- * Indented with `pad` so it drops cleanly into each framework's onSubmit.
174
+ * How one framework exposes the turn's thread the array every round of the
175
+ * tool loop re-encodes.
176
+ *
177
+ * THE WHOLE PROBLEM IN ONE INTERFACE. Round 2's request is
178
+ * `toOpenAIMessages(<the thread INCLUDING the assistant turn so far>)`, and the
179
+ * assistant message is appended by `createAssistantStream` through the setter,
180
+ * so the submit handler never holds it. Three of the four surfaces already keep
181
+ * their messages somewhere a closure can read back synchronously
182
+ * (`chat.messages`, `messages.value`, a Svelte `let`), so for those the thread
183
+ * IS that live value and there is nothing to add. React is the odd one:
184
+ * `useState` cannot be read back inside the async turn that is writing it, so
185
+ * the turn declares its own `thread` and `setMessages` becomes the projection of
186
+ * it. See `REACT_THREAD` for why that is a single source of truth and not a
187
+ * mirror.
127
188
  */
128
- function mockStreamBody(opts: {
189
+ interface ThreadBinding {
190
+ /** Lines that open the turn: append the user message and commit it. */
191
+ open(ctx: { pad: string; userMessage: string; typed: boolean }): string[];
192
+ /** Expression that reads the CURRENT thread, valid at any point in the turn. */
193
+ live: string;
194
+ /** The `SetMessages` updater handed to `createAssistantStream`. */
195
+ setter: string;
196
+ }
197
+
198
+ /**
199
+ * React (and next / tanstack-start, which are React).
200
+ *
201
+ * `thread` is the turn's single source of truth; `setMessages(thread)` is a
202
+ * projection of it for rendering, never read back. That ordering matters: the
203
+ * inverse (holding a copy and folding FROM it while React holds the truth) is
204
+ * the mirror bug this kit already shipped once — `createAssistantStream` used to
205
+ * fold from a local `currentParts` and silently clobbered any edit made through
206
+ * the store. Here nothing folds from the store, so nothing can be clobbered by a
207
+ * stale copy; the one rule, stated in the emitted comment, is that a turn's
208
+ * writes all go through `set`.
209
+ */
210
+ const REACT_THREAD: ThreadBinding = {
211
+ open: ({ pad, userMessage }) => [
212
+ `${pad}// THE TURN OWNS THE THREAD. Every round of the loop below re-encodes the`,
213
+ `${pad}// whole thread, and React state cannot be read back to get it: setMessages`,
214
+ `${pad}// is async and this closure captured the pre-submit \`messages\`. So \`thread\``,
215
+ `${pad}// is the source of truth for this turn and setMessages just projects it for`,
216
+ `${pad}// rendering. Route any other messages write you make mid-turn through set().`,
217
+ `${pad}let thread: ChatMessage[] = [...messages, ${userMessage}];`,
218
+ `${pad}const set: SetMessages = (fn) => { thread = fn(thread); setMessages(thread); };`,
219
+ `${pad}setMessages(thread);`,
220
+ ],
221
+ live: 'thread',
222
+ setter: 'set',
223
+ };
224
+
225
+ /** A framework whose messages live in a variable/property the turn can read back
226
+ * (html's `chat.messages`, Vue's `messages.value`, Svelte's `messages`). The
227
+ * live value IS the thread: no local copy, nothing to keep in sync.
228
+ *
229
+ * `firstRead` differs from `expr` only where the first read can precede any
230
+ * write: an un-upgraded `<kai-chat>` has no `messages` yet, and spreading
231
+ * `undefined` throws. Every later read is of a value this code assigned. */
232
+ function liveThreadBinding(expr: string, setter: string, firstRead = expr): ThreadBinding {
233
+ return {
234
+ open: ({ pad, userMessage }) => [
235
+ `${pad}// ${expr} IS the thread: the stream writes the assistant message back`,
236
+ `${pad}// through it, so every round below re-encodes the live, current value.`,
237
+ `${pad}${expr} = [...${firstRead}, ${userMessage}];`,
238
+ ],
239
+ live: expr,
240
+ setter,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * A framework whose messages sit behind a GETTER plus a separate setter call —
246
+ * an Angular signal (`this.messages()` / `this.messages.set(…)`) or a Solid one
247
+ * (`messages()` / `setMessages(…)`). Same story as `liveThreadBinding`: the read
248
+ * is synchronous, so the live value IS the thread and React's turn-scoped copy
249
+ * (`REACT_THREAD`) is not needed. The only difference is that the write is a
250
+ * call, not an assignment, so the commit is passed in instead of derived.
251
+ */
252
+ function accessorThreadBinding(read: string, commit: (value: string) => string, setter: string): ThreadBinding {
253
+ return {
254
+ open: ({ pad, userMessage }) => [
255
+ `${pad}// ${read} IS the thread: the stream writes the assistant message back`,
256
+ `${pad}// through the setter, so every round below re-encodes the live, current`,
257
+ `${pad}// value. A signal reads back synchronously, so there is no React-style`,
258
+ `${pad}// stale-closure problem and no turn-local copy to keep in sync.`,
259
+ `${pad}${commit(`[...${read}, ${userMessage}]`)}`,
260
+ ],
261
+ live: read,
262
+ setter,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * The real-backend submit body. Four lines of adapter, the rest is fetch.
268
+ *
269
+ * This deliberately reverses the inline-everything policy that governs the mock
270
+ * path. Inlining was correct while the kit had nothing to import; it is now the
271
+ * reason a scaffold with kai-tool in its archetype rendered a panel no code path
272
+ * could ever fill, and the hand-rolled reader it replaces was wrong about
273
+ * multi-line SSE frames and codepoints split across a socket boundary.
274
+ *
275
+ * `createAssistantStream` appends the in-flight assistant message itself and
276
+ * folds every delta onto its `parts`, so the scaffold no longer hand-builds an
277
+ * empty assistant message. `readOpenAIStream` parses the SSE properly:
278
+ * keep-alive comments, multi-line frames, codepoints split across a socket
279
+ * boundary, tool calls and reasoning. The inline reader this replaces got the
280
+ * last three wrong and could only ever produce text.
281
+ *
282
+ * `commitSet(expr)` is how each framework writes a whole new messages array, and
283
+ * `setterAdapter` is the `SetMessages` updater createAssistantStream drives.
284
+ * Both are used by the single-round shape only; the tool-loop shape takes its
285
+ * thread and its setter from `thread` (see `ThreadBinding`).
286
+ */
287
+ function realStreamBody(opts: {
129
288
  pad: string;
130
289
  /** read the current messages array (for building `history`) */
131
290
  read: string;
132
- /** commit the initial user + empty-assistant pair */
133
- commitInitial: (expr: string) => string;
134
- /** commit a `prev.map(...)` update; `mapBody` is the body of `.map((m) => …)` */
135
- commitMap: (mapBody: string) => string;
291
+ /** commit a whole new messages array */
292
+ commitSet: (expr: string) => string;
293
+ /** the `SetMessages` functional-updater expression handed to createAssistantStream */
294
+ setterAdapter: string;
136
295
  /** set loading true/false */
137
296
  setLoading: (v: 'true' | 'false') => string;
297
+ /** the JSON.stringify argument for the POST body, given the thread expression */
298
+ bodyPayload: (thread: string) => string;
299
+ /** emit `as const` + the ChatMessage[] annotation (strict-TS frameworks) */
300
+ strictRoles?: boolean;
301
+ /** archetype renders kai-tool → emit the LIVE multi-round loop */
302
+ toolLoop: boolean;
303
+ /** the scaffold declares a card registry → the loop gets its cardFromToolCall arm */
304
+ cards?: boolean;
305
+ /** how this framework exposes the turn's thread (tool-loop shape only) */
306
+ thread: ThreadBinding;
307
+ /** where the submitted text comes from — every kai-* target reads it off the
308
+ * `kai-submit` CustomEvent; `solid` renders the SolidJS `PromptInput`, which
309
+ * has no such event, so its submitted text is the controlled input signal. */
310
+ valueSource?: string;
311
+ /** lines emitted right after the value is read and guarded */
312
+ afterValue?: string[];
138
313
  /**
139
- * Emit `as const` on role literals so they narrow to 'user'|'assistant' under
140
- * strict TS. Set to true for TypeScript frameworks (react/next); false for
141
- * plain-JS contexts (html) where `as const` is invalid syntax.
314
+ * The `mock` integration. Swaps ONLY the source of the stream — the canned
315
+ * responder instead of `fetch('/api/chat')` and leaves every other line
316
+ * identical. That identity is the point: see `mockRequest`.
142
317
  */
143
- strictRoles?: boolean;
318
+ mock?: boolean;
144
319
  }): string {
145
- const { pad, read, commitInitial, commitMap, setLoading, strictRoles = false } = opts;
320
+ const {
321
+ pad, read, commitSet, setterAdapter, setLoading, bodyPayload, strictRoles = false, toolLoop, thread,
322
+ cards = false, valueSource = 'e.detail.value', afterValue = [], mock = false,
323
+ } = opts;
146
324
  const asConst = strictRoles ? ' as const' : '';
147
- const mapBody = `(m.id === assistantId ? { ...m, content: answer } : m)`;
325
+ // Under strict TS an un-annotated array literal widens the part's `type` to
326
+ // `string`, so the later commit fails TS2322. Plain-JS contexts (html) have no
327
+ // type to annotate with.
328
+ const historyType = strictRoles ? ': ChatMessage[]' : '';
329
+ const userMessage = `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [{ type: 'text', text: value }] }`;
330
+
331
+ const open = toolLoop
332
+ ? thread.open({ pad, userMessage, typed: strictRoles })
333
+ : [
334
+ `${pad}const history${historyType} = [...${read}, ${userMessage}];`,
335
+ `${pad}${commitSet('history')}`,
336
+ ];
337
+ const threadExpr = toolLoop ? thread.live : 'history';
338
+ const setter = toolLoop ? thread.setter : setterAdapter;
339
+
340
+ /**
341
+ * The mock's request. Note what it is NOT: it is not a different streaming
342
+ * strategy, it is the same two lines with a different source expression.
343
+ *
344
+ * `mockResponse(value)` yields SSE frames that `readOpenAIStream` parses
345
+ * exactly as it parses a provider's, so the no-backend preview exercises the
346
+ * kit's real reader rather than a hand-rolled fold — and swapping to a real
347
+ * backend is replacing this one expression with the `fetch` below.
348
+ *
349
+ * It is also, deliberately, impossible to mistake for a real response: the
350
+ * stream opens with a `: kai-mock` SSE comment, every frame carries a
351
+ * `_kai_mock` marker, the model reports as `kai-mock` and the turn reports
352
+ * zero tokens. See `createMockResponder` in @kitn.ai/ui/state.
353
+ */
354
+ const mockRequest = (indent: string): string[] => [
355
+ `${indent}// NO BACKEND AND NO PROVIDER. mockResponse() returns canned SSE frames that`,
356
+ `${indent}// are read by the SAME parser a real model's response goes through, so this`,
357
+ `${indent}// preview exercises the real path. Every frame is marked as a mock (a`,
358
+ `${indent}// ': kai-mock' banner, a _kai_mock field, model 'kai-mock', zero usage), so`,
359
+ `${indent}// nothing here can be mistaken for a real turn.`,
360
+ `${indent}//`,
361
+ `${indent}// TO GO LIVE, only this one line changes: \`res\` becomes the POST to your`,
362
+ `${indent}// route, with toOpenAIMessages(${threadExpr}) as the body. Rather than copy`,
363
+ `${indent}// that request into a comment here — where it would drift from the real`,
364
+ `${indent}// one — scaffold again with a provider (integration: 'openrouter', 'ollama',`,
365
+ `${indent}// …) and the emitted code is the exact replacement, backend route included.`,
366
+ `${indent}// Everything below this line is already the real path and stays as it is.`,
367
+ `${indent}const res = mockResponse(value);`,
368
+ `${indent}const turn = await readOpenAIStream(res, stream);`,
369
+ ];
370
+
371
+ const realRequest = (indent: string): string[] => [
372
+ `${indent}const res = await fetch('/api/chat', {`,
373
+ `${indent} method: 'POST',`,
374
+ `${indent} headers: { 'Content-Type': 'application/json' },`,
375
+ `${indent} body: JSON.stringify(${bodyPayload(threadExpr)}),`,
376
+ `${indent}});`,
377
+ `${indent}// The finished turn: text, reasoning, tool calls, stop reason, usage. An`,
378
+ `${indent}// error FRAME inside a 200 stream lands on turn.error, and whatever`,
379
+ `${indent}// streamed before it is already on the message. A non-ok RESPONSE throws`,
380
+ `${indent}// instead, which is the catch below.`,
381
+ `${indent}const turn = await readOpenAIStream(res, stream);`,
382
+ ];
383
+
384
+ const request = mock ? mockRequest : realRequest;
385
+
148
386
  return [
149
- `${pad}const value = e.detail.value.trim();`,
387
+ `${pad}const value = ${valueSource}.trim();`,
150
388
  `${pad}if (!value) return;`,
151
- `${pad}const history = [...${read}, { id: crypto.randomUUID(), role: 'user'${asConst}, content: value }];`,
152
- `${pad}const assistantId = crypto.randomUUID();`,
153
- `${pad}${commitInitial(`[...history, { id: assistantId, role: 'assistant'${asConst}, content: '' }]`)}`,
389
+ ...afterValue.map((l) => `${pad}${l}`),
390
+ ...open,
154
391
  `${pad}${setLoading('true')}`,
155
- `${pad}// No backend: stream a canned reply client-side, one token at a time.`,
156
- `${pad}const reply = ${JSON.stringify(MOCK_REPLY)};`,
157
- `${pad}const tokens = reply.split(/(\\s+)/);`,
158
- `${pad}let answer = '';`,
159
- `${pad}for (const tok of tokens) {`,
160
- `${pad} await new Promise((r) => setTimeout(r, 24));`,
161
- `${pad} answer += tok;`,
162
- `${pad} // new array + object reference per chunk so kai-chat re-renders`,
163
- `${pad} ${commitMap(mapBody)}`,
392
+ `${pad}// createAssistantStream appends the in-flight assistant message and folds`,
393
+ `${pad}// every delta onto its parts. readOpenAIStream parses the SSE: keep-alive`,
394
+ `${pad}// comments, multi-line frames, split codepoints, tool calls, reasoning.`,
395
+ `${pad}const stream = createAssistantStream(${setter});`,
396
+ ...(toolLoop ? toolLoopBody({ pad, request, threadExpr, cards }) : [
397
+ `${pad}try {`,
398
+ ...request(`${pad} `),
399
+ `${pad} if (turn.error) console.error('Model error:', turn.error.message);`,
400
+ ]),
401
+ `${pad}} catch (err) {`,
402
+ `${pad} // Without this a bad key is a permanently blank assistant bubble plus an`,
403
+ `${pad} // unhandled rejection. abort() settles the message and flips any tool`,
404
+ `${pad} // panel still waiting on a result to output-error, so nothing spins`,
405
+ `${pad} // forever; text that already streamed stays put.`,
406
+ `${pad} stream.abort(err instanceof Error ? err.message : 'Request failed');`,
407
+ `${pad} console.error(err); // swap in your own error surface (a toast, a banner)`,
408
+ `${pad}} finally {`,
409
+ `${pad} // done() SETTLES the message: every sink call after it is dropped, which`,
410
+ `${pad} // is why the whole loop runs above it and not after.`,
411
+ `${pad} stream.done();`,
412
+ `${pad} ${setLoading('false')}`,
164
413
  `${pad}}`,
165
- `${pad}${setLoading('false')}`,
166
414
  ].join('\n');
167
415
  }
168
416
 
417
+ /**
418
+ * The multi-round tool loop, LIVE.
419
+ *
420
+ * It used to be emitted commented out, on the reasoning that a loop calling
421
+ * tools that do not exist yet would fail on the first run. That reasoning was
422
+ * wrong in the way that matters: the commented block named an undefined
423
+ * `runYourTool`, and its second round was prose ("then POST again with
424
+ * toOpenAIMessages() over the updated thread") describing a value the consumer
425
+ * had no way to obtain — `history` never contains the assistant message, the
426
+ * `messages` closure is stale by construction, and `AssistantStream` is
427
+ * write-only. So the archetype's headline capability could not be completed by
428
+ * uncommenting, or by any amount of local editing.
429
+ *
430
+ * Live, with a `runTool` stub that answers the one tool the scaffold declares,
431
+ * it runs end to end on the first submit: the panel reaches `output-available`
432
+ * and the model's answer streams into the same message, after the tool part, as
433
+ * a new text part.
434
+ *
435
+ * Both rounds drive the SAME `AssistantStream`, so the whole exchange folds into
436
+ * one assistant message with its parts in stream order — which is what
437
+ * `toOpenAIMessages` splits back into `assistant(tool_calls) → tool → assistant`
438
+ * on the way out.
439
+ */
440
+ function toolLoopBody(opts: {
441
+ pad: string;
442
+ request: (indent: string) => string[];
443
+ threadExpr: string;
444
+ /** the scaffold declares a card registry, so a `kai_*` call is a card, not a tool */
445
+ cards?: boolean;
446
+ }): string[] {
447
+ const { pad, request, threadExpr, cards = false } = opts;
448
+ // THE ONE LINE THAT CLOSES THE LOOP.
449
+ //
450
+ // A `kai_confirm` call is not a tool to run: `cardTools` handed the model the
451
+ // card's own data schema, so the arguments ARE the envelope's `data` and the
452
+ // mapping is identity. `cardFromToolCall` returns null for every other name, so
453
+ // the app's own tools fall through untouched and this costs nothing when the
454
+ // model never asks for a card.
455
+ //
456
+ // The provider's `tool_call_id` becomes `CardEnvelope.id` verbatim, which is what
457
+ // buys free revision: a model correcting a card re-sends the same call id and
458
+ // `addCard` upserts in place instead of stacking a second copy.
459
+ const cardBranch = cards
460
+ ? [
461
+ `${pad} // A kai_* call is a CARD, not a tool to run: cardTools handed the model`,
462
+ `${pad} // the card's own schema, so the arguments ARE the envelope's data.`,
463
+ `${pad} // cardFromToolCall returns null for anything else, which falls through to`,
464
+ `${pad} // your own tools below.`,
465
+ `${pad} const card = cardFromToolCall(call.name, call.input ?? {}, { id: call.id });`,
466
+ `${pad} if (card) {`,
467
+ `${pad} // The id IS the tool call id, so a model that revises the card re-sends`,
468
+ `${pad} // the same id and this replaces it in place instead of drawing a second.`,
469
+ `${pad} stream.addCard(card);`,
470
+ `${pad} // Answer the call now: the card is on screen and the model must not sit`,
471
+ `${pad} // waiting on a result. The user's click comes back through the card's own`,
472
+ `${pad} // kai-card-event, not through this round.`,
473
+ `${pad} applyToolOutput(stream, call.id, { status: 'awaiting_user' });`,
474
+ `${pad} continue;`,
475
+ `${pad} }`,
476
+ ]
477
+ : [];
478
+ return [
479
+ `${pad}// Cap the rounds: a runaway model is a runaway bill.`,
480
+ `${pad}const MAX_TOOL_ROUNDS = 4;`,
481
+ `${pad}try {`,
482
+ `${pad} for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {`,
483
+ ...request(`${pad} `),
484
+ `${pad} if (turn.error) { console.error('Model error:', turn.error.message); break; }`,
485
+ `${pad} // The kit never RUNS a tool. Calls the provider executed itself already`,
486
+ `${pad} // carry their output, and a malformed one has nothing to run.`,
487
+ `${pad} const pending = turn.toolCalls.filter((c) => !c.error && !c.providerExecuted);`,
488
+ `${pad} if (pending.length === 0) break; // the model answered — the turn is done`,
489
+ `${pad} for (const call of pending) {`,
490
+ ...cardBranch,
491
+ `${pad} try {`,
492
+ `${pad} applyToolOutput(stream, call.id, await runTool(call.name, call.input ?? {}));`,
493
+ `${pad} } catch (err) {`,
494
+ `${pad} // The panel must not spin forever because your tool threw; the model`,
495
+ `${pad} // is told about the failure and can react to it next round.`,
496
+ `${pad} applyToolFailure(stream, call.id, err instanceof Error ? err.message : 'Tool failed');`,
497
+ `${pad} }`,
498
+ `${pad} }`,
499
+ `${pad} // Next round re-encodes ${threadExpr}, which now carries this round's calls`,
500
+ `${pad} // AND their results: toOpenAIMessages splits the turn at the tool boundary`,
501
+ `${pad} // into assistant(tool_calls) -> tool(result) -> assistant(answer).`,
502
+ `${pad} }`,
503
+ ];
504
+ }
505
+
506
+ /**
507
+ * The tool runner the loop calls, emitted as a working stub.
508
+ *
509
+ * The kit never calls a consumer's function — this is the seam where the host
510
+ * does. It answers the `search` tool the scaffold declares in `tools` so the
511
+ * loop completes a round on the first run against a real model, and says so
512
+ * loudly enough that nobody ships the canned answer.
513
+ *
514
+ * No template literals: the emitted code is itself inside one.
515
+ */
516
+ function toolRunnerLines(pad: string, typed: boolean): string[] {
517
+ const sig = typed
518
+ ? `async function runTool(name: string, input: Record<string, unknown>): Promise<Record<string, unknown>> {`
519
+ : `async function runTool(name, input) {`;
520
+ return [
521
+ `${pad}// YOUR tools run here. The kit never calls one: the model asks, you execute,`,
522
+ `${pad}// and applyToolOutput reports the result back into the panel and into the`,
523
+ `${pad}// next round's request. Whatever you return is JSON-encoded as the result.`,
524
+ `${pad}${sig}`,
525
+ `${pad} if (name === 'search') {`,
526
+ `${pad} // STUB — replace with a real search call. Bracket access, not \`input.query\`:`,
527
+ `${pad} // \`input\` is an index signature, and Angular's stock tsconfig turns on`,
528
+ `${pad} // noPropertyAccessFromIndexSignature, which rejects the dotted form (TS4111).`,
529
+ `${pad} return { results: ['No search backend wired up yet. Query: ' + String(input['query'] ?? '')] };`,
530
+ `${pad} }`,
531
+ `${pad} return { error: 'Unknown tool: ' + name };`,
532
+ `${pad}}`,
533
+ ];
534
+ }
535
+
536
+ /** True when the surface renders a tool panel, so the scaffold needs the loop. */
537
+ function hasToolPanel(components: readonly string[]): boolean {
538
+ return components.includes('kai-tool');
539
+ }
540
+
541
+ /**
542
+ * True when the scaffold emits the generative-UI card round trip: a
543
+ * `createCardRegistry` declaration, `cardTools()` in the tools array, and
544
+ * `cardFromToolCall()` in the tool loop.
545
+ *
546
+ * CARDS FOLD INTO `kai-tool`. THERE IS NO SEPARATE CARD COMPONENT, AND THAT IS A
547
+ * CHOICE. A card is a tool call the model makes and the app draws instead of
548
+ * executing, so "the model reaches for a tool" is the same capability `kai-tool`
549
+ * already stands for, and a distinct card capability would add another surface's
550
+ * worth of front-end cells to `verify:scaffold` to say it twice.
551
+ *
552
+ * Written as its own predicate rather than being spelled `hasToolPanel` at each
553
+ * call site even though it returns exactly that today. The fold is a decision with
554
+ * ONE place to change: point this at a different component and the registry, the
555
+ * tools and the loop line move together. Inlining `hasToolPanel` would scatter the
556
+ * decision over eight renderers and make moving it a search-and-replace.
557
+ */
558
+ function bearsCards(components: readonly string[]): boolean {
559
+ return hasToolPanel(components);
560
+ }
561
+
562
+ /**
563
+ * Which provider envelope `cardTools()` should project into, for the wire this
564
+ * integration's route really speaks.
565
+ *
566
+ * `cardTools` takes `provider` as a REQUIRED argument because the three shapes are
567
+ * genuinely different documents: OpenAI nests the schema under
568
+ * `function.parameters`, Anthropic puts it at `input_schema`, and `jsonschema`
569
+ * returns a bare `{name, description, schema}` that `ai@7`'s `jsonSchema()` takes
570
+ * directly. Emitting the wrong one is not a style problem — the provider 400s, or
571
+ * (worse) ignores the tool and the card silently never arrives.
572
+ *
573
+ * READ OFF THE INTEGRATION, not inferred. This used to be a table keyed on
574
+ * `streamFormat`, and that table was wrong in a way only `anthropic` made
575
+ * visible: `streamFormat` describes the RESPONSE stream, the tool envelope is a
576
+ * REQUEST concern, and the two only looked correlated while every integration
577
+ * that forwarded tools happened to be `openai-sse`. `anthropic` is
578
+ * `streamFormat: 'native'`, the table mapped `native` to `null`, and a null here
579
+ * means a scaffold that hands the model a tools array with no card in it.
580
+ *
581
+ * Nor is the fix "key on the provider instead". Anthropic's emitted route
582
+ * CONVERTS the array itself — `toAnthropicTools` reads `raw.function.name` and
583
+ * `raw.function.parameters` — so it wants the OPENAI envelope even though it
584
+ * POSTs to api.anthropic.com. Only the route knows its own request contract, so
585
+ * the integration declares it (`clientToolFormat`), exactly as it already
586
+ * declares `forwardsFromClient`.
587
+ *
588
+ * `null` here means the integration does not forward a tools array at all, which
589
+ * is the honest state for langgraph/mastra/pi/vercel-ai-sdk (their routes own the
590
+ * tool list server-side). It can no longer mean "forwards tools, envelope
591
+ * unknown": `IntegrationSchema` rejects that combination at the catalog boundary,
592
+ * `assertCardToolFormat` below refuses to emit it, and `scaffold.test.ts` +
593
+ * `cardRoundTripCheck` both assert it against the real registry.
594
+ */
595
+ function cardToolProviderFor(integration: Integration): 'openai' | 'anthropic' | 'jsonschema' | null {
596
+ return integration.clientToolFormat ?? null;
597
+ }
598
+
599
+ /**
600
+ * The emit-time hard stop.
601
+ *
602
+ * The schema refinement catches a malformed catalog entry, but `Integration`
603
+ * values also arrive from tests and from callers that build one by hand, so the
604
+ * emit path refuses rather than trusting that. Throwing here is the point: the
605
+ * failure this guards is SILENT by nature — a tools array with no card tool in it
606
+ * produces a model that is never told a card exists, so it never emits one, and
607
+ * nothing anywhere says why. A thrown error naming the integration is strictly
608
+ * better than a scaffold that looks fine and renders nothing.
609
+ */
610
+ function assertCardToolFormat(integration: Integration): 'openai' | 'anthropic' | 'jsonschema' {
611
+ const provider = cardToolProviderFor(integration);
612
+ if (provider === null) {
613
+ throw new Error(
614
+ `Integration '${integration.id}' forwards a 'tools' array but declares no clientToolFormat, so the ` +
615
+ `scaffold would offer the model no card tool at all. Set clientToolFormat on the integration to the ` +
616
+ `envelope its route expects: 'openai' | 'anthropic' | 'jsonschema'.`,
617
+ );
618
+ }
619
+ return provider;
620
+ }
621
+
622
+ /**
623
+ * What the scaffolder INTENDS to emit for one (components, integration) pair, so a
624
+ * guard can check the eight renderers against the decision instead of restating it.
625
+ *
626
+ * Exported for `cardRoundTripCheck` in scripts/verify-scaffold-compiles.mjs and
627
+ * for scaffold.test.ts. It reads the same three predicates `renderSurface` reads
628
+ * and nothing else, which is the point: the failure it exists to catch is one of
629
+ * the eight framework renderers not following the decision, and the check would be
630
+ * worthless if the guard hard-coded "the agentic archetype, 8 integrations".
631
+ *
632
+ * KEYED ON A COMPONENTS LIST, not an archetype id, for the same reason
633
+ * `renderSurface` is. A guard that took an archetype id could only ever check the
634
+ * six presets, so the surfaces the feature multi-select makes reachable — the ones
635
+ * with no preset — would emit cards with nothing asserting they did. The archetype
636
+ * axis was also the narrower one: `getArchetype` returns `undefined` for anything
637
+ * not in the catalog, so the guard could not have been pointed at a new surface
638
+ * without first shipping a catalog entry for it.
639
+ *
640
+ * It cannot catch the emitter and the plan being wrong TOGETHER, so it is not the
641
+ * only check: `cardRoundTripCheck` also asserts, without consulting this function,
642
+ * that a scaffold declaring a `tools` array always calls `cardTools` — a tools
643
+ * array offered to a model with no card in it is the silent hole.
644
+ */
645
+ export function cardEmitPlan(
646
+ components: readonly string[],
647
+ integrationId: string,
648
+ ): { cards: boolean; tools: boolean; provider: 'openai' | 'anthropic' | 'jsonschema' | null } | null {
649
+ const integration = getIntegration(integrationId);
650
+ if (!integration || components.length === 0) return null;
651
+ const isMock = integration.id === 'mock';
652
+ const cards = !isMock && bearsCards(components);
653
+ const tools = !isMock && emitsToolSchemas(components, integration);
654
+ return { cards, tools, provider: cards && tools ? cardToolProviderFor(integration) : null };
655
+ }
656
+
657
+ /**
658
+ * The import lines a real-backend scaffold needs on top of the ones it already
659
+ * emits.
660
+ *
661
+ * Every name here MUST be referenced by live emitted code: every starter in this
662
+ * repo (and create-vite's own TypeScript template) sets `noUnusedLocals`, so one
663
+ * unreferenced name fails `npm run build` with TS6133 in a stock app.
664
+ * `applyToolOutput`/`applyToolFailure` used to be excluded for exactly that
665
+ * reason — the tool loop that called them was commented out. The loop is live
666
+ * now, so they are imported, and only when it is emitted.
667
+ *
668
+ * `type SetMessages` is React-only for the same reason: it annotates the `set`
669
+ * adapter in `REACT_THREAD`, which no other framework needs.
670
+ *
671
+ * `typed` pulls in the kit's own `ChatMessage` for the strict-TS frameworks; the
672
+ * plain-JS html target must not emit a type import, and `solid` takes the same
673
+ * type from `@kitn.ai/ui` alongside its components.
674
+ *
675
+ * The `mock` integration imports from here too. It used to import nothing at all
676
+ * and hand-declare a narrow local `ChatMessage` instead — a subset with no
677
+ * `raw`/`rawInput` and no `source`/`file` part variants, which a message the kit
678
+ * itself produced did not satisfy. Now that the mock streams through
679
+ * `readOpenAIStream` like everything else, it takes the real type.
680
+ */
681
+ function wireImportLines(opts: {
682
+ pad?: string;
683
+ typed: boolean;
684
+ /** the live tool loop is emitted → it calls applyToolOutput/applyToolFailure */
685
+ toolLoop?: boolean;
686
+ /** the framework's thread binding declares `const set: SetMessages` */
687
+ setMessagesType?: boolean;
688
+ /** the card registry is emitted → createCardRegistry + cardFromToolCall */
689
+ cards?: boolean;
690
+ /** the tools array calls cardTools() as well */
691
+ cardTools?: boolean;
692
+ /** the `mock` integration → import the shared responder, not a fetch encoder */
693
+ mock?: boolean;
694
+ }): string[] {
695
+ const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false } = opts;
696
+ const stateNames = [
697
+ 'createAssistantStream',
698
+ // The mock's canned reply comes from the kit, not from a copy pasted into
699
+ // this file. One implementation, shared with create-kai and the starters.
700
+ ...(mock ? ['createMockResponder'] : []),
701
+ ...(typed ? ['type ChatMessage'] : []),
702
+ ...(typed && setMessagesType ? ['type SetMessages'] : []),
703
+ ].join(', ');
704
+ // `noUnusedLocals` is enforced over the emitted scaffolds (verify:scaffold), so
705
+ // the mock must NOT name toOpenAIMessages: it has no request body to encode.
706
+ const wireNames = [
707
+ 'readOpenAIStream',
708
+ ...(mock ? [] : ['toOpenAIMessages']),
709
+ ...(toolLoop ? ['applyToolOutput', 'applyToolFailure'] : []),
710
+ ].join(', ');
711
+ // Same noUnusedLocals rule as above, one entry finer: `cardTools` is named only
712
+ // when the integration's route actually forwards a tools array, because on the
713
+ // ones that build their tools server-side the registry is still wired to the
714
+ // client (cardTypes/cardSchemas) while nothing here calls cardTools.
715
+ const schemaNames = [
716
+ 'createCardRegistry',
717
+ ...(emitsCardTools ? ['cardTools'] : []),
718
+ ...(toolLoop ? ['cardFromToolCall'] : []),
719
+ ].join(', ');
720
+ return [
721
+ `${pad}import { ${stateNames} } from '@kitn.ai/ui/state';`,
722
+ `${pad}import { ${wireNames} } from '@kitn.ai/ui/wire';`,
723
+ // Server-safe: no DOM, no Solid runtime. The same import works in the route
724
+ // when the registry moves to its own cards.ts.
725
+ ...(cards ? [`${pad}import { ${schemaNames} } from '@kitn.ai/ui/schemas';`] : []),
726
+ ];
727
+ }
728
+
729
+ /**
730
+ * The mock responder, declared at MODULE scope.
731
+ *
732
+ * Module scope rather than inside the submit handler because the responder owns
733
+ * the cursor into its canned replies: rebuilt per turn it would answer with the
734
+ * first reply forever, and the seeded conversation would stop making sense on
735
+ * the second message.
736
+ */
737
+ function mockResponderInit(pad = ''): string[] {
738
+ return [
739
+ `${pad}// The kit's own mock responder — no backend, no key, no network. It streams`,
740
+ `${pad}// canned SSE frames through the same reader a real provider's response uses,`,
741
+ `${pad}// and marks every one of them as a mock. Shared with create-kai and the`,
742
+ `${pad}// starters, so there is one implementation of this and not seven.`,
743
+ `${pad}const mockResponse = createMockResponder();`,
744
+ ];
745
+ }
746
+
747
+ /** The POST body for a real backend. `toOpenAIMessages` keeps tool calls and
748
+ * tool results on the way back, which is what makes a second round possible;
749
+ * `tools` is what makes a FIRST tool call possible at all. Each field appears
750
+ * only when the emitted code declares the const it names.
751
+ *
752
+ * `thread` is the expression holding the messages to encode — a const built
753
+ * once for a single-round scaffold, the LIVE thread for a tool loop that
754
+ * re-encodes it every round. */
755
+ function realBodyPayload(opts: { defaultModel?: string; tools: boolean }): (thread: string) => string {
756
+ return (thread: string) => {
757
+ const fields = [
758
+ ...(opts.defaultModel ? ['model'] : []),
759
+ `messages: toOpenAIMessages(${thread})`,
760
+ ...(opts.tools ? ['tools'] : []),
761
+ ];
762
+ return `{ ${fields.join(', ')} }`;
763
+ };
764
+ }
765
+
169
766
  // ── SCAF-8: per-integration default model ids ─────────────────────────────────
170
767
 
171
768
  /**
172
- * Return a sensible default model id for integrations whose route forwards a
173
- * `model` field to the upstream provider. The dev can change this const.
174
- * Returns undefined for integrations whose route does not use a model param.
769
+ * Default model id per integration whose route forwards one.
770
+ *
771
+ * THE ID IS HOST-SPECIFIC, and there is no such thing as a safe generic one.
772
+ * This used to fall through to `'openai/gpt-4o-mini'` for anything unlisted, on
773
+ * the reasoning that "a route that forwards the client's model is by definition
774
+ * pointed at an OpenAI-compatible endpoint". That was false twice over the
775
+ * moment a first-party provider landed: `openai/gpt-4o-mini` is an OPENROUTER
776
+ * slug — api.openai.com 404s the prefixed form, and api.anthropic.com rejects it
777
+ * outright — so a scaffold generated for the provider it names could not run
778
+ * against it.
779
+ *
780
+ * tsc cannot see any of this; every one of those strings compiles. The guard is
781
+ * `scaffold.test.ts` → "the emitted model id is valid for the host its route
782
+ * POSTs to", which reads the id out of the EMITTED scaffold and the host out of
783
+ * the route source, so a new integration cannot reintroduce a wrong one.
784
+ */
785
+ const CLIENT_MODEL_IDS: Record<string, string> = {
786
+ // Vendor-prefixed `vendor/model`: OpenRouter's own id space, and the ONLY one
787
+ // of the three where the prefix belongs.
788
+ openrouter: 'openai/gpt-4o-mini',
789
+ // No vendor prefix. This is what the route already pinned, so moving the knob
790
+ // to the client changes the wire not at all.
791
+ openai: 'gpt-4o-mini',
792
+ // Anthropic's id space. Matches what the route pinned; 'claude-sonnet-5' and
793
+ // 'claude-haiku-4-5' are the cheaper swaps (see this integration's runNote).
794
+ anthropic: 'claude-opus-5',
795
+ };
796
+
797
+ /**
798
+ * The default model id for an integration whose ROUTE reads the client's `model`
799
+ * field, and undefined for every other one.
800
+ *
801
+ * This used to be a substring test (`routeSrc.includes('model')`), which is true
802
+ * of any template that so much as writes `model: 'llama3.2'`. That emitted an
803
+ * editable `const model` into ollama, langgraph, vercel-ai-sdk and cloudflare
804
+ * scaffolds whose routes pin their own model and never read the field, so
805
+ * changing it did nothing, and cloudflare's default was not even a valid Workers
806
+ * AI id. `forwardsFromClient` states the fact instead of guessing at it.
175
807
  */
176
808
  function defaultModelFor(integration: Integration): string | undefined {
177
- // Detect: any route template destructures `model` from the request body.
178
- const routeSrc = Object.values(integration.routeTemplates).join('\n');
179
- if (!routeSrc.includes('model')) return undefined;
180
-
181
- const defaults: Record<string, string> = {
182
- openrouter: 'openai/gpt-4o-mini',
183
- ollama: 'llama3.2',
184
- 'vercel-ai-sdk': 'openai/gpt-4o-mini',
185
- cloudflare: 'openai/gpt-4o-mini',
809
+ if (!integration.forwardsFromClient.includes('model')) return undefined;
810
+ const id = CLIENT_MODEL_IDS[integration.id];
811
+ // No fallback, deliberately — the old `?? 'openai/gpt-4o-mini'` is what let a
812
+ // first-party provider inherit an OpenRouter slug and emit a scaffold that
813
+ // 404s on its own host. A model id is a per-host fact; an integration that
814
+ // forwards one has to say which.
815
+ if (id === undefined) {
816
+ throw new Error(
817
+ `Integration '${integration.id}' forwards the client's 'model' but has no CLIENT_MODEL_IDS entry, so the ` +
818
+ `scaffold would emit a model id that is not valid for the host its route POSTs to. Add one in ` +
819
+ `mcp/tools/scaffold.ts.`,
820
+ );
821
+ }
822
+ return id;
823
+ }
824
+
825
+ /**
826
+ * True when the scaffold should declare tool schemas and put them in the body.
827
+ *
828
+ * Both halves matter. A tool panel with no tools array in the request is a panel
829
+ * no code path can populate: the model never emits a tool call, so kai-tool
830
+ * renders nothing forever. And a tools array the route drops on the floor is the
831
+ * same dead-const defect as the model one: langgraph builds its tools into the
832
+ * agent, Mastra and Pi into the harness, and none of them read the field.
833
+ *
834
+ * The archetype half is an OR, not just `hasToolPanel`, because there are now two
835
+ * reasons a scaffold needs a tools array: a tool panel with nothing to call, and a
836
+ * card the model can never be asked for. Both operands are true for `agentic`
837
+ * today (see `bearsCards`), so the `||` changes no output — it is here so that
838
+ * moving cards to an archetype without `kai-tool` keeps emitting their tools
839
+ * instead of silently dropping them.
840
+ */
841
+ function emitsToolSchemas(components: readonly string[], integration: Integration): boolean {
842
+ const needsToolsArray = hasToolPanel(components) || bearsCards(components);
843
+ return needsToolsArray && integration.forwardsFromClient.includes('tools');
844
+ }
845
+
846
+ /**
847
+ * The tool schemas, emitted beside the model const. OpenAI function-calling
848
+ * form, which is what every passthrough route forwards.
849
+ *
850
+ * `search` on purpose: it is the tool `SAMPLE_AGENTIC_MESSAGE` already shows in
851
+ * the seeded thread, so the sample panel and the live one describe one tool.
852
+ *
853
+ * The CARD tools are appended by calling `cardTools(cards, { provider })`, never
854
+ * by writing a card's shape out here. That is the whole point of the emit
855
+ * contract: a `confirm` card's schema already exists, the kit already validates
856
+ * arriving cards against it, and a scaffolder that restated it would be the sixth
857
+ * copy of one shape — the copy that drifts, in the file a developer is least
858
+ * likely to re-read.
859
+ */
860
+ function toolSchemaLines(
861
+ pad: string,
862
+ cardProvider: 'openai' | 'anthropic' | 'jsonschema' | null = null,
863
+ ): string[] {
864
+ // The `provider` note differs per shape, because the three shapes are genuinely
865
+ // different documents and a developer swapping backends needs to know which line
866
+ // to change.
867
+ const PROVIDER_NOTE: Record<'openai' | 'anthropic' | 'jsonschema', string[]> = {
868
+ openai: [
869
+ `// \`provider\` is required, not cosmetic: this route POSTs to an OpenAI-compatible`,
870
+ `// endpoint, which wants { type: 'function', function: { parameters } }. Anthropic`,
871
+ `// wants the schema at \`input_schema\` instead — one word here, not a rewrite.`,
872
+ ],
873
+ anthropic: [
874
+ `// \`provider\` is required, not cosmetic: Anthropic's /v1/messages wants the schema`,
875
+ `// at \`input_schema\`, where an OpenAI-compatible endpoint wants it nested under`,
876
+ `// \`function.parameters\`. One word here, not a rewrite.`,
877
+ ],
878
+ jsonschema: [
879
+ `// 'jsonschema' returns a bare { name, description, schema }, which is the form the`,
880
+ `// AI SDK takes directly: tool({ description, inputSchema: jsonSchema(def.schema) }).`,
881
+ `// No provider envelope, so our schema is not an awkward second source of truth.`,
882
+ ],
186
883
  };
187
- return defaults[integration.id] ?? 'openai/gpt-4o-mini';
884
+ const cardLines =
885
+ cardProvider === null
886
+ ? []
887
+ : [
888
+ `${pad} // Every card type \`cards\` declares, as a tool definition GENERATED from the`,
889
+ `${pad} // card's own JSON Schema — the same schema the kit validates arriving cards`,
890
+ `${pad} // against, so the tool the model sees and the card that renders cannot drift.`,
891
+ ...PROVIDER_NOTE[cardProvider].map((l) => `${pad} ${l}`),
892
+ `${pad} ...cardTools(cards, { provider: '${cardProvider}' }),`,
893
+ ];
894
+ return [
895
+ `${pad}// The tools the model may call. The request body carries this array; without`,
896
+ `${pad}// it the model never emits a tool call and the kai-tool panel stays empty,`,
897
+ `${pad}// which is the whole reason it is here. Replace with your own. The kit never`,
898
+ `${pad}// RUNS a tool: see the loop in onSubmit for who does.`,
899
+ `${pad}const tools = [`,
900
+ `${pad} {`,
901
+ `${pad} type: 'function',`,
902
+ `${pad} function: {`,
903
+ `${pad} name: 'search',`,
904
+ `${pad} description: 'Search the web for up-to-date information.',`,
905
+ `${pad} parameters: {`,
906
+ `${pad} type: 'object',`,
907
+ `${pad} properties: { query: { type: 'string', description: 'What to search for.' } },`,
908
+ `${pad} required: ['query'],`,
909
+ `${pad} },`,
910
+ `${pad} },`,
911
+ `${pad} },`,
912
+ ...cardLines,
913
+ `${pad}];`,
914
+ ];
915
+ }
916
+
917
+ /**
918
+ * The card registry: the ONE place this app writes down which cards it renders.
919
+ *
920
+ * Emitted at MODULE scope in every framework, deliberately. Three separate things
921
+ * read it — the tools array, the tool loop's `cardFromToolCall` fall-through, and
922
+ * the client's `cardTypes`/`cardSchemas` wiring — and in the Solid target one of
923
+ * them (`renderPart`) is a top-level function that cannot see a component-local
924
+ * const. In a real app this block is `cards.ts`, imported by the client AND by the
925
+ * route; the scaffold's tool loop runs in the browser, so one module holds both
926
+ * ends here and the comment says where it goes when they separate.
927
+ *
928
+ * `use` is a real narrowing, not decoration: it is what the model is OFFERED, so
929
+ * two entries is two tool definitions per request instead of seven. It does not
930
+ * narrow what RENDERS — `mergeCardTags` unions all seven built-ins in regardless,
931
+ * so a `tasks` envelope arriving from somewhere else still draws.
932
+ */
933
+ function cardRegistryLines(pad = ''): string[] {
934
+ return [
935
+ `${pad}// The card types this app renders, declared ONCE for both ends of the round`,
936
+ `${pad}// trip: \`cardTools(cards, …)\` turns them into the tool definitions the model`,
937
+ `${pad}// is offered, and cardTypes/cardSchemas below tell <kai-chat> what draws an`,
938
+ `${pad}// arriving card and what a valid one looks like. Move this to its own cards.ts`,
939
+ `${pad}// the moment your BACKEND needs it too — it imports nothing from the DOM.`,
940
+ `${pad}const cards = createCardRegistry({`,
941
+ `${pad} // The built-ins the model is OFFERED. Omit \`use\` for all seven; every entry`,
942
+ `${pad} // is one more tool definition in every request. This does NOT narrow what`,
943
+ `${pad} // RENDERS: all seven built-in cards still draw if one turns up.`,
944
+ `${pad} use: ['confirm', 'choice'],`,
945
+ `${pad} // YOUR card types go here, and this is the half that makes the generative UI`,
946
+ `${pad} // yours. \`schema\` is both what the model is told and what arriving data is`,
947
+ `${pad} // checked against; \`tag\` is your own custom element.`,
948
+ `${pad} // custom: {`,
949
+ `${pad} // 'pricing-table': {`,
950
+ `${pad} // schema: pricingSchema, // import pricingSchema from './pricing.schema.json'`,
951
+ `${pad} // tag: 'my-pricing-table',`,
952
+ `${pad} // description: 'Show a plan comparison the user can pick from.',`,
953
+ `${pad} // },`,
954
+ `${pad} // },`,
955
+ `${pad}});`,
956
+ ];
957
+ }
958
+
959
+ /**
960
+ * The two card properties, as the JS PROPERTIES they have to be.
961
+ *
962
+ * `cards.tags` and `cards.validationSchemas` carry the CUSTOM types only, because
963
+ * the kit merges its own seven in itself — so with no `custom` block above they are
964
+ * both `{}` and these two lines currently do nothing. They are emitted anyway, and
965
+ * that is the point of the emit contract rather than an oversight: filling in
966
+ * `custom` is then a one-line change instead of an archaeology exercise through the
967
+ * docs, and the two names are the ones a developer would otherwise have to
968
+ * discover. `verify:scaffold` compiles the assignment in all eight frameworks, so
969
+ * the shapes are proven compatible rather than asserted to be.
970
+ *
971
+ * Never an attribute. `cardTypes`/`cardSchemas` are objects, and an object set as
972
+ * an HTML attribute stringifies to "[object Object]" and silently registers
973
+ * nothing.
974
+ */
975
+ const CARD_PROP_COMMENT = [
976
+ `// Cards, as JS PROPERTIES (objects can never be HTML attributes). \`tags\` says`,
977
+ `// what DRAWS your own card type; \`cardSchemas\` says what a VALID one looks like,`,
978
+ `// so a model that gets the shape wrong shows a named diagnostic instead of empty`,
979
+ `// chrome. Both are {} until the \`custom\` block above is filled in; the built-in`,
980
+ `// seven draw and validate without them.`,
981
+ ];
982
+
983
+ // `//` comment lines as a JSX comment block, for the three JSX targets.
984
+ //
985
+ // A JSX comment (a braces pair wrapping a block comment) is legal only at ELEMENT
986
+ // position; between attributes it is a parse error. So these go above the tag
987
+ // rather than among its props. Written as line comments here because the thing
988
+ // being described cannot be spelled inside a block comment.
989
+ function jsxComment(lines: readonly string[], pad: string): string[] {
990
+ return lines.map((l) => `${pad}{/* ${l.replace(/^\/\/ ?/, '')} */}`);
188
991
  }
189
992
 
190
993
  // ── SCAF-9: message-embedded companion logic ──────────────────────────────────
@@ -206,41 +1009,123 @@ const MESSAGE_EMBEDDED_TAGS = new Set(['kai-tool', 'kai-reasoning']);
206
1009
  */
207
1010
  const WORKSPACE_STRUCTURAL_TAGS = new Set(['kai-resizable', 'kai-artifact']);
208
1011
 
209
- /** True when the archetype is the resizable split workspace (chat + artifact). */
210
- function isWorkspace(archetype: Archetype): boolean {
211
- return archetype.components.includes('kai-resizable') && archetype.components.includes('kai-artifact');
1012
+ /** True when the surface is the resizable split workspace (chat + artifact). */
1013
+ function isWorkspace(components: readonly string[]): boolean {
1014
+ return components.includes('kai-resizable') && components.includes('kai-artifact');
212
1015
  }
213
1016
 
214
1017
  /**
215
- * A sample assistant message that demonstrates embedded tool + reasoning so the
216
- * agentic archetype renders correctly out of the box.
1018
+ * A sample assistant message showing embedded tool + reasoning.
1019
+ *
1020
+ * IT IS NOT SEEDED INTO A REAL SCAFFOLD'S THREAD, and used not to be optional.
1021
+ * A fabricated assistant turn in the initial state is conversation history the
1022
+ * user never had, and it does three things:
1023
+ *
1024
+ * 1. it is SENT TO THE MODEL on turn one, as `assistant(tool_calls tc_001)` +
1025
+ * `tool(result)`, so the very first request claims the model made a call it
1026
+ * never made and that a tool it may not have answered;
1027
+ * 2. it THROWS in `toAnthropicMessages` — a reasoning part with no verbatim
1028
+ * `raw` cannot be echoed back as a thinking block, which is a
1029
+ * `WireEncodeError` before the request is even built;
1030
+ * 3. it kills the thread's empty state, so the `suggestions` the caller passed
1031
+ * to `scaffold` never render at all.
1032
+ *
1033
+ * The live tool loop fills the panel for real on the first submit, so nothing is
1034
+ * lost by starting empty. The `mock` preview has no provider to lie to and no
1035
+ * encoder to throw, so it gets this as a COMMENTED fixture (see
1036
+ * `sampleSeedComment`): the one place uncommenting it is safe.
217
1037
  */
218
1038
  const SAMPLE_AGENTIC_MESSAGE = {
219
1039
  id: 'sample-assistant',
220
1040
  role: 'assistant' as const,
221
- content: 'Searched the web for current pricing.',
222
- reasoning: { text: 'I should call the search tool to get up-to-date data.' },
223
- tools: [
1041
+ parts: [
1042
+ { type: 'reasoning' as const, text: 'I should call the search tool to get up-to-date data.' },
224
1043
  {
225
- type: 'search',
226
- state: 'output-available' as const,
227
- input: { query: 'current pricing' },
228
- output: { results: ['Result A', 'Result B'] },
229
- toolCallId: 'tc_001',
1044
+ type: 'tool' as const,
1045
+ tool: {
1046
+ type: 'search',
1047
+ state: 'output-available' as const,
1048
+ input: { query: 'current pricing' },
1049
+ output: { results: ['Result A', 'Result B'] },
1050
+ toolCallId: 'tc_001',
1051
+ },
230
1052
  },
1053
+ { type: 'text' as const, text: 'Searched the web for current pricing.' },
231
1054
  ],
232
1055
  };
233
1056
 
1057
+ /**
1058
+ * What the agentic archetype emits where the seed used to be.
1059
+ *
1060
+ * `mock` gets the fixture commented out beside an empty thread; a real backend
1061
+ * gets the explanation only, because there the fixture is unsafe at any level of
1062
+ * commenting-out (see `SAMPLE_AGENTIC_MESSAGE`).
1063
+ *
1064
+ * `decl` is the framework's own line(s) for the fixture, so what an editor's
1065
+ * uncomment produces is complete: the declaration AND whatever hands it to the
1066
+ * thread. A commented block that leaves an unused `sampleMessages` behind fails
1067
+ * `noUnusedLocals` the moment someone takes it up on the offer.
1068
+ */
1069
+ function sampleSeedComment(
1070
+ isMock: boolean,
1071
+ pad: string,
1072
+ decl: (literal: string) => string[],
1073
+ ): string[] {
1074
+ const shared = [
1075
+ `${pad}// Tool calls and reasoning render INSIDE the thread, as parts on the`,
1076
+ `${pad}// assistant message — the stream in onSubmit builds them as the model works.`,
1077
+ `${pad}// The thread starts EMPTY so the suggestions show and turn one carries no`,
1078
+ `${pad}// conversation the user never had.`,
1079
+ ];
1080
+ if (!isMock) return shared;
1081
+ return [
1082
+ ...shared,
1083
+ `${pad}// This local preview has no provider to send it to, so here — and only`,
1084
+ `${pad}// here — you can uncomment a fixture to see a filled panel with no backend`,
1085
+ `${pad}// (replace the empty initializer below with it):`,
1086
+ ...decl(JSON.stringify(SAMPLE_AGENTIC_MESSAGE)).map((line) => `${pad}// ${line}`),
1087
+ ];
1088
+ }
1089
+
234
1090
  // ── front-end rendering ───────────────────────────────────────────────────────
235
1091
 
236
1092
  interface RenderCtx {
237
1093
  p: PlacementStyle;
238
1094
  emptyHint: string;
239
1095
  suggestions: string[];
1096
+ /**
1097
+ * The human label in the emitted banner comment, DERIVED from the components
1098
+ * (see `surfaceLabel`) rather than passed in from a preset's `title`.
1099
+ *
1100
+ * That is deliberate and it is what makes archetypes data. A renderer that took
1101
+ * a caller-supplied title would emit different bytes for the same components
1102
+ * depending on who asked, so `renderSurface(components)` and
1103
+ * `scaffold({ useCase })` could not be compared for equality — and that equality
1104
+ * is the single check standing between this file and a second renderer growing
1105
+ * back (see `assertPresetsAreData` in scripts/verify-scaffold-compiles.mjs).
1106
+ * The preset's title is still printed, in the provenance header `compose`
1107
+ * writes ABOVE the surface, where it describes where the request came from
1108
+ * instead of what was rendered.
1109
+ */
1110
+ label: string;
240
1111
  /** mock = stream the reply client-side (no fetch, no backend, no key) */
241
1112
  isMock: boolean;
242
1113
  /** SCAF-8: non-undefined when the integration forwards a model param */
243
1114
  defaultModel?: string;
1115
+ /** the surface renders kai-tool AND the route forwards a tools array, so the
1116
+ * scaffold declares the schemas that make a tool call possible */
1117
+ emitTools: boolean;
1118
+ /** the surface renders kai-tool and there is a backend to call, so the live
1119
+ * multi-round loop (and the `runTool` stub it calls) is emitted */
1120
+ emitToolLoop: boolean;
1121
+ /** the surface bears cards and there is a model to ask for one, so the
1122
+ * registry + the loop's cardFromToolCall arm + the cardTypes/cardSchemas
1123
+ * wiring are emitted */
1124
+ emitCards: boolean;
1125
+ /** which provider envelope `cardTools()` projects into, or null when this
1126
+ * integration's route does not forward a tools array we can shape (see
1127
+ * `cardToolProviderFor`). Non-null implies `emitCards`. */
1128
+ cardProvider: 'openai' | 'anthropic' | 'jsonschema' | null;
244
1129
  }
245
1130
 
246
1131
  /** The kai-* tags for the archetype, in order, as opening/closing markup.
@@ -252,10 +1137,25 @@ interface RenderCtx {
252
1137
  * SCAF-14: workspace structural types (kai-resizable, kai-artifact) are emitted
253
1138
  * as a properly composed split layout — chat in one pane, artifact in the other.
254
1139
  */
255
- function componentTags(archetype: Archetype, chatFill: string): string {
256
- // SCAF-14: workspace is a structural/layout archetype — emit a runnable split.
257
- if (isWorkspace(archetype)) {
258
- return [
1140
+ function componentTags(components: readonly string[], chatFill: string): string {
1141
+ const companionTags = components.filter(
1142
+ (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
1143
+ );
1144
+ const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1145
+ const hasStandaloneCompanions = companionTags.length > 0;
1146
+
1147
+ const lines: string[] = [];
1148
+ // SCAF-14: workspace is a structural/layout surface — emit a runnable split.
1149
+ //
1150
+ // This used to `return` here, which dropped every standalone companion on the
1151
+ // floor: a surface with kai-sources AND the workspace pair rendered the split
1152
+ // and nothing else. No archetype could reach that combination (`workspace` is
1153
+ // the only preset with the pair and it carries no companions), so the bug was
1154
+ // unreachable until the surface axis became a components list. Building the
1155
+ // chat block into `lines` instead means the companion loop below runs either
1156
+ // way, which is the property that was missing rather than a special case.
1157
+ if (isWorkspace(components)) {
1158
+ lines.push(
259
1159
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
260
1160
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
261
1161
  ` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
@@ -267,22 +1167,16 @@ function componentTags(archetype: Archetype, chatFill: string): string {
267
1167
  ` <kai-artifact id="artifact" src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
268
1168
  ` </kai-resizable-item>`,
269
1169
  ` </kai-resizable>`,
270
- ].join('\n');
1170
+ );
1171
+ } else {
1172
+ lines.push(` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`);
271
1173
  }
272
1174
 
273
- const companionTags = archetype.components.filter(
274
- (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
275
- );
276
- const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
277
- const hasStandaloneCompanions = companionTags.length > 0;
278
-
279
- const lines: string[] = [];
280
- lines.push(` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`);
281
-
282
1175
  if (hasEmbedded) {
283
1176
  lines.push(
284
- ` <!-- kai-tool / kai-reasoning render INSIDE the thread, not as siblings.`,
285
- ` Seed messages with { tools: [...], reasoning: { text: '...' } } see the sample in the script below. -->`,
1177
+ ` <!-- kai-tool / kai-reasoning render INSIDE the thread, not as siblings: they are`,
1178
+ ` parts on a message — parts: [{ type: 'reasoning', }, { type: 'tool', tool: {…} }, …]`,
1179
+ ` — and the stream in the script below builds them as the model works. -->`,
286
1180
  );
287
1181
  }
288
1182
 
@@ -305,159 +1199,215 @@ function componentTags(archetype: Archetype, chatFill: string): string {
305
1199
  return lines.join('\n');
306
1200
  }
307
1201
 
308
- /** The HTML <script> wiring — mock streams client-side; everything else fetches /api/chat. */
309
- function htmlWiring(ctx: RenderCtx, archetype: Archetype): string {
310
- const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
311
- const hasSources = archetype.components.includes('kai-sources');
1202
+ /**
1203
+ * The `html` target's logic, as a REAL `src/main.ts` module.
1204
+ *
1205
+ * SCAF-19 used to inline this as plain JS inside `<script type="module">` in
1206
+ * index.html, on the reasoning that the wiring sets untyped properties on a raw
1207
+ * `customElements` reference and would need a hand-cast per property. Being
1208
+ * invisible to tsc was described as the benefit.
1209
+ *
1210
+ * It was the defect. The canonical getting-started path
1211
+ * (`npm create vite -- --template vanilla-ts`) builds with `tsc && vite build`
1212
+ * and its tsconfig is `"include": ["src"]`, so the consumer's own build
1213
+ * type-checked NONE of the scaffold's logic. Proven rather than argued: an
1214
+ * injected call to a function that does not exist anywhere left `npm run build`
1215
+ * exiting 0 in a stock app.
1216
+ *
1217
+ * The hand-cast worry does not survive contact either — the kit SHIPS the element
1218
+ * interfaces, so one `as KaiChatElement` at the lookup types every property that
1219
+ * follows, which is what the svelte and angular targets already do. And the
1220
+ * message type comes from the element itself
1221
+ * (`KaiChatElement['messages'][number]`) instead of the hand-written local subset
1222
+ * the other mock targets declare: derived from the property it is assigned to, it
1223
+ * cannot drift out of step with it.
1224
+ *
1225
+ * Moving the logic into `src/` also retires the TS18003 workaround the old note
1226
+ * carried. That error existed only because deleting the template's `src/main.ts`
1227
+ * left `src/` with no `.ts` files at all; this scaffold now IS `src/main.ts`.
1228
+ */
1229
+ function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
1230
+ const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1231
+ const hasSources = components.includes('kai-sources');
312
1232
 
313
- // SCAF-9: seed the sample agentic message so tool+reasoning render immediately.
1233
+ // SCAF-9: the agentic archetype explains where tool + reasoning parts come
1234
+ // from. It no longer SEEDS a fabricated turn — see `SAMPLE_AGENTIC_MESSAGE`.
314
1235
  const seedLines = hasEmbedded
1236
+ ? [...sampleSeedComment(ctx.isMock, ` `, (literal) => [`chat.messages = [${literal}];`]), ``]
1237
+ : [];
1238
+
1239
+ const sourcesSetupLines = hasSources
315
1240
  ? [
316
- ` // SCAF-9: tool calls + reasoning render INSIDE the thread — set them on the message object.`,
317
- ` // Replace this sample with real messages from your backend.`,
318
- ` chat.messages = [${JSON.stringify(SAMPLE_AGENTIC_MESSAGE, null, 0)}];`,
1241
+ ` const sourcesEl = document.getElementById('sources') as KaiSourcesElement;`,
1242
+ ` // Replace with your real source data (set as a JS property — it's an array).`,
1243
+ ` const sampleSources = [`,
1244
+ ` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
1245
+ ` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
1246
+ ` ];`,
1247
+ ` sourcesEl.sources = sampleSources;`,
319
1248
  ``,
320
1249
  ]
321
1250
  : [];
322
1251
 
323
- const sourcesSetupLines = hasSources
1252
+ // Module scope, like vue/angular: the handler below closes over all three, and
1253
+ // `runTool` is a function declaration rather than something wedged into init().
1254
+ const modelLines = ctx.defaultModel
324
1255
  ? [
325
- ` const sourcesEl = document.getElementById('sources');`,
326
- ` // Replace with your real source data (set as a JS property — it's an array).`,
327
- ` const sampleSources = [`,
328
- ` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
329
- ` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
330
- ` ];`,
331
- ` sourcesEl.sources = sampleSources;`,
1256
+ `// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
1257
+ `const model = '${ctx.defaultModel}';`,
332
1258
  ``,
333
1259
  ]
334
1260
  : [];
1261
+ const cardsLines = ctx.emitCards ? [...cardRegistryLines(''), ``] : [];
1262
+ const toolsLines = ctx.emitTools ? [...toolSchemaLines('', ctx.cardProvider), ``] : [];
1263
+ const runnerLines = ctx.emitToolLoop ? [...toolRunnerLines('', true), ``] : [];
1264
+ const cardPropLines = ctx.emitCards
1265
+ ? [
1266
+ ...CARD_PROP_COMMENT.map((l) => ` ${l}`),
1267
+ ` chat.cardTypes = cards.tags;`,
1268
+ ` chat.cardSchemas = cards.validationSchemas;`,
1269
+ ``,
1270
+ ]
1271
+ : [];
1272
+
1273
+ // KaiSourcesElement only when a kai-sources companion is really declared: a
1274
+ // stock vanilla-ts tsconfig sets noUnusedLocals, so an always-on import is a
1275
+ // build error on every other archetype.
1276
+ const elementTypes = hasSources ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement';
1277
+
1278
+ /**
1279
+ * Same rule, applied to the kit's own `ChatMessage`.
1280
+ *
1281
+ * Unlike vue and svelte — which declare `ref<ChatMessage[]>` / `let messages:
1282
+ * ChatMessage[]` and therefore always reference it — this target keeps the
1283
+ * thread on the element. So the name is only used by the SINGLE-ROUND shape's
1284
+ * `const history: ChatMessage[]`; the tool-loop shape's thread IS
1285
+ * `chat.messages`, already typed by `KaiChatElement`, and importing the type
1286
+ * there is a TS6133 that fails `npm run build` in a stock app.
1287
+ */
1288
+ const annotatesChatMessage = !ctx.emitToolLoop;
335
1289
 
336
1290
  const head = [
337
- ` <script type="module">`,
338
- ` import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
339
- ` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
340
- ``,
341
- ` // Guard: module scripts run before the DOM is ready when inlined in <head>.`,
342
- ` // DOMContentLoaded fires synchronously when already loaded; otherwise waits.`,
343
- ` async function init() {`,
344
- ` const chat = document.getElementById('chat');`,
345
- ` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
346
- ` // element may not be upgraded yet. Wait for the upgrade before setting any`,
347
- ` // array/object property values set pre-upgrade are dropped on upgrade.`,
348
- ` await customElements.whenDefined('kai-chat');`,
349
- ` // suggestions is a JS PROPERTY (arrays can't be HTML attributes)`,
350
- ` chat.suggestions = ${jsArray(ctx.suggestions)};`,
351
- ` chat.suggestionMode = 'submit';`,
1291
+ `// src/main.ts — the page's logic, in a module YOUR build type-checks.`,
1292
+ `//`,
1293
+ `// It lives here rather than inline in index.html on purpose: the canonical`,
1294
+ `// getting-started path (\`npm create vite -- --template vanilla-ts\`) builds with`,
1295
+ `// \`tsc && vite build\` and scopes its tsconfig to "include": ["src"], so an`,
1296
+ `// inline <script> is checked by nothing at all. Delete the template's own`,
1297
+ `// src/main.ts and save this in its place; index.html already points at it.`,
1298
+ `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
1299
+ `// The kit ships the element interfaces, so one cast at the lookup below types`,
1300
+ `// every property assignment that follows.`,
1301
+ `import type { ${elementTypes} } from '@kitn.ai/ui/elements';`,
1302
+ ...wireImportLines({
1303
+ typed: annotatesChatMessage,
1304
+ toolLoop: ctx.emitToolLoop,
1305
+ cards: ctx.emitCards,
1306
+ cardTools: ctx.cardProvider !== null,
1307
+ mock: ctx.isMock,
1308
+ }),
1309
+ `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1310
+ ``,
1311
+ ...(ctx.isMock ? [...mockResponderInit(), ``] : []),
1312
+ ...modelLines,
1313
+ ...cardsLines,
1314
+ ...toolsLines,
1315
+ ...runnerLines,
1316
+ `async function init() {`,
1317
+ ` const chat = document.getElementById('chat') as KaiChatElement;`,
1318
+ ` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
1319
+ ` // element may not be upgraded yet. Wait for the upgrade before setting any`,
1320
+ ` // array/object property — values set pre-upgrade are dropped on upgrade.`,
1321
+ ` await customElements.whenDefined('kai-chat');`,
1322
+ ` // suggestions is a JS PROPERTY (arrays can't be HTML attributes)`,
1323
+ ` chat.suggestions = ${jsArray(ctx.suggestions)};`,
1324
+ ` chat.suggestionMode = 'submit';`,
352
1325
  ``,
353
- ...seedLines.map((l) => (l.trim() === '' ? l : ` ${l}`)),
354
- ...sourcesSetupLines.map((l) => (l.trim() === '' ? l : ` ${l}`)),
1326
+ ...cardPropLines,
1327
+ ...seedLines,
1328
+ ...sourcesSetupLines,
355
1329
  ];
356
1330
 
357
- // DOMContentLoaded footer closes init() and wires it safely.
358
- const domReadyFooter = [
359
- ` }`,
360
- ` if (document.readyState === 'loading') {`,
361
- ` document.addEventListener('DOMContentLoaded', init);`,
362
- ` } else {`,
363
- ` init();`,
364
- ` }`,
1331
+ // `Event`, not `CustomEvent`: addEventListener with a custom event name hands
1332
+ // the listener a plain Event, so the narrowing happens in the body — the same
1333
+ // shape renderAngular emits, for the same reason.
1334
+ const listenerOpen = [
1335
+ ` chat.addEventListener('kai-submit', async (event: Event) => {`,
1336
+ ` const e = event as CustomEvent<{ value: string }>;`,
1337
+ ];
1338
+ const footer = [
1339
+ ` });`,
1340
+ `}`,
1341
+ ``,
1342
+ `// A <script type="module"> is deferred, so the DOM is already parsed here.`,
1343
+ `void init();`,
365
1344
  ];
366
-
367
- if (ctx.isMock) {
368
- const body = mockStreamBody({
369
- pad: ' ',
370
- read: 'chat.messages',
371
- commitInitial: (expr) => `chat.messages = ${expr};`,
372
- // chat.messages is live (no React snapshot) — map over it directly
373
- commitMap: (mapBody) => `chat.messages = chat.messages.map((m) => ${mapBody});`,
374
- setLoading: (v) => `chat.loading = ${v};`,
375
- });
376
- return [
377
- ...head,
378
- ` // No backend: stream a canned reply client-side (no fetch, no API key).`,
379
- ` chat.addEventListener('kai-submit', async (e) => {`,
380
- body,
381
- ` });`,
382
- ...domReadyFooter,
383
- ` </script>`,
384
- ].join('\n');
385
- }
386
-
387
- // SCAF-8: include model in the POST body when the integration forwards it.
388
- const modelLines = ctx.defaultModel
389
- ? [
390
- ` // SCAF-8: change this model id to any provider/model string you want to use.`,
391
- ` const model = '${ctx.defaultModel}';`,
392
- ``,
393
- ]
394
- : [];
395
- const bodyPayload = ctx.defaultModel
396
- ? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }`
397
- : `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
398
1345
 
399
1346
  return [
400
1347
  ...head,
401
- ` chat.addEventListener('kai-submit', async (e) => {`,
402
- ` const value = e.detail.value.trim();`,
403
- ` if (!value) return;`,
404
- ``,
405
- ...modelLines,
406
- ` // messages is a JS PROPERTY (objects can't be HTML attributes)`,
407
- ` const history = [...chat.messages, { id: crypto.randomUUID(), role: 'user', content: value }];`,
408
- ` const assistantId = crypto.randomUUID();`,
409
- ` chat.messages = [...history, { id: assistantId, role: 'assistant', content: '' }];`,
410
- ` chat.loading = true;`,
411
- ``,
412
- ` const res = await fetch('/api/chat', {`,
413
- ` method: 'POST',`,
414
- ` headers: { 'Content-Type': 'application/json' },`,
415
- ` body: JSON.stringify(${bodyPayload}),`,
416
- ` });`,
417
- ``,
418
- ` // Read the OpenAI-format SSE and stream it into the assistant message.`,
419
- ` // This loop is the Streaming recipe — copy its exact body if you need keep-alive handling.`,
420
- ` const reader = res.body.getReader();`,
421
- ` const decoder = new TextDecoder();`,
422
- ` let buffer = '', answer = '';`,
423
- ` while (true) {`,
424
- ` const { value: chunk, done } = await reader.read();`,
425
- ` if (done) break;`,
426
- ` buffer += decoder.decode(chunk, { stream: true });`,
427
- ` const lines = buffer.split('\\n');`,
428
- ` buffer = lines.pop();`,
429
- ` for (const line of lines) {`,
430
- ` const s = line.trim();`,
431
- ` if (!s.startsWith('data:')) continue;`,
432
- ` const payload = s.slice(5).trim();`,
433
- ` if (payload === '[DONE]') continue;`,
434
- ` try {`,
435
- ` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
436
- ` if (!delta) continue;`,
437
- ` answer += delta;`,
438
- ` chat.messages = chat.messages.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
439
- ` } catch { /* skip keep-alive lines */ }`,
440
- ` }`,
441
- ` }`,
442
- ` chat.loading = false;`,
443
- ` });`,
444
- ...domReadyFooter,
445
- ` </script>`,
1348
+ ` // messages is a JS PROPERTY (objects can't be HTML attributes)`,
1349
+ ...listenerOpen,
1350
+ realStreamBody({
1351
+ pad: ' ',
1352
+ read: 'chat.messages',
1353
+ commitSet: (expr) => `chat.messages = ${expr};`,
1354
+ setterAdapter: '(fn) => { chat.messages = fn(chat.messages); }',
1355
+ setLoading: (v) => `chat.loading = ${v};`,
1356
+ bodyPayload: realBodyPayload({ defaultModel: ctx.defaultModel, tools: ctx.emitTools }),
1357
+ strictRoles: true,
1358
+ toolLoop: ctx.emitToolLoop,
1359
+ cards: ctx.emitCards,
1360
+ thread: liveThreadBinding(
1361
+ 'chat.messages',
1362
+ '(fn) => { chat.messages = fn(chat.messages); }',
1363
+ 'chat.messages ?? []',
1364
+ ),
1365
+ mock: ctx.isMock,
1366
+ }),
1367
+ ...footer,
446
1368
  ].join('\n');
447
1369
  }
448
1370
 
449
- function renderHtml(archetype: Archetype, ctx: RenderCtx): string {
1371
+ /**
1372
+ * The `html` target: TWO files, split on the same `// ── path ──` separator the
1373
+ * backend routes already use.
1374
+ *
1375
+ * index.html the markup, plus a <script type="module" src="/src/main.ts">
1376
+ * src/main.ts the logic — see `htmlModule` for why it is not inline any more
1377
+ *
1378
+ * Both files are emitted for every target that lands here — the backend-only
1379
+ * frameworks (express/worker/fastapi) get the same browser side, since a module
1380
+ * their bundler can see beats an inline script for them too. Only the note about
1381
+ * replacing the vanilla-ts template's own entry is specific to `html`, so only
1382
+ * that is gated on `isViteHtmlTarget`.
1383
+ */
1384
+ function renderHtml(components: readonly string[], ctx: RenderCtx, isViteHtmlTarget: boolean): string {
450
1385
  const { p, emptyHint } = ctx;
1386
+ const scriptNote = isViteHtmlTarget
1387
+ ? [
1388
+ `<!-- The logic is a real module, NOT an inline script: a stock vanilla-ts`,
1389
+ ` tsconfig is "include": ["src"], so anything inline is type-checked by`,
1390
+ ` nothing at all. Delete the template's src/main.ts and save the file`,
1391
+ ` below in its place — this tag is the one the template already ships. -->`,
1392
+ ]
1393
+ : [
1394
+ `<!-- The logic is a real module, not an inline script, so your own build`,
1395
+ ` type-checks it. Save the file below as src/main.ts. -->`,
1396
+ ];
451
1397
  return [
452
- `<!-- ${archetype.title}${p.note} -->`,
453
- ...(p.altNote ? [`<!-- ${p.altNote} -->`] : []),
1398
+ `<!-- index.htmlpaste this into <body>. -->`,
1399
+ `<!-- ${ctx.label} ${p.note} -->`,
1400
+ ...(p.altNote ?? []).map((l) => `<!-- ${l} -->`),
454
1401
  `<div style="${p.style}">`,
455
- componentTags(archetype, p.chatFill),
1402
+ componentTags(components, p.chatFill),
456
1403
  `</div>`,
1404
+ ...scriptNote,
1405
+ `<script type="module" src="/src/main.ts"></script>`,
457
1406
  ``,
458
- htmlWiring(ctx, archetype),
1407
+ `<!-- empty-state hint: ${emptyHint} -->`,
459
1408
  ``,
460
- ` <!-- empty-state hint: ${emptyHint} -->`,
1409
+ `// ── src/main.ts ──────────────────────────────────────────────────────────────`,
1410
+ htmlModule(ctx, components),
461
1411
  ].join('\n');
462
1412
  }
463
1413
 
@@ -472,15 +1422,15 @@ function toPascalCase(tag: string): string {
472
1422
  }
473
1423
 
474
1424
  /** JSX usage for react/next: uses the official @kitn.ai/ui/react wrappers. */
475
- function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): string {
476
- const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
1425
+ function renderJsx(components: readonly string[], ctx: RenderCtx, framework: string): string {
1426
+ const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
477
1427
 
478
- const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
479
- const workspace = isWorkspace(archetype);
1428
+ const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1429
+ const workspace = isWorkspace(components);
480
1430
 
481
1431
  // SCAF-9: exclude message-embedded tags from import list.
482
1432
  // SCAF-14: workspace uses Resizable+ResizableItem+Artifact — keep them in the import list.
483
- const renderableTags = archetype.components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
1433
+ const renderableTags = components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
484
1434
  // For workspace: replace 'kai-resizable' with 'kai-resizable-item' so we get ResizableItem too.
485
1435
  const importTags = workspace
486
1436
  ? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
@@ -489,7 +1439,7 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
489
1439
  const importList = wrapperNames.join(', ');
490
1440
 
491
1441
  // SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not workspace-structural).
492
- const standaloneCompanionTags = archetype.components.filter(
1442
+ const standaloneCompanionTags = components.filter(
493
1443
  (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
494
1444
  );
495
1445
 
@@ -498,8 +1448,8 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
498
1448
  const companionJsxLines: string[] = [];
499
1449
  if (hasEmbedded) {
500
1450
  companionJsxLines.push(
501
- ` {/* kai-tool / kai-reasoning render inside the thread. Tool calls + reasoning`,
502
- ` are set on each message object see the sampleMessages initializer above. */}`,
1451
+ ` {/* kai-tool / kai-reasoning render inside the thread, as parts on the`,
1452
+ ` assistant message the stream in onSubmit builds. */}`,
503
1453
  );
504
1454
  }
505
1455
  for (const t of standaloneCompanionTags) {
@@ -515,19 +1465,19 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
515
1465
  }
516
1466
  const companions = companionJsxLines.join('\n');
517
1467
 
518
- // SCAF-4: Inline ChatMessage type for strict-TS consumers; avoids implicit-any on useState/handler.
519
- // SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
520
- const chatMessageType = `type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string }[] };`;
1468
+ const mockInit = isMock ? mockResponderInit() : [];
521
1469
 
522
- // SCAF-9: seed sample messages for agentic archetype so tool+reasoning render immediately.
523
- const sampleMessagesInit = hasEmbedded
524
- ? [
525
- ` // SCAF-9: tool calls and reasoning render inside the thread — set them on the message object.`,
526
- ` // Replace with real messages streamed from your backend.`,
527
- ` const sampleMessages: ChatMessage[] = [${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}];`,
528
- ` const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
529
- ].join('\n')
530
- : ` const [messages, setMessages] = useState<ChatMessage[]>([]);`;
1470
+ // SCAF-9: no fabricated seed see SAMPLE_AGENTIC_MESSAGE for the three ways
1471
+ // one broke a real app.
1472
+ const sampleMessagesInit = [
1473
+ ...(hasEmbedded
1474
+ ? sampleSeedComment(isMock, ' ', (literal) => [
1475
+ `const sampleMessages: ChatMessage[] = [${literal}];`,
1476
+ `const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
1477
+ ])
1478
+ : []),
1479
+ ` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
1480
+ ].join('\n');
531
1481
 
532
1482
  // SCAF-9: sample sources data for knowledge-base archetype.
533
1483
  const sampleSourcesInit =
@@ -543,68 +1493,53 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
543
1493
 
544
1494
  // SCAF-8: model const for integrations that forward model to the upstream provider.
545
1495
  const modelInit = defaultModel
546
- ? ` // SCAF-8: change this to any provider/model string you want to use.\n const model = '${defaultModel}';`
1496
+ ? ` // SCAF-8: change this model id to another id THIS PROVIDER accepts.\n const model = '${defaultModel}';`
547
1497
  : '';
548
- const bodyPayload = defaultModel
549
- ? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }`
550
- : `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
551
-
552
- // onSubmit body: mock streams a canned reply client-side; otherwise fetch /api/chat.
553
- const onSubmitBody = isMock
554
- ? mockStreamBody({
555
- pad: ' ',
556
- read: 'messages',
557
- commitInitial: (expr) => `setMessages(${expr});`,
558
- // functional updater so each token maps over the LATEST array, not the snapshot
559
- commitMap: (mapBody) => `setMessages((prev) => prev.map((m) => ${mapBody}));`,
560
- setLoading: (v) => `setLoading(${v});`,
561
- strictRoles: true,
562
- })
563
- : [
564
- ` const value = e.detail.value.trim();`,
565
- ` if (!value) return;`,
566
- ` const history: ChatMessage[] = [...messages, { id: crypto.randomUUID(), role: 'user' as const, content: value }];`,
567
- ` const assistantId = crypto.randomUUID();`,
568
- ` setMessages([...history, { id: assistantId, role: 'assistant' as const, content: '' }]);`,
569
- ` setLoading(true);`,
570
- ` const res = await fetch('/api/chat', {`,
571
- ` method: 'POST',`,
572
- ` headers: { 'Content-Type': 'application/json' },`,
573
- ` body: JSON.stringify(${bodyPayload}),`,
574
- ` });`,
575
- ` // Stream the OpenAI-format SSE into the assistant message — see the Streaming recipe.`,
576
- ` const reader = res.body!.getReader();`,
577
- ` const decoder = new TextDecoder();`,
578
- ` let buffer = '', answer = '';`,
579
- ` while (true) {`,
580
- ` const { value: chunk, done } = await reader.read();`,
581
- ` if (done) break;`,
582
- ` buffer += decoder.decode(chunk, { stream: true });`,
583
- ` const lines = buffer.split('\\n');`,
584
- ` buffer = lines.pop()!;`,
585
- ` for (const line of lines) {`,
586
- ` const s = line.trim();`,
587
- ` if (!s.startsWith('data:')) continue;`,
588
- ` const payload = s.slice(5).trim();`,
589
- ` if (payload === '[DONE]') continue;`,
590
- ` try {`,
591
- ` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
592
- ` if (!delta) continue;`,
593
- ` answer += delta;`,
594
- ` setMessages((ms) => ms.map((m) => (m.id === assistantId ? { ...m, content: answer } : m)));`,
595
- ` } catch { /* skip keep-alives */ }`,
596
- ` }`,
597
- ` }`,
598
- ` setLoading(false);`,
599
- ].join('\n');
1498
+
1499
+ const toolsInit = emitTools ? toolSchemaLines(' ', ctx.cardProvider).join('\n') : '';
1500
+ const toolRunner = emitToolLoop ? toolRunnerLines(' ', true).join('\n') : '';
1501
+ // MODULE scope, unlike `tools`/`runTool`: a registry is static data, so rebuilding
1502
+ // it on every render would allocate a new object per keystroke and hand <Chat> a
1503
+ // new cardTypes reference each time.
1504
+ const cardsInit = ctx.emitCards ? cardRegistryLines('') : [];
1505
+ // The two card props, as JSX props on the wrapper — which sets them as DOM
1506
+ // PROPERTIES on <kai-chat>, never as attributes. The explanation goes ABOVE the
1507
+ // element, not between its attributes: `{/* … */}` is only legal at element
1508
+ // position in JSX, and in attribute position it is a parse error.
1509
+ const cardProps = (pad: string): string[] =>
1510
+ ctx.emitCards
1511
+ ? [`${pad}cardTypes={cards.tags}`, `${pad}cardSchemas={cards.validationSchemas}`]
1512
+ : [];
1513
+ const cardPropsNote = (pad: string): string[] =>
1514
+ ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
1515
+
1516
+ // onSubmit body. The mock and the real backend differ by ONE expression the
1517
+ // stream's source — and share every other line: see `realStreamBody`.
1518
+ const onSubmitBody = realStreamBody({
1519
+ pad: ' ',
1520
+ read: 'messages',
1521
+ commitSet: (expr) => `setMessages(${expr});`,
1522
+ // useState's setter IS a SetMessages: both are (updater) => void.
1523
+ setterAdapter: 'setMessages',
1524
+ setLoading: (v) => `setLoading(${v});`,
1525
+ bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
1526
+ strictRoles: true,
1527
+ toolLoop: emitToolLoop,
1528
+ cards: ctx.emitCards,
1529
+ thread: REACT_THREAD,
1530
+ mock: isMock,
1531
+ });
600
1532
 
601
1533
  // SCAF-2: Next.js App Router requires 'use client' for components that use hooks/interactivity.
602
1534
  const useClientDirective = framework === 'next' ? [`'use client';`, ``] : [];
603
1535
 
604
- // SCAF-6: For Next.js ONLY — use next/dynamic with { ssr: false } so the elements
605
- // bundle (which calls delegateEvents(events, doc = window.document) at module-eval)
606
- // never runs on the server during prerender avoids "window is not defined" crash.
607
- // Plain `react` (Vite) has no SSR and keeps the top-level imports unchanged.
1536
+ // SCAF-6: For Next.js ONLY — use next/dynamic with { ssr: false }. NOT because
1537
+ // importing the package on the server crashes: `@kitn.ai/ui/react`, `@kitn.ai/ui/elements`
1538
+ // and the state helpers are all SSR-import-safe (verified by prerendering a server
1539
+ // component that statically imports them). The reason is rendering: <kai-*> are
1540
+ // CLIENT-ONLY custom elements, and the server has no customElements registry, so a
1541
+ // server-rendered <kai-chat> is an inert unupgraded tag that mismatches the upgraded
1542
+ // client tree on hydration. Plain `react` (Vite) has no SSR and keeps top-level imports.
608
1543
  if (framework === 'next') {
609
1544
  // Build dynamic() calls for every renderable wrapper in the archetype.
610
1545
  const dynamicImports = wrapperNames.map(
@@ -620,21 +1555,36 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
620
1555
  ...useClientDirective,
621
1556
  `import { useState } from 'react';`,
622
1557
  `import dynamic from 'next/dynamic';`,
1558
+ // The adapter is pure parsing + pure state; both entries are SSR-import-safe,
1559
+ // so they stay static imports even though the ELEMENTS have to be dynamic.
1560
+ ...wireImportLines({
1561
+ typed: true,
1562
+ toolLoop: emitToolLoop,
1563
+ setMessagesType: emitToolLoop,
1564
+ cards: ctx.emitCards,
1565
+ cardTools: ctx.cardProvider !== null,
1566
+ mock: isMock,
1567
+ }),
623
1568
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
624
- `// kai-* bundle Solid's client runtime load client-only so SSR/prerender doesn't crash`,
1569
+ `// <kai-*> are client-only custom elements (the server has no customElements`,
1570
+ `// registry) → load client-only so hydration doesn't mismatch. The package itself`,
1571
+ `// is SSR-import-safe; importing it from a server component is fine.`,
625
1572
  ...dynamicImports,
626
1573
  ``,
627
1574
  ...nextConfigNote,
628
- `// ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint}`,
629
- ...(p.altNote ? [`// ${p.altNote}`] : []),
630
- chatMessageType,
1575
+ `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
1576
+ ...(p.altNote ?? []).map((l) => `// ${l}`),
1577
+ ...mockInit,
631
1578
  ``,
1579
+ ...cardsInit,
632
1580
  `export default function App() {`,
633
1581
  sampleMessagesInit,
634
1582
  ` const [loading, setLoading] = useState(false);`,
635
1583
  ` const suggestions = ${jsArray(suggestions)};`,
636
1584
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
637
1585
  ...(modelInit ? [modelInit] : []),
1586
+ ...(toolsInit ? [toolsInit] : []),
1587
+ ...(toolRunner ? [toolRunner] : []),
638
1588
  ``,
639
1589
  ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
640
1590
  onSubmitBody,
@@ -648,27 +1598,36 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
648
1598
  ` {/* Resizable needs ResizableItem children to render panels. */}`,
649
1599
  ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
650
1600
  ` <ResizableItem size="40%" min="240px">`,
1601
+ ...cardPropsNote(' '),
651
1602
  ` <Chat`,
652
1603
  ` messages={messages}`,
653
1604
  ` loading={loading}`,
654
1605
  ` suggestions={suggestions}`,
655
1606
  ` suggestionMode="submit"`,
1607
+ ...cardProps(' '),
656
1608
  ` onSubmit={onSubmit}`,
657
1609
  ` style={{ ${jsxStyle(p.chatFill)} }}`,
658
1610
  ` />`,
659
1611
  ` </ResizableItem>`,
660
1612
  ` <ResizableItem min="280px">`,
661
- ` {/* Replace src with your artifact URL or set files for multi-file preview. */}`,
662
- ` <Artifact src="https://example.com" style={{ width: '100%', height: '100%' }} />`,
1613
+ ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
1614
+ ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
663
1615
  ` </ResizableItem>`,
664
1616
  ` </Resizable>`,
1617
+ // Standalone companions are siblings of the SPLIT, not of the chat:
1618
+ // the split owns chat + artifact, and a sources panel or a voice input
1619
+ // belongs beside it. Dropping them here is what the workspace branch
1620
+ // used to do — see WORKSPACE_STRUCTURAL_TAGS.
1621
+ companions,
665
1622
  ]
666
1623
  : [
1624
+ ...cardPropsNote(' '),
667
1625
  ` <Chat`,
668
1626
  ` messages={messages}`,
669
1627
  ` loading={loading}`,
670
1628
  ` suggestions={suggestions}`,
671
1629
  ` suggestionMode="submit"`,
1630
+ ...cardProps(' '),
672
1631
  ` onSubmit={onSubmit}`,
673
1632
  ` style={{ ${jsxStyle(p.chatFill)} }}`,
674
1633
  ` />`,
@@ -693,19 +1652,30 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
693
1652
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
694
1653
  `import { useState } from 'react';`,
695
1654
  `import { ${importList} } from '@kitn.ai/ui/react';`,
1655
+ ...wireImportLines({
1656
+ typed: true,
1657
+ toolLoop: emitToolLoop,
1658
+ setMessagesType: emitToolLoop,
1659
+ cards: ctx.emitCards,
1660
+ cardTools: ctx.cardProvider !== null,
1661
+ mock: isMock,
1662
+ }),
696
1663
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
697
1664
  ``,
698
1665
  ...nextConfigNote,
699
- `// ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint}`,
700
- ...(p.altNote ? [`// ${p.altNote}`] : []),
701
- chatMessageType,
1666
+ `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
1667
+ ...(p.altNote ?? []).map((l) => `// ${l}`),
1668
+ ...mockInit,
702
1669
  ``,
1670
+ ...cardsInit,
703
1671
  `export default function App() {`,
704
1672
  sampleMessagesInit,
705
1673
  ` const [loading, setLoading] = useState(false);`,
706
1674
  ` const suggestions = ${jsArray(suggestions)};`,
707
1675
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
708
1676
  ...(modelInit ? [modelInit] : []),
1677
+ ...(toolsInit ? [toolsInit] : []),
1678
+ ...(toolRunner ? [toolRunner] : []),
709
1679
  ``,
710
1680
  ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
711
1681
  onSubmitBody,
@@ -719,27 +1689,33 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
719
1689
  ` {/* Resizable needs ResizableItem children to render panels. */}`,
720
1690
  ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
721
1691
  ` <ResizableItem size="40%" min="240px">`,
1692
+ ...cardPropsNote(' '),
722
1693
  ` <Chat`,
723
1694
  ` messages={messages}`,
724
1695
  ` loading={loading}`,
725
1696
  ` suggestions={suggestions}`,
726
1697
  ` suggestionMode="submit"`,
1698
+ ...cardProps(' '),
727
1699
  ` onSubmit={onSubmit}`,
728
1700
  ` style={{ ${jsxStyle(p.chatFill)} }}`,
729
1701
  ` />`,
730
1702
  ` </ResizableItem>`,
731
1703
  ` <ResizableItem min="280px">`,
732
- ` {/* Replace src with your artifact URL or set files for multi-file preview. */}`,
733
- ` <Artifact src="https://example.com" style={{ width: '100%', height: '100%' }} />`,
1704
+ ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
1705
+ ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
734
1706
  ` </ResizableItem>`,
735
1707
  ` </Resizable>`,
1708
+ // Siblings of the SPLIT — see the same line in the other JSX branch.
1709
+ companions,
736
1710
  ]
737
1711
  : [
1712
+ ...cardPropsNote(' '),
738
1713
  ` <Chat`,
739
1714
  ` messages={messages}`,
740
1715
  ` loading={loading}`,
741
1716
  ` suggestions={suggestions}`,
742
1717
  ` suggestionMode="submit"`,
1718
+ ...cardProps(' '),
743
1719
  ` onSubmit={onSubmit}`,
744
1720
  ` style={{ ${jsxStyle(p.chatFill)} }}`,
745
1721
  ` />`,
@@ -754,21 +1730,21 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
754
1730
  }
755
1731
 
756
1732
  /** Vue: bind messages/suggestions as properties, listen for kai-submit with @. */
757
- function renderVue(archetype: Archetype, ctx: RenderCtx): string {
758
- const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
1733
+ function renderVue(components: readonly string[], ctx: RenderCtx): string {
1734
+ const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
759
1735
 
760
1736
  // SCAF-9: exclude message-embedded tags from companion rendering.
761
1737
  // SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
762
- const workspace = isWorkspace(archetype);
763
- const standaloneCompanionTags = archetype.components.filter(
1738
+ const workspace = isWorkspace(components);
1739
+ const standaloneCompanionTags = components.filter(
764
1740
  (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
765
1741
  );
766
- const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1742
+ const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
767
1743
 
768
1744
  const companionLines: string[] = [];
769
1745
  if (hasEmbedded) {
770
1746
  companionLines.push(
771
- ` <!-- kai-tool / kai-reasoning render INSIDE the thread set tools/reasoning on each message object. -->`,
1747
+ ` <!-- kai-tool / kai-reasoning render INSIDE the thread, as parts on the assistant message the stream builds. -->`,
772
1748
  );
773
1749
  }
774
1750
  for (const t of standaloneCompanionTags) {
@@ -782,79 +1758,56 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
782
1758
  }
783
1759
  const companions = companionLines.join('\n');
784
1760
 
785
- // SCAF-8: include model when the integration forwards it.
786
- const bodyPayload = defaultModel
787
- ? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }`
788
- : `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
789
-
790
- const onSubmitBody = isMock
791
- ? mockStreamBody({
792
- pad: ' ',
793
- read: 'messages.value',
794
- commitInitial: (expr) => `messages.value = ${expr};`,
795
- // messages.value is live map over it directly
796
- commitMap: (mapBody) => `messages.value = messages.value.map((m) => ${mapBody});`,
797
- setLoading: (v) => `loading.value = ${v};`,
798
- strictRoles: true,
799
- })
800
- : [
801
- ` const value = e.detail.value.trim();`,
802
- ` if (!value) return;`,
803
- ` const history: ChatMessage[] = [...messages.value, { id: crypto.randomUUID(), role: 'user' as const, content: value }];`,
804
- ` const assistantId = crypto.randomUUID();`,
805
- ` messages.value = [...history, { id: assistantId, role: 'assistant' as const, content: '' }];`,
806
- ` loading.value = true;`,
807
- ` // POST to /api/chat, then stream the OpenAI-format SSE into the`,
808
- ` // assistant message (reassign messages.value per chunk) — see the Streaming recipe.`,
809
- ...(defaultModel
810
- ? [
811
- ` // SCAF-8: change this to any provider/model string you want to use.`,
812
- ` const model = '${defaultModel}';`,
813
- ]
814
- : []),
815
- ` const res = await fetch('/api/chat', {`,
816
- ` method: 'POST',`,
817
- ` headers: { 'Content-Type': 'application/json' },`,
818
- ` body: JSON.stringify(${bodyPayload}),`,
819
- ` });`,
820
- ` // Stream the OpenAI-format SSE — see the Streaming recipe.`,
821
- ` const reader = res.body.getReader();`,
822
- ` const decoder = new TextDecoder();`,
823
- ` let buffer = '', answer = '';`,
824
- ` while (true) {`,
825
- ` const { value: chunk, done } = await reader.read();`,
826
- ` if (done) break;`,
827
- ` buffer += decoder.decode(chunk, { stream: true });`,
828
- ` const lines = buffer.split('\\n');`,
829
- ` buffer = lines.pop();`,
830
- ` for (const line of lines) {`,
831
- ` const s = line.trim();`,
832
- ` if (!s.startsWith('data:')) continue;`,
833
- ` const payload = s.slice(5).trim();`,
834
- ` if (payload === '[DONE]') continue;`,
835
- ` try {`,
836
- ` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
837
- ` if (!delta) continue;`,
838
- ` answer += delta;`,
839
- ` messages.value = messages.value.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
840
- ` } catch { /* skip keep-alives */ }`,
841
- ` }`,
842
- ` }`,
843
- ` loading.value = false;`,
844
- ].join('\n');
845
-
846
- // SCAF-10: ChatMessage type for strict-TS Vue consumers — matches the React SCAF-4 type.
847
- // SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
848
- const chatMessageType = `type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string }[] };`;
849
-
850
- // SCAF-9: sample seeding for agentic archetype.
851
- const sampleSeed = hasEmbedded
1761
+ const onSubmitBody = realStreamBody({
1762
+ pad: ' ',
1763
+ read: 'messages.value',
1764
+ commitSet: (expr) => `messages.value = ${expr};`,
1765
+ setterAdapter: '(fn) => { messages.value = fn(messages.value); }',
1766
+ setLoading: (v) => `loading.value = ${v};`,
1767
+ bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
1768
+ strictRoles: true,
1769
+ toolLoop: emitToolLoop,
1770
+ cards: ctx.emitCards,
1771
+ thread: liveThreadBinding('messages.value', '(fn) => { messages.value = fn(messages.value); }'),
1772
+ mock: isMock,
1773
+ });
1774
+
1775
+ // SCAF-10: ChatMessage declaration for strict-TS Vue consumers.
1776
+ const mockInit = isMock ? mockResponderInit() : [];
1777
+
1778
+ // SCAF-8: model const at module scope so onSubmit closes over it.
1779
+ const modelInit = defaultModel
852
1780
  ? [
853
- `// SCAF-9: tool calls + reasoning render inside the thread set them on the message object.`,
854
- `// Replace with real messages streamed from your backend.`,
855
- `const messages = ref<ChatMessage[]>([${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}]);`,
1781
+ `// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
1782
+ `const model = '${defaultModel}';`,
856
1783
  ]
857
- : [`const messages = ref<ChatMessage[]>([]);`];
1784
+ : [];
1785
+
1786
+ const cardsInit = ctx.emitCards ? [...cardRegistryLines(''), ``] : [];
1787
+ // Vue applies the card props in the SAME onMounted re-application the other
1788
+ // element properties go through, and NOT as a `:cardTypes.prop` template binding.
1789
+ // A registry is static — it is built once at module load and never changes — so a
1790
+ // reactive binding would buy nothing, and the one place that has to be right is
1791
+ // the post-upgrade re-apply: a property set on a not-yet-upgraded custom element
1792
+ // is dropped on upgrade.
1793
+ const cardPropAssign = ctx.emitCards
1794
+ ? [`cardTypes: cards.tags`, `cardSchemas: cards.validationSchemas`]
1795
+ : [];
1796
+
1797
+ // Same scope, same reason: onSubmit puts `tools` in the request body.
1798
+ const toolsLines = emitTools ? toolSchemaLines('', ctx.cardProvider) : [];
1799
+ // Same scope again: the loop in onSubmit calls runTool.
1800
+ const runnerLines = emitToolLoop ? toolRunnerLines('', true) : [];
1801
+
1802
+ // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
1803
+ const sampleSeed = [
1804
+ ...(hasEmbedded
1805
+ ? sampleSeedComment(isMock, '', (literal) => [
1806
+ `const messages = ref<ChatMessage[]>([${literal}]);`,
1807
+ ])
1808
+ : []),
1809
+ `const messages = ref<ChatMessage[]>([]);`,
1810
+ ];
858
1811
 
859
1812
  // SCAF-9: sample sources setup.
860
1813
  const sourcesSeed = standaloneCompanionTags.includes('kai-sources')
@@ -896,6 +1849,8 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
896
1849
  ` <kai-artifact src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
897
1850
  ` </kai-resizable-item>`,
898
1851
  ` </kai-resizable>`,
1852
+ // Siblings of the SPLIT — see the same line in renderJsx.
1853
+ companions,
899
1854
  ]
900
1855
  : [
901
1856
  ` <kai-chat`,
@@ -910,22 +1865,33 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
910
1865
  ];
911
1866
 
912
1867
  return [
913
- `<!-- vue — ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint} -->`,
914
- ...(p.altNote ? [`<!-- ${p.altNote} -->`] : []),
915
- `<!-- SCAF-3: vite.config.ts tell Vue that kai-* are custom elements (not Vue components).`,
916
- ` Without this, Vue warns "Unknown custom element" and .prop bindings may misbehave.`,
917
- ` import vue from '@vitejs/plugin-vue';`,
918
- ` export default { plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('kai-') } } })] }; -->`,
1868
+ `<!-- vue — ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint} -->`,
1869
+ ...(p.altNote ?? []).map((l) => `<!-- ${l} -->`),
1870
+ `<!-- SCAF-3: pair this with the vite.config.ts in block (0) above. Without its`,
1871
+ ` isCustomElement, every kai-* tag logs "[Vue warn]: Failed to resolve`,
1872
+ ` component: kai-chat" in dev — the app still renders, but the console does`,
1873
+ ` not, and that warning is Vue asking you for exactly that config. -->`,
919
1874
  `<script setup lang="ts">`,
920
1875
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
1876
+ ...wireImportLines({
1877
+ typed: true,
1878
+ toolLoop: emitToolLoop,
1879
+ cards: ctx.emitCards,
1880
+ cardTools: ctx.cardProvider !== null,
1881
+ mock: isMock,
1882
+ }),
921
1883
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
922
1884
  vueImports,
923
1885
  ``,
924
- chatMessageType,
1886
+ ...mockInit,
925
1887
  ``,
1888
+ ...cardsInit,
926
1889
  ...sampleSeed,
927
1890
  `const loading = ref(false);`,
928
1891
  `const suggestions = ${jsArray(suggestions)};`,
1892
+ ...modelInit,
1893
+ ...toolsLines,
1894
+ ...runnerLines,
929
1895
  ...sourcesSeed,
930
1896
  ``,
931
1897
  `// SCAF-15: kai-* register via an async dynamic import (SSR-safety). The .prop`,
@@ -933,8 +1899,9 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
933
1899
  `// the element is defined so the initial messages/suggestions/loading stick.`,
934
1900
  `onMounted(async () => {`,
935
1901
  ` await customElements.whenDefined('kai-chat');`,
1902
+ ...(ctx.emitCards ? CARD_PROP_COMMENT.map((l) => ` ${l}`) : []),
936
1903
  ` const el = document.querySelector('kai-chat');`,
937
- ` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions });`,
1904
+ ` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(', ')}` : ''} });`,
938
1905
  `});`,
939
1906
  ``,
940
1907
  `async function onSubmit(e: CustomEvent<{ value: string }>) {`,
@@ -953,21 +1920,22 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
953
1920
  }
954
1921
 
955
1922
  /** Svelte: use bind:this to set array/object properties reactively; on:kai-submit for the event. */
956
- function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
957
- const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
1923
+ function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
1924
+ const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
958
1925
 
959
1926
  // SCAF-9: exclude message-embedded tags from companion rendering.
960
1927
  // SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
961
- const workspace = isWorkspace(archetype);
962
- const standaloneCompanionTags = archetype.components.filter(
1928
+ const workspace = isWorkspace(components);
1929
+ const standaloneCompanionTags = components.filter(
963
1930
  (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
964
1931
  );
965
- const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1932
+ const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1933
+ const hasSourcesCompanion = standaloneCompanionTags.includes('kai-sources');
966
1934
 
967
1935
  const companionLinesList: string[] = [];
968
1936
  if (hasEmbedded) {
969
1937
  companionLinesList.push(
970
- ` <!-- kai-tool / kai-reasoning render INSIDE the thread set tools/reasoning on each message object. -->`,
1938
+ ` <!-- kai-tool / kai-reasoning render INSIDE the thread, as parts on the assistant message the stream builds. -->`,
971
1939
  );
972
1940
  }
973
1941
  for (const t of standaloneCompanionTags) {
@@ -981,81 +1949,66 @@ function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
981
1949
  }
982
1950
  const companionLines = companionLinesList.join('\n');
983
1951
 
984
- // SCAF-8: include model when the integration forwards it.
985
- const bodyPayload = defaultModel
986
- ? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }`
987
- : `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
988
-
989
- const onSubmitBody = isMock
990
- ? mockStreamBody({
991
- pad: ' ',
992
- read: 'messages',
993
- commitInitial: (expr) => `messages = ${expr};`,
994
- // `messages` is a live local reassign to map over the latest array
995
- commitMap: (mapBody) => `messages = messages.map((m) => ${mapBody});`,
996
- setLoading: (v) => `loading = ${v};`,
997
- strictRoles: true,
998
- })
999
- : [
1000
- ` const value = e.detail.value.trim();`,
1001
- ` if (!value) return;`,
1002
- ` const history = [...messages, { id: crypto.randomUUID(), role: 'user' as const, content: value }];`,
1003
- ` const assistantId = crypto.randomUUID();`,
1004
- ` messages = [...history, { id: assistantId, role: 'assistant' as const, content: '' }];`,
1005
- ` loading = true;`,
1006
- ...(defaultModel
1007
- ? [
1008
- ` // SCAF-8: change this to any provider/model string you want to use.`,
1009
- ` const model = '${defaultModel}';`,
1010
- ]
1011
- : []),
1012
- ` const res = await fetch('/api/chat', {`,
1013
- ` method: 'POST',`,
1014
- ` headers: { 'Content-Type': 'application/json' },`,
1015
- ` body: JSON.stringify(${bodyPayload}),`,
1016
- ` });`,
1017
- ` // Stream the OpenAI-format SSE into the assistant message — see the Streaming recipe.`,
1018
- ` const reader = res.body.getReader();`,
1019
- ` const decoder = new TextDecoder();`,
1020
- ` let buffer = '', answer = '';`,
1021
- ` while (true) {`,
1022
- ` const { value: chunk, done } = await reader.read();`,
1023
- ` if (done) break;`,
1024
- ` buffer += decoder.decode(chunk, { stream: true });`,
1025
- ` const lines = buffer.split('\\n');`,
1026
- ` buffer = lines.pop();`,
1027
- ` for (const line of lines) {`,
1028
- ` const s = line.trim();`,
1029
- ` if (!s.startsWith('data:')) continue;`,
1030
- ` const payload = s.slice(5).trim();`,
1031
- ` if (payload === '[DONE]') continue;`,
1032
- ` try {`,
1033
- ` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
1034
- ` if (!delta) continue;`,
1035
- ` answer += delta;`,
1036
- ` messages = messages.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
1037
- ` } catch { /* skip keep-alives */ }`,
1038
- ` }`,
1039
- ` }`,
1040
- ` loading = false;`,
1041
- ].join('\n');
1042
-
1043
- // SCAF-10: ChatMessage type for strict-TS Svelte consumers — matches the React SCAF-4 type.
1044
- // SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
1045
- const chatMessageType = ` type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string }[] };`;
1046
-
1047
- // SCAF-9: seed sample messages for agentic archetype.
1048
- const sampleMessagesInit = hasEmbedded
1952
+ const onSubmitBody = realStreamBody({
1953
+ pad: ' ',
1954
+ read: 'messages',
1955
+ commitSet: (expr) => `messages = ${expr};`,
1956
+ setterAdapter: '(fn) => { messages = fn(messages); }',
1957
+ setLoading: (v) => `loading = ${v};`,
1958
+ bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
1959
+ strictRoles: true,
1960
+ toolLoop: emitToolLoop,
1961
+ cards: ctx.emitCards,
1962
+ thread: liveThreadBinding('messages', '(fn) => { messages = fn(messages); }'),
1963
+ mock: isMock,
1964
+ });
1965
+
1966
+ // SCAF-10: ChatMessage declaration for strict-TS Svelte consumers.
1967
+ const mockInit = isMock ? mockResponderInit(' ') : [];
1968
+
1969
+ // SCAF-8: model const at script scope so onSubmit closes over it.
1970
+ const modelInit = defaultModel
1049
1971
  ? [
1050
- ` // SCAF-9: tool calls + reasoning render INSIDE the thread set them on the message object.`,
1051
- ` // Replace with real messages streamed from your backend.`,
1052
- ` let messages: ChatMessage[] = [${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}];`,
1972
+ ` // SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
1973
+ ` const model = '${defaultModel}';`,
1053
1974
  ]
1054
- : [` let messages: ChatMessage[] = [];`];
1975
+ : [];
1976
+
1977
+ const cardsInit = ctx.emitCards ? cardRegistryLines(' ') : [];
1978
+ // Applied in the same upgrade-gated $effect as messages/loading/suggestions, for
1979
+ // the same reason: a property set on a not-yet-upgraded custom element is dropped
1980
+ // on upgrade. `cards` never changes, so re-running the effect re-applies the same
1981
+ // two references and does nothing.
1982
+ const cardPropEffect = ctx.emitCards
1983
+ ? ` chatEl.cardTypes = cards.tags; chatEl.cardSchemas = cards.validationSchemas;`
1984
+ : '';
1985
+
1986
+ // Same scope, same reason: onSubmit puts `tools` in the request body.
1987
+ const toolsLines = emitTools ? toolSchemaLines(' ', ctx.cardProvider) : [];
1988
+ // Same scope again: the loop in onSubmit calls runTool.
1989
+ const runnerLines = emitToolLoop ? toolRunnerLines(' ', true) : [];
1990
+
1991
+ // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
1992
+ //
1993
+ // `$state.raw`, not `$state`: the kit's contract is a NEW array reference per
1994
+ // chunk (mutating in place does not re-render), which is exactly what raw state
1995
+ // tracks. Deep state would also proxy every message object on its way into a
1996
+ // Solid-backed custom element, for reactivity this code never relies on.
1997
+ const sampleMessagesInit = [
1998
+ ...(hasEmbedded
1999
+ ? sampleSeedComment(isMock, ' ', (literal) => [
2000
+ `let messages = $state.raw<ChatMessage[]>([${literal}]);`,
2001
+ ])
2002
+ : []),
2003
+ ` let messages = $state.raw<ChatMessage[]>([]);`,
2004
+ ];
1055
2005
 
1056
- // SCAF-9: sources element ref + sample data.
1057
- const sourcesEl = standaloneCompanionTags.includes('kai-sources')
1058
- ? [` let sourcesEl: HTMLElement | undefined;`]
2006
+ // SCAF-9: sources element ref + sample data. Typed as the kit's own element
2007
+ // interface (not HTMLElement) so the `.sources =` assignment below typechecks
2008
+ // honestly under `tsc --strict`: HTMLElement has no `sources` property.
2009
+ // `bind:this` writes to this binding, so in runes mode it has to be $state.
2010
+ const sourcesEl = hasSourcesCompanion
2011
+ ? [` let sourcesEl = $state<KaiSourcesElement | undefined>(undefined);`]
1059
2012
  : [];
1060
2013
  const sourcesReactive = standaloneCompanionTags.includes('kai-sources')
1061
2014
  ? [
@@ -1064,7 +2017,7 @@ function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
1064
2017
  ` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
1065
2018
  ` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
1066
2019
  ` ];`,
1067
- ` $: if (sourcesEl) { sourcesEl.sources = sampleSources; }`,
2020
+ ` $effect(() => { if (sourcesEl) { sourcesEl.sources = sampleSources; } });`,
1068
2021
  ]
1069
2022
  : [];
1070
2023
 
@@ -1075,42 +2028,65 @@ function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
1075
2028
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
1076
2029
  ` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
1077
2030
  ` <kai-resizable-item size="40%" min="240px">`,
1078
- ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" on:kai-submit={onSubmit}></kai-chat>`,
2031
+ ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}></kai-chat>`,
1079
2032
  ` </kai-resizable-item>`,
1080
2033
  ` <kai-resizable-item min="280px">`,
1081
2034
  ` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
1082
2035
  ` <kai-artifact src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
1083
2036
  ` </kai-resizable-item>`,
1084
2037
  ` </kai-resizable>`,
2038
+ // Siblings of the SPLIT — see the same line in renderJsx.
2039
+ companionLines,
1085
2040
  ]
1086
2041
  : [
1087
- ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" on:kai-submit={onSubmit}></kai-chat>`,
2042
+ ` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}></kai-chat>`,
1088
2043
  companionLines,
1089
2044
  ];
1090
2045
 
1091
2046
  return [
1092
- `<!-- svelte — ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint} -->`,
1093
- ...(p.altNote ? [`<!-- ${p.altNote} -->`] : []),
1094
- `<!-- SCAF-5: This uses Svelte-4 syntax ($:, on:event). Works in Svelte 5 via legacy mode;`,
1095
- ` runes-mode users should adapt to $state/$effect and onkai-submit event handlers. -->`,
2047
+ `<!-- svelte — ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint} -->`,
2048
+ ...(p.altNote ?? []).map((l) => `<!-- ${l} -->`),
2049
+ `<!-- SCAF-5: Svelte 5 RUNES ($state / $effect, onkai-submit). \`sv create\` forces`,
2050
+ ` runes mode project-wide (see the compilerOptions in its vite.config.ts), so the`,
2051
+ ` Svelte-4 forms this used to emit are hard errors there, not deprecations:`,
2052
+ ` "\`$:\` is not allowed in runes mode" fails svelte-check AND vite build. -->`,
1096
2053
  `<script lang="ts">`,
1097
2054
  ` import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
1098
- ` import type { KaiChatElement } from '@kitn.ai/ui/elements';`,
2055
+ // KaiSourcesElement is only imported when a kai-sources companion is actually
2056
+ // declared below: an always-on import would be unused (and fail noUnusedLocals)
2057
+ // on every archetype without kai-sources.
2058
+ ` import type { ${hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement'} } from '@kitn.ai/ui/elements';`,
2059
+ ...wireImportLines({
2060
+ pad: ' ',
2061
+ typed: true,
2062
+ toolLoop: emitToolLoop,
2063
+ cards: ctx.emitCards,
2064
+ cardTools: ctx.cardProvider !== null,
2065
+ mock: isMock,
2066
+ }),
1099
2067
  ` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
1100
2068
  ` import { onMount } from 'svelte';`,
1101
- chatMessageType,
1102
- ` let chatEl: KaiChatElement | undefined;`,
2069
+ ...mockInit,
2070
+ ` // \`bind:this\` writes to this binding, so under runes it must be $state.`,
2071
+ ` let chatEl = $state<KaiChatElement | undefined>(undefined);`,
1103
2072
  ` // SCAF-15: kai-* register via an async dynamic import (SSR-safety). Gate the`,
1104
- ` // reactive property block on the upgrade so the first application isn't dropped`,
2073
+ ` // property $effect on the upgrade so the first application isn't dropped`,
1105
2074
  ` // (props set on a not-yet-upgraded element are lost on upgrade).`,
1106
- ` let defined = false;`,
2075
+ ` let defined = $state(false);`,
1107
2076
  ` onMount(async () => { await customElements.whenDefined('kai-chat'); defined = true; });`,
1108
2077
  ...sourcesEl,
1109
2078
  ...sampleMessagesInit,
1110
- ` let loading: boolean = false;`,
2079
+ ` let loading = $state(false);`,
1111
2080
  ` const suggestions: string[] = ${jsArray(suggestions)};`,
2081
+ ...modelInit,
2082
+ ...cardsInit,
2083
+ ...toolsLines,
2084
+ ...runnerLines,
1112
2085
  ` // suggestions/messages are JS PROPERTIES (arrays/objects can't be attributes)`,
1113
- ` $: if (chatEl && defined) { chatEl.messages = messages; chatEl.loading = loading; chatEl.suggestions = suggestions; }`,
2086
+ ...(ctx.emitCards ? CARD_PROP_COMMENT.map((l) => ` ${l}`) : []),
2087
+ ` $effect(() => {`,
2088
+ ` if (chatEl && defined) { chatEl.messages = messages; chatEl.loading = loading; chatEl.suggestions = suggestions;${cardPropEffect} }`,
2089
+ ` });`,
1114
2090
  ...sourcesReactive,
1115
2091
  ``,
1116
2092
  ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
@@ -1138,19 +2114,23 @@ function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
1138
2114
  * Scaffold command (official TanStack CLI, non-interactive):
1139
2115
  * npx @tanstack/cli@latest create <app-name> --framework react --no-git --package-manager npm -y
1140
2116
  *
1141
- * After scaffolding, run `npm install @kitn.ai/ui`, then drop this file into
1142
- * `src/routes/chat.tsx`. Start the dev server with `npm run dev` (port 3000).
2117
+ * After scaffolding, install what block (3) lists — `@kitn.ai/ui` plus whatever
2118
+ * the chosen integration's route imports, which this renderer cannot know and so
2119
+ * no longer guesses — then drop this file into `src/routes/chat.tsx`. Start the
2120
+ * dev server with `npm run dev` (port 3000).
1143
2121
  * Build: `npm run build`; preview: `npm run preview` (or `node dist/server/server.js`).
1144
2122
  * Note: `npm start` does NOT exist in TanStack Start projects — use `npm run dev` / `npm run preview`.
1145
2123
  *
1146
- * Backend: TanStack Start supports server-side routes via `createServerFn` in
1147
- * `src/server/`. For a chat API, place the route in `src/server/chat.ts` and call
1148
- * it from your route component. The emitted scaffold uses `fetch('/api/chat')` as a
1149
- * placeholder pointing at a standard route swap for your TanStack server function
1150
- * or an external endpoint.
2124
+ * Backend: the emitted front end fetches `/api/chat`, so it needs a SERVER ROUTE,
2125
+ * not a server function. In TanStack Start that is a file route carrying a
2126
+ * `server.handlers` block `src/routes/api/chat.ts` with
2127
+ * `createFileRoute('/api/chat')({ server: { handlers: { POST } } })`. Block (2)
2128
+ * emits exactly that. This comment used to name `createServerFn` and
2129
+ * `src/server/chat.ts`, which can never answer a `fetch('/api/chat')`: the two
2130
+ * halves of the same scaffold contradicted each other.
1151
2131
  */
1152
- function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
1153
- const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
2132
+ function renderTanstackStart(components: readonly string[], ctx: RenderCtx): string {
2133
+ const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
1154
2134
 
1155
2135
  // TanStack Start is React — reuse all the React composition logic:
1156
2136
  // same ChatMessage type, same state/loading/suggestions, same mock stream body,
@@ -1161,25 +2141,25 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
1161
2141
  // 4. No `import '@kitn.ai/ui/elements'` needed as a top-level import (same as next's dynamic approach
1162
2142
  // is not needed here — the library is SSR-import-safe, but we include elements for safety)
1163
2143
 
1164
- const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
1165
- const workspace = isWorkspace(archetype);
2144
+ const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2145
+ const workspace = isWorkspace(components);
1166
2146
 
1167
- const renderableTags = archetype.components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
2147
+ const renderableTags = components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
1168
2148
  const importTags = workspace
1169
2149
  ? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
1170
2150
  : renderableTags;
1171
2151
  const wrapperNames = importTags.map(toPascalCase);
1172
2152
  const importList = wrapperNames.join(', ');
1173
2153
 
1174
- const standaloneCompanionTags = archetype.components.filter(
2154
+ const standaloneCompanionTags = components.filter(
1175
2155
  (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
1176
2156
  );
1177
2157
 
1178
2158
  const companionJsxLines: string[] = [];
1179
2159
  if (hasEmbedded) {
1180
2160
  companionJsxLines.push(
1181
- ` {/* kai-tool / kai-reasoning render inside the thread. Tool calls + reasoning`,
1182
- ` are set on each message object see the sampleMessages initializer above. */}`,
2161
+ ` {/* kai-tool / kai-reasoning render inside the thread, as parts on the`,
2162
+ ` assistant message the stream in onSubmit builds. */}`,
1183
2163
  );
1184
2164
  }
1185
2165
  for (const t of standaloneCompanionTags) {
@@ -1195,16 +2175,18 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
1195
2175
  }
1196
2176
  const companions = companionJsxLines.join('\n');
1197
2177
 
1198
- const chatMessageType = `type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string }[] };`;
2178
+ const mockInit = isMock ? mockResponderInit() : [];
1199
2179
 
1200
- const sampleMessagesInit = hasEmbedded
1201
- ? [
1202
- ` // SCAF-9: tool calls and reasoning render inside the thread — set them on the message object.`,
1203
- ` // Replace with real messages streamed from your backend.`,
1204
- ` const sampleMessages: ChatMessage[] = [${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}];`,
1205
- ` const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
1206
- ].join('\n')
1207
- : ` const [messages, setMessages] = useState<ChatMessage[]>([]);`;
2180
+ // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
2181
+ const sampleMessagesInit = [
2182
+ ...(hasEmbedded
2183
+ ? sampleSeedComment(isMock, ' ', (literal) => [
2184
+ `const sampleMessages: ChatMessage[] = [${literal}];`,
2185
+ `const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
2186
+ ])
2187
+ : []),
2188
+ ` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
2189
+ ].join('\n');
1208
2190
 
1209
2191
  const sampleSourcesInit =
1210
2192
  standaloneCompanionTags.includes('kai-sources')
@@ -1218,67 +2200,48 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
1218
2200
  : '';
1219
2201
 
1220
2202
  const modelInit = defaultModel
1221
- ? ` // SCAF-8: change this to any provider/model string you want to use.\n const model = '${defaultModel}';`
2203
+ ? ` // SCAF-8: change this model id to another id THIS PROVIDER accepts.\n const model = '${defaultModel}';`
1222
2204
  : '';
1223
- const bodyPayload = defaultModel
1224
- ? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }`
1225
- : `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
1226
-
1227
- const onSubmitBody = isMock
1228
- ? mockStreamBody({
1229
- pad: ' ',
1230
- read: 'messages',
1231
- commitInitial: (expr) => `setMessages(${expr});`,
1232
- commitMap: (mapBody) => `setMessages((prev) => prev.map((m) => ${mapBody}));`,
1233
- setLoading: (v) => `setLoading(${v});`,
1234
- strictRoles: true,
1235
- })
1236
- : [
1237
- ` const value = e.detail.value.trim();`,
1238
- ` if (!value) return;`,
1239
- ` const history: ChatMessage[] = [...messages, { id: crypto.randomUUID(), role: 'user' as const, content: value }];`,
1240
- ` const assistantId = crypto.randomUUID();`,
1241
- ` setMessages([...history, { id: assistantId, role: 'assistant' as const, content: '' }]);`,
1242
- ` setLoading(true);`,
1243
- ` const res = await fetch('/api/chat', {`,
1244
- ` method: 'POST',`,
1245
- ` headers: { 'Content-Type': 'application/json' },`,
1246
- ` body: JSON.stringify(${bodyPayload}),`,
1247
- ` });`,
1248
- ` // Stream the OpenAI-format SSE into the assistant message — see the Streaming recipe.`,
1249
- ` const reader = res.body!.getReader();`,
1250
- ` const decoder = new TextDecoder();`,
1251
- ` let buffer = '', answer = '';`,
1252
- ` while (true) {`,
1253
- ` const { value: chunk, done } = await reader.read();`,
1254
- ` if (done) break;`,
1255
- ` buffer += decoder.decode(chunk, { stream: true });`,
1256
- ` const lines = buffer.split('\\n');`,
1257
- ` buffer = lines.pop()!;`,
1258
- ` for (const line of lines) {`,
1259
- ` const s = line.trim();`,
1260
- ` if (!s.startsWith('data:')) continue;`,
1261
- ` const payload = s.slice(5).trim();`,
1262
- ` if (payload === '[DONE]') continue;`,
1263
- ` try {`,
1264
- ` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
1265
- ` if (!delta) continue;`,
1266
- ` answer += delta;`,
1267
- ` setMessages((ms) => ms.map((m) => (m.id === assistantId ? { ...m, content: answer } : m)));`,
1268
- ` } catch { /* skip keep-alives */ }`,
1269
- ` }`,
1270
- ` }`,
1271
- ` setLoading(false);`,
1272
- ].join('\n');
2205
+
2206
+ const toolsInit = emitTools ? toolSchemaLines(' ', ctx.cardProvider).join('\n') : '';
2207
+ const toolRunner = emitToolLoop ? toolRunnerLines(' ', true).join('\n') : '';
2208
+ // Module scope: static data, so it is not rebuilt on every render (see renderJsx).
2209
+ const cardsInit = ctx.emitCards ? cardRegistryLines('') : [];
2210
+ const cardProps = (pad: string): string[] =>
2211
+ ctx.emitCards
2212
+ ? [`${pad}cardTypes={cards.tags}`, `${pad}cardSchemas={cards.validationSchemas}`]
2213
+ : [];
2214
+ const cardPropsNote = (pad: string): string[] =>
2215
+ ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
2216
+
2217
+ const onSubmitBody = realStreamBody({
2218
+ pad: ' ',
2219
+ read: 'messages',
2220
+ commitSet: (expr) => `setMessages(${expr});`,
2221
+ // useState's setter IS a SetMessages: both are (updater) => void.
2222
+ setterAdapter: 'setMessages',
2223
+ setLoading: (v) => `setLoading(${v});`,
2224
+ bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
2225
+ strictRoles: true,
2226
+ toolLoop: emitToolLoop,
2227
+ cards: ctx.emitCards,
2228
+ thread: REACT_THREAD,
2229
+ mock: isMock,
2230
+ });
1273
2231
 
1274
2232
  // File path guidance for TanStack Start (file-based routing)
1275
2233
  const filePathNote = [
1276
2234
  `// TanStack Start route file — save as: src/routes/chat.tsx`,
1277
2235
  `// Scaffold command: npx @tanstack/cli@latest create <app-name> --framework react --no-git --package-manager npm -y`,
1278
- `// Then: npm install @kitn.ai/ui`,
2236
+ // NOT "npm install @kitn.ai/ui". This block is emitted for every integration,
2237
+ // so a fixed package list here is wrong for the eight of them whose route
2238
+ // imports something else — and it silently WAS, for langgraph and mastra.
2239
+ // Block (3) builds the line from the integration's own `deps`.
2240
+ `// Then: install the packages block (3) lists.`,
1279
2241
  `// Dev: npm run dev (port 3000) Build: npm run build Preview: npm run preview`,
1280
2242
  `// Note: there is no 'npm start' script — use 'npm run dev' or 'npm run preview'.`,
1281
- `// Backend: place TanStack server functions in src/server/chat.ts (createServerFn).`,
2243
+ `// Backend: the fetch below hits /api/chat, so it needs a SERVER ROUTE, not a`,
2244
+ `// server function: src/routes/api/chat.ts with server.handlers.POST — block (2).`,
1282
2245
  ``,
1283
2246
  ];
1284
2247
 
@@ -1290,12 +2253,21 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
1290
2253
  // Elements registration: the library is SSR-import-safe; top-level import is safe here
1291
2254
  `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
1292
2255
  `import { ${importList} } from '@kitn.ai/ui/react'`,
2256
+ ...wireImportLines({
2257
+ typed: true,
2258
+ toolLoop: emitToolLoop,
2259
+ setMessagesType: emitToolLoop,
2260
+ cards: ctx.emitCards,
2261
+ cardTools: ctx.cardProvider !== null,
2262
+ mock: isMock,
2263
+ }),
1293
2264
  `import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
1294
2265
  ``,
1295
- `// ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint}`,
1296
- ...(p.altNote ? [`// ${p.altNote}`] : []),
1297
- chatMessageType,
2266
+ `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
2267
+ ...(p.altNote ?? []).map((l) => `// ${l}`),
2268
+ ...mockInit,
1298
2269
  ``,
2270
+ ...cardsInit,
1299
2271
  `// ssr: false keeps the Solid-based web component client-only.`,
1300
2272
  `// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
1301
2273
  `export const Route = createFileRoute('/chat')({`,
@@ -1309,51 +2281,847 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
1309
2281
  ` const suggestions = ${jsArray(suggestions)};`,
1310
2282
  ...(sampleSourcesInit ? [sampleSourcesInit] : []),
1311
2283
  ...(modelInit ? [modelInit] : []),
2284
+ ...(toolsInit ? [toolsInit] : []),
2285
+ ...(toolRunner ? [toolRunner] : []),
1312
2286
  ``,
1313
2287
  ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
1314
2288
  onSubmitBody,
1315
2289
  ` }`,
1316
2290
  ``,
1317
2291
  ` return (`,
1318
- ` <main style={{ ${jsxStyle(p.style)} }}>`,
1319
- ...(workspace
1320
- ? [
1321
- ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
1322
- ` {/* Resizable needs ResizableItem children to render panels. */}`,
1323
- ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
1324
- ` <ResizableItem size="40%" min="240px">`,
1325
- ` <Chat`,
1326
- ` messages={messages}`,
1327
- ` loading={loading}`,
1328
- ` suggestions={suggestions}`,
1329
- ` suggestionMode="submit"`,
1330
- ` onSubmit={onSubmit}`,
1331
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
1332
- ` />`,
1333
- ` </ResizableItem>`,
1334
- ` <ResizableItem min="280px">`,
1335
- ` {/* Replace src with your artifact URL or set files for multi-file preview. */}`,
1336
- ` <Artifact src="https://example.com" style={{ width: '100%', height: '100%' }} />`,
1337
- ` </ResizableItem>`,
1338
- ` </Resizable>`,
1339
- ]
1340
- : [
1341
- ` <Chat`,
1342
- ` messages={messages}`,
1343
- ` loading={loading}`,
1344
- ` suggestions={suggestions}`,
1345
- ` suggestionMode="submit"`,
1346
- ` onSubmit={onSubmit}`,
1347
- ` style={{ ${jsxStyle(p.chatFill)} }}`,
1348
- ` />`,
1349
- companions,
1350
- ]),
1351
- ` </main>`,
2292
+ ` <main style={{ ${jsxStyle(p.style)} }}>`,
2293
+ ...(workspace
2294
+ ? [
2295
+ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
2296
+ ` {/* Resizable needs ResizableItem children to render panels. */}`,
2297
+ ` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
2298
+ ` <ResizableItem size="40%" min="240px">`,
2299
+ ...cardPropsNote(' '),
2300
+ ` <Chat`,
2301
+ ` messages={messages}`,
2302
+ ` loading={loading}`,
2303
+ ` suggestions={suggestions}`,
2304
+ ` suggestionMode="submit"`,
2305
+ ...cardProps(' '),
2306
+ ` onSubmit={onSubmit}`,
2307
+ ` style={{ ${jsxStyle(p.chatFill)} }}`,
2308
+ ` />`,
2309
+ ` </ResizableItem>`,
2310
+ ` <ResizableItem min="280px">`,
2311
+ ` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
2312
+ ` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
2313
+ ` </ResizableItem>`,
2314
+ ` </Resizable>`,
2315
+ // Siblings of the SPLIT — see the same line in the other JSX branch.
2316
+ companions,
2317
+ ]
2318
+ : [
2319
+ ...cardPropsNote(' '),
2320
+ ` <Chat`,
2321
+ ` messages={messages}`,
2322
+ ` loading={loading}`,
2323
+ ` suggestions={suggestions}`,
2324
+ ` suggestionMode="submit"`,
2325
+ ...cardProps(' '),
2326
+ ` onSubmit={onSubmit}`,
2327
+ ` style={{ ${jsxStyle(p.chatFill)} }}`,
2328
+ ` />`,
2329
+ companions,
2330
+ ]),
2331
+ ` </main>`,
2332
+ ` );`,
2333
+ `}`,
2334
+ ]
2335
+ .filter((l) => l !== '')
2336
+ .join('\n');
2337
+ }
2338
+
2339
+ /**
2340
+ * Angular: the `kai-*` custom elements, same as vue/svelte/html — plus the two
2341
+ * things Angular needs and no other framework does.
2342
+ *
2343
+ * 1. `schemas: [CUSTOM_ELEMENTS_SCHEMA]` on the component. Without it the
2344
+ * template compiler REJECTS every unknown tag ("'kai-chat' is not a known
2345
+ * element") and `ng build` fails outright. With it, Angular stamps the tag
2346
+ * and passes the bindings straight through to the DOM.
2347
+ * 2. `[messages]="messages()"` — the square brackets are what make it a DOM
2348
+ * PROPERTY. `messages="…"` would be an ATTRIBUTE, i.e. the string
2349
+ * "[object Object]": arrays and objects only ever reach a custom element as
2350
+ * properties. Same for `[suggestions]` and `[loading]`.
2351
+ *
2352
+ * Two Angular-specific facts the emitted comments carry, because getting either
2353
+ * wrong is a build error rather than a subtle bug:
2354
+ *
2355
+ * · Stylesheets come from `angular.json` -> architect.build.options.styles.
2356
+ * `@angular/build` does not take a TS `import './x.css'`, so the theme
2357
+ * cannot be imported the way every other framework imports it here.
2358
+ * · `$event` on an unknown custom-element event is typed `Event` under
2359
+ * `strictTemplates`, not `CustomEvent`, so the handler takes an `Event` and
2360
+ * narrows inside — exactly what examples/starters/angular does.
2361
+ *
2362
+ * The thread is an Angular signal, which reads back synchronously, so this uses
2363
+ * `accessorThreadBinding` and not React's turn-scoped `thread` copy.
2364
+ */
2365
+ function renderAngular(components: readonly string[], ctx: RenderCtx): string {
2366
+ const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
2367
+
2368
+ const workspace = isWorkspace(components);
2369
+ const standaloneCompanionTags = components.filter(
2370
+ (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2371
+ );
2372
+ const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2373
+ const hasSourcesCompanion = standaloneCompanionTags.includes('kai-sources');
2374
+
2375
+ const companionLines: string[] = [];
2376
+ if (hasEmbedded) {
2377
+ companionLines.push(
2378
+ ` <!-- kai-tool / kai-reasoning render INSIDE the thread, as parts on the assistant message the stream builds. -->`,
2379
+ );
2380
+ }
2381
+ for (const t of standaloneCompanionTags) {
2382
+ if (t === 'kai-sources') {
2383
+ companionLines.push(
2384
+ ` <!-- Replace sampleSources with your real data. [sources] is a PROPERTY binding: an array can't be an attribute. -->`,
2385
+ ` <kai-sources #sources [sources]="sampleSources"></kai-sources>`,
2386
+ );
2387
+ } else {
2388
+ companionLines.push(` <!-- wire data props — see the component_reference MCP tool -->`);
2389
+ companionLines.push(` <${t}></${t}>`);
2390
+ }
2391
+ }
2392
+
2393
+ // Angular signals: `this.messages()` reads, `this.messages.set(next)` writes a
2394
+ // BRAND-NEW array, which is what re-renders <kai-chat>.
2395
+ const read = 'this.messages()';
2396
+ const commit = (value: string) => `this.messages.set(${value});`;
2397
+ const setter = '(fn) => this.messages.set(fn(this.messages()))';
2398
+
2399
+ const onSubmitBody = realStreamBody({
2400
+ pad: ' ',
2401
+ read,
2402
+ commitSet: (expr) => commit(expr),
2403
+ setterAdapter: setter,
2404
+ setLoading: (v) => `this.loading.set(${v});`,
2405
+ bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
2406
+ strictRoles: true,
2407
+ toolLoop: emitToolLoop,
2408
+ cards: ctx.emitCards,
2409
+ thread: accessorThreadBinding(read, commit, setter),
2410
+ mock: isMock,
2411
+ });
2412
+
2413
+ // Module scope, exactly like vue: a class can hold neither a bare `const` nor a
2414
+ // `function` declaration, and the methods below close over all three.
2415
+ const modelInit = defaultModel
2416
+ ? [
2417
+ `// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
2418
+ `const model = '${defaultModel}';`,
2419
+ ``,
2420
+ ]
2421
+ : [];
2422
+ const cardsInit = ctx.emitCards ? [...cardRegistryLines(''), ``] : [];
2423
+ const toolsLines = emitTools ? [...toolSchemaLines('', ctx.cardProvider), ``] : [];
2424
+ const runnerLines = emitToolLoop ? [...toolRunnerLines('', true), ``] : [];
2425
+ // Set in the SAME afterNextRender re-application as messages/loading/suggestions,
2426
+ // not as a `[cardTypes]` template binding. Two reasons, and the second is the
2427
+ // load-bearing one: a registry is static, so a binding buys nothing; and an
2428
+ // Angular template can only read CLASS members, so a template binding would need
2429
+ // the module-scope `cards` restated as a field. The re-apply is also the point
2430
+ // that matters — a property set on a not-yet-upgraded custom element is dropped.
2431
+ const cardPropAssign = ctx.emitCards
2432
+ ? [
2433
+ ...CARD_PROP_COMMENT.map((l) => ` ${l}`),
2434
+ ` cardTypes: cards.tags,`,
2435
+ ` cardSchemas: cards.validationSchemas,`,
2436
+ ]
2437
+ : [];
2438
+
2439
+ // SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
2440
+ const sampleSeed = [
2441
+ ...(hasEmbedded
2442
+ ? sampleSeedComment(isMock, ' ', (literal) => [
2443
+ `readonly messages = signal<ChatMessage[]>([${literal}]);`,
2444
+ ])
2445
+ : []),
2446
+ ` readonly messages = signal<ChatMessage[]>([]);`,
2447
+ ];
2448
+
2449
+ const sourcesField = hasSourcesCompanion
2450
+ ? [
2451
+ ` // Replace sampleSources with your real source data.`,
2452
+ ` readonly sampleSources = [`,
2453
+ ` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
2454
+ ` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
2455
+ ` ];`,
2456
+ ` private readonly sourcesEl = viewChild.required<ElementRef<KaiSourcesElement>>('sources');`,
2457
+ ]
2458
+ : [];
2459
+ const sourcesReapply = hasSourcesCompanion
2460
+ ? [` Object.assign(this.sourcesEl().nativeElement, { sources: this.sampleSources });`]
2461
+ : [];
2462
+
2463
+ const chatTag = (pad: string) =>
2464
+ [
2465
+ `<kai-chat`,
2466
+ ` #chat`,
2467
+ ` [messages]="messages()"`,
2468
+ ` [loading]="loading()"`,
2469
+ ` [suggestions]="suggestions"`,
2470
+ ` suggestion-mode="submit"`,
2471
+ ` style="${p.chatFill}"`,
2472
+ ` (kai-submit)="onSubmit($event)"`,
2473
+ `></kai-chat>`,
2474
+ ].map((l) => `${pad}${l}`);
2475
+
2476
+ const templateBody = workspace
2477
+ ? [
2478
+ ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
2479
+ ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
2480
+ ` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
2481
+ ` <kai-resizable-item size="40%" min="240px">`,
2482
+ ...chatTag(' '),
2483
+ ` </kai-resizable-item>`,
2484
+ ` <kai-resizable-item min="280px">`,
2485
+ ` <!-- Replace src with your artifact URL or set [files] for multi-file preview. -->`,
2486
+ ` <kai-artifact src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
2487
+ ` </kai-resizable-item>`,
2488
+ ` </kai-resizable>`,
2489
+ // Siblings of the SPLIT — see the same line in renderJsx.
2490
+ ...companionLines,
2491
+ ]
2492
+ : [...chatTag(' '), ...companionLines];
2493
+
2494
+ // KaiSourcesElement is imported only when a kai-sources companion is really
2495
+ // declared: an always-on import is unused on every other archetype, and a stock
2496
+ // Angular tsconfig turns on the checks that make that a build error.
2497
+ const elementTypes = hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement';
2498
+
2499
+ return [
2500
+ `// Angular standalone component — save as: src/app/chat.component.ts`,
2501
+ `// Render it: put <app-chat /> in your root template and add ChatComponent to`,
2502
+ `// that component's \`imports: [...]\`.`,
2503
+ `//`,
2504
+ `// SETUP, once — Angular takes stylesheets from angular.json, NOT from a TS`,
2505
+ `// \`import './x.css'\`, so the theme cannot be imported here the way it is in`,
2506
+ `// every other framework. Add it to architect.build.options.styles:`,
2507
+ `// "styles": ["node_modules/@kitn.ai/ui/dist/theme.tokens.css", "src/styles.css"]`,
2508
+ `// (@kitn.ai/ui/theme.tokens.css is the compiled token file; theme.css is`,
2509
+ `// Tailwind source and is only for apps that compile Tailwind themselves.)`,
2510
+ `import { CUSTOM_ELEMENTS_SCHEMA, Component, ElementRef, afterNextRender, signal, viewChild } from '@angular/core';`,
2511
+ `import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
2512
+ `import type { ${elementTypes} } from '@kitn.ai/ui/elements';`,
2513
+ ...wireImportLines({
2514
+ typed: true,
2515
+ toolLoop: emitToolLoop,
2516
+ cards: ctx.emitCards,
2517
+ cardTools: ctx.cardProvider !== null,
2518
+ mock: isMock,
2519
+ }),
2520
+ ``,
2521
+ `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
2522
+ ...(p.altNote ?? []).map((l) => `// ${l}`),
2523
+ ...(isMock ? mockResponderInit() : []),
2524
+ ``,
2525
+ ...modelInit,
2526
+ ...cardsInit,
2527
+ ...toolsLines,
2528
+ ...runnerLines,
2529
+ `@Component({`,
2530
+ ` selector: 'app-chat',`,
2531
+ ` // REQUIRED. Angular does not know the kai-* tags; without this the template`,
2532
+ ` // compiler fails with "'kai-chat' is not a known element". With it, Angular`,
2533
+ ` // stamps the tag and passes [prop] bindings through to the DOM.`,
2534
+ ` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
2535
+ ` template: \``,
2536
+ ` <div style="${p.style}">`,
2537
+ ...templateBody,
2538
+ ` </div>`,
2539
+ ` \`,`,
2540
+ `})`,
2541
+ `export class ChatComponent {`,
2542
+ ` // Every write assigns a NEW array. That reference change is what re-renders`,
2543
+ ` // <kai-chat> — mutating the array in place does nothing.`,
2544
+ ...sampleSeed,
2545
+ ` readonly loading = signal(false);`,
2546
+ ` readonly suggestions = ${jsArray(suggestions)};`,
2547
+ ...sourcesField,
2548
+ ` private readonly chatEl = viewChild.required<ElementRef<KaiChatElement>>('chat');`,
2549
+ ``,
2550
+ ` constructor() {`,
2551
+ ` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
2552
+ ` // element may not be upgraded when Angular first applies the bindings above —`,
2553
+ ` // and a property set on a not-yet-upgraded element is dropped on upgrade.`,
2554
+ ` // Re-apply once it is defined so the initial messages/suggestions/loading stick.`,
2555
+ ` // afterNextRender never runs on the server, which is also what keeps this`,
2556
+ ` // \`customElements\` reference safe under SSR/prerender.`,
2557
+ ` afterNextRender(async () => {`,
2558
+ ` await customElements.whenDefined('kai-chat');`,
2559
+ ` Object.assign(this.chatEl().nativeElement, {`,
2560
+ ` messages: this.messages(),`,
2561
+ ` loading: this.loading(),`,
2562
+ ` suggestions: this.suggestions,`,
2563
+ ...cardPropAssign,
2564
+ ` });`,
2565
+ ...sourcesReapply,
2566
+ ` });`,
2567
+ ` }`,
2568
+ ``,
2569
+ ` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
2570
+ ` // an unknown custom-element event as a plain Event, so the narrowing happens`,
2571
+ ` // here rather than in the signature.`,
2572
+ ` async onSubmit(event: Event) {`,
2573
+ ` const e = event as CustomEvent<{ value: string }>;`,
2574
+ onSubmitBody,
2575
+ ` }`,
2576
+ `}`,
2577
+ ]
2578
+ // Collapse runs of blanks rather than dropping every blank (what the JSX/vue
2579
+ // renderers do): this target emits one long file — imports, module-scope
2580
+ // consts, the decorator, the class — and with no separators at all it reads
2581
+ // as a wall.
2582
+ .filter((l, i, arr) => l !== '' || (i > 0 && i < arr.length - 1 && arr[i - 1] !== ''))
2583
+ .join('\n');
2584
+ }
2585
+
2586
+ /**
2587
+ * SolidJS — the one target that does NOT render `kai-*`.
2588
+ *
2589
+ * The kit is AUTHORED in Solid, so a Solid consumer imports the real components
2590
+ * from the `@kitn.ai/ui` root entry and gets real props and real fine-grained
2591
+ * reactivity. Routing it through the custom-element facade would ship the Solid
2592
+ * runtime twice and put a reactive-context boundary in the middle of the app for
2593
+ * no gain.
2594
+ *
2595
+ * THE GRANULARITY GAP. `<kai-chat>` is a coarse preset: one tag renders the
2596
+ * thread, the parts, the scroll behaviour, the suggestions and the composer. The
2597
+ * Solid layer is fine-grained — ChatContainer / Message / MessageContent /
2598
+ * PromptInput / … — so the same capability has to be composed here. Two
2599
+ * consequences the emitted code has to handle, and both are silent failures if
2600
+ * it does not:
2601
+ *
2602
+ * 1. The thread renders exactly what `renderPart` renders, so `renderPart` has
2603
+ * to carry a branch for EVERY variant of `MessagePart`. `<kai-chat>` owns
2604
+ * that switch internally; a hand-composed tree that only handles `text`
2605
+ * shows nothing when a reasoning or tool part streams in. A missing branch
2606
+ * is not a type error — a `<Switch>` with fewer `<Match>`es compiles fine —
2607
+ * it is a part that arrives in the data and renders nothing, which is how
2608
+ * `card` and `source` were silently dropped here while this very paragraph
2609
+ * claimed otherwise. `solidPartCoverageCheck` in
2610
+ * scripts/verify-scaffold-compiles.mjs now derives the variant list from
2611
+ * the `MessagePart` union and fails the build when one has no branch. The
2612
+ * part renderer is emitted for EVERY archetype, not just the agentic one —
2613
+ * the stream can produce any variant regardless of which components the
2614
+ * archetype names.
2615
+ * 2. The components are Tailwind-v4 SOURCE, not shadow-encapsulated CSS. The
2616
+ * class names have to survive into the consumer's stylesheet, which is what
2617
+ * the `@source` line in the setup note is for. Without it Tailwind scans
2618
+ * only `src/`, strips every kit class as unused, and the app renders
2619
+ * unstyled — see examples/starters/solid/README.md.
2620
+ *
2621
+ * Solid signals read back synchronously, so this uses `accessorThreadBinding`
2622
+ * and not React's turn-scoped `thread` copy.
2623
+ */
2624
+ function renderSolid(components: readonly string[], ctx: RenderCtx): string {
2625
+ const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
2626
+
2627
+ const workspace = isWorkspace(components);
2628
+ const standaloneCompanionTags = components.filter(
2629
+ (t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
2630
+ );
2631
+ const hasSources = standaloneCompanionTags.includes('kai-sources');
2632
+ const hasVoice = standaloneCompanionTags.includes('kai-voice-input');
2633
+
2634
+ // Every name here is referenced by the emitted tree below — `noUnusedLocals` is
2635
+ // on in a stock Solid app (`npm run build` runs `tsc` first), so an extra one
2636
+ // is a build failure.
2637
+ //
2638
+ // The unconditional block is what `renderPart` needs, and it is unconditional
2639
+ // because `renderPart` is: the stream can produce any MessagePart variant no
2640
+ // matter which components the archetype named. `Source*` in particular is NOT
2641
+ // gated on `hasSources` — that flag only decides whether the archetype also
2642
+ // gets a standalone sample source list, while `source` PARTS arrive from any
2643
+ // model that cites.
2644
+ const componentImports = [
2645
+ 'Attachment',
2646
+ 'AttachmentInfo',
2647
+ 'AttachmentPreview',
2648
+ 'Attachments',
2649
+ 'Button',
2650
+ 'CardRenderer',
2651
+ 'ChatConfig',
2652
+ 'ChatContainer',
2653
+ 'ChatContainerContent',
2654
+ 'ChatContainerScrollAnchor',
2655
+ 'Message',
2656
+ 'MessageContent',
2657
+ 'PromptInput',
2658
+ 'PromptInputActions',
2659
+ 'PromptInputTextarea',
2660
+ 'PromptSuggestion',
2661
+ 'Reasoning',
2662
+ 'ReasoningContent',
2663
+ 'ReasoningTrigger',
2664
+ 'ScrollButton',
2665
+ 'Source',
2666
+ 'SourceContent',
2667
+ 'SourceList',
2668
+ 'SourceTrigger',
2669
+ 'Tool',
2670
+ ...(workspace ? ['Artifact', 'ResizableHandle', 'ResizablePanel', 'ResizablePanelGroup'] : []),
2671
+ ...(hasVoice ? ['VoiceInput'] : []),
2672
+ ].sort();
2673
+
2674
+ // Solid signals: `messages()` reads, `setMessages(next)` writes a NEW array.
2675
+ const read = 'messages()';
2676
+ const commit = (value: string) => `setMessages(${value});`;
2677
+ // Solid's own Setter is overloaded (and treats a function argument as an
2678
+ // updater), so it is wrapped rather than handed over directly — the wrapper is
2679
+ // exactly the `SetMessages` shape createAssistantStream wants.
2680
+ const setter = '(fn) => setMessages((prev) => fn(prev))';
2681
+
2682
+ const onSubmitBody = realStreamBody({
2683
+ pad: ' ',
2684
+ read,
2685
+ commitSet: (expr) => commit(expr),
2686
+ setterAdapter: setter,
2687
+ setLoading: (v) => `setLoading(${v});`,
2688
+ bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
2689
+ strictRoles: true,
2690
+ toolLoop: emitToolLoop,
2691
+ cards: ctx.emitCards,
2692
+ thread: accessorThreadBinding(read, commit, setter),
2693
+ valueSource: 'input()',
2694
+ afterValue: [`setInput('');`],
2695
+ mock: isMock,
2696
+ });
2697
+
2698
+ const modelInit = defaultModel
2699
+ ? [
2700
+ ` // SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
2701
+ ` const model = '${defaultModel}';`,
2702
+ ]
2703
+ : [];
2704
+ // MODULE scope in this target and not merely by convention: `renderPart` is a
2705
+ // top-level function (it has to be — <Index> calls it per row), and it is what
2706
+ // hands the registry to <CardRenderer>. A const inside App() would be invisible
2707
+ // to it.
2708
+ const cardsInit = ctx.emitCards ? [...cardRegistryLines(''), ``] : [];
2709
+ const toolsLines = emitTools ? toolSchemaLines(' ', ctx.cardProvider) : [];
2710
+ const runnerLines = emitToolLoop ? toolRunnerLines(' ', true) : [];
2711
+ // Solid renders the components directly, so the props go on <CardRenderer>
2712
+ // itself rather than on <kai-chat>: `types` is the Solid-component half of
2713
+ // `cardTypes` (a component, not a tag name) and `schemas` is `cardSchemas`.
2714
+ const cardRendererProps = ctx.emitCards
2715
+ ? [
2716
+ ` types={cards.components}`,
2717
+ ` schemas={cards.validationSchemas}`,
2718
+ ]
2719
+ : [];
2720
+
2721
+ const sourcesInit = hasSources
2722
+ ? [
2723
+ ` // Replace sampleSources with your real source data.`,
2724
+ ` const sampleSources = [`,
2725
+ ` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
2726
+ ` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
2727
+ ` ];`,
2728
+ ]
2729
+ : [];
2730
+
2731
+ // The scrolling thread + composer. Reused verbatim inside the workspace split.
2732
+ // `fill` is how this column claims its height: the placement's own chatFill at
2733
+ // the top level, 100% of the pane inside a resizable panel.
2734
+ const surface = (pad: string, fill: string): string[] =>
2735
+ [
2736
+ `<div class="flex w-full flex-col" style={{ ${fill} }}>`,
2737
+ ` <div class="relative min-h-0 flex-1">`,
2738
+ // ChatContainer IS the scroll container (it carries overflow-y-auto and the
2739
+ // stick-to-bottom ref), so the wrapper above must not also scroll.
2740
+ ` <ChatContainer class="h-full">`,
2741
+ ` <ChatContainerContent class="px-5 pt-4 pb-12">`,
2742
+ ` {/* Keyed by message id (see messageKeys), so a delta updates the row`,
2743
+ ` instead of replacing it. */}`,
2744
+ ` <For each={messageKeys()}>`,
2745
+ ` {(_id, i) => (`,
2746
+ ` // The row reads its message through <For>'s index accessor, never`,
2747
+ ` // through a captured value: it outlives the delta that replaced its`,
2748
+ ` // object, so every read below has to go through m(). <Show> supplies`,
2749
+ ` // the non-null accessor and covers the frame where a removal has`,
2750
+ ` // shortened the array.`,
2751
+ ` <Show when={messages()[i()]}>`,
2752
+ ` {(m) => (`,
2753
+ ` <Message class={\`mx-auto flex w-full max-w-3xl flex-col gap-2 px-6 \${m().role === 'user' ? 'items-end' : 'items-start'}\`}>`,
2754
+ ` {/* <Index>, not <For>, INSIDE a message. The state folds only`,
2755
+ ` ever append a part or patch one in place — they never`,
2756
+ ` reorder — so a part's POSITION is a stable identity, and`,
2757
+ ` <Index> hands each row its part as a SIGNAL so the row stays`,
2758
+ ` mounted while its content grows. (Position is not a valid`,
2759
+ ` key for the message list above it, which the host splices.) */}`,
2760
+ ` {/* renderPart also takes the part's INDEX and the array it came`,
2761
+ ` from: consecutive source/file parts render as one row, so a`,
2762
+ ` branch has to be able to see its neighbours. \`() => m().parts\``,
2763
+ ` stays an accessor for the same reason \`part\` does. */}`,
2764
+ ` <Index each={m().parts}>`,
2765
+ ` {(part, pi) => renderPart(part, pi, () => m().parts, m().role)}`,
2766
+ ` </Index>`,
2767
+ ` </Message>`,
2768
+ ` )}`,
2769
+ ` </Show>`,
2770
+ ` )}`,
2771
+ ` </For>`,
2772
+ ...(hasSources
2773
+ ? [
2774
+ ` {/* Replace sampleSources with your real data. */}`,
2775
+ ` <SourceList class="mx-auto w-full max-w-3xl px-6 pt-2">`,
2776
+ ` <For each={sampleSources}>`,
2777
+ ` {(s) => (`,
2778
+ ` <Source href={s.href}>`,
2779
+ ` <SourceTrigger showFavicon />`,
2780
+ ` <SourceContent title={s.title} description={s.description} />`,
2781
+ ` </Source>`,
2782
+ ` )}`,
2783
+ ` </For>`,
2784
+ ` </SourceList>`,
2785
+ ]
2786
+ : []),
2787
+ ` <ChatContainerScrollAnchor />`,
2788
+ ` </ChatContainerContent>`,
2789
+ ` <div class="absolute bottom-4 left-1/2 flex w-full max-w-3xl -translate-x-1/2 justify-center px-5">`,
2790
+ ` <ScrollButton />`,
2791
+ ` </div>`,
2792
+ ` </ChatContainer>`,
2793
+ ` </div>`,
2794
+ ``,
2795
+ ` <div class="shrink-0 px-3 pb-3 md:px-5 md:pb-5">`,
2796
+ ` <div class="mx-auto max-w-3xl">`,
2797
+ ` {/* Starter prompts, shown only while the thread is empty. */}`,
2798
+ ` <Show when={messages().length === 0}>`,
2799
+ ` <div class="flex flex-wrap gap-2 pb-3">`,
2800
+ ` <For each={suggestions}>`,
2801
+ ` {(s) => <PromptSuggestion onClick={() => { setInput(s); void onSubmit(); }}>{s}</PromptSuggestion>}`,
2802
+ ` </For>`,
2803
+ ` </div>`,
2804
+ ` </Show>`,
2805
+ ` <PromptInput value={input()} onValueChange={setInput} onSubmit={onSubmit} isLoading={loading()}>`,
2806
+ ` <div class="flex flex-col">`,
2807
+ ` <PromptInputTextarea placeholder="Send a message…" class="min-h-[44px] pt-3 pl-4" />`,
2808
+ ` <PromptInputActions class="mt-2 flex w-full items-center justify-end gap-2 px-3 pb-3">`,
2809
+ ...(hasVoice
2810
+ ? [
2811
+ ` {/* hasTranscribe={false} uses the browser's native SpeechRecognition.`,
2812
+ ` Point onTranscribe at your speech-to-text endpoint to record + upload instead. */}`,
2813
+ ` <VoiceInput`,
2814
+ ` hasTranscribe={false}`,
2815
+ ` onTranscribe={async (audio) => {`,
2816
+ ` const res = await fetch('/api/transcribe', { method: 'POST', body: audio });`,
2817
+ ` const data = (await res.json()) as { text: string };`,
2818
+ ` return data.text;`,
2819
+ ` }}`,
2820
+ ` onTranscription={(text) => setInput(text)}`,
2821
+ ` />`,
2822
+ ]
2823
+ : []),
2824
+ ` <Button size="sm" class="rounded-full" disabled={!input().trim() || loading()} onClick={onSubmit}>`,
2825
+ ` Send`,
2826
+ ` </Button>`,
2827
+ ` </PromptInputActions>`,
2828
+ ` </div>`,
2829
+ ` </PromptInput>`,
2830
+ ` </div>`,
2831
+ ` </div>`,
2832
+ `</div>`,
2833
+ ].map((l) => (l === '' ? l : `${pad}${l}`));
2834
+
2835
+ const tree = workspace
2836
+ ? [
2837
+ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
2838
+ ` <ResizablePanelGroup orientation="horizontal" class="h-full w-full">`,
2839
+ ` <ResizablePanel defaultSize={40} minSize="240px">`,
2840
+ ...surface(' ', `'height': '100%', 'min-height': '0'`),
2841
+ ` </ResizablePanel>`,
2842
+ ` <ResizableHandle handle="grip" />`,
2843
+ ` <ResizablePanel minSize="280px">`,
2844
+ ` {/* Replace src + files with your real artifact data. */}`,
2845
+ ` <Artifact`,
2846
+ ` src="https://example.com"`,
2847
+ ` files={[{ path: 'index.html', url: 'https://example.com' }]}`,
2848
+ ` class="h-full w-full"`,
2849
+ ` />`,
2850
+ ` </ResizablePanel>`,
2851
+ ` </ResizablePanelGroup>`,
2852
+ ]
2853
+ : surface(' ', solidStyle(p.chatFill));
2854
+
2855
+ return [
2856
+ `// SolidJS + Vite — save as: src/App.tsx`,
2857
+ `//`,
2858
+ `// This target does NOT use the <kai-*> custom elements, and that is deliberate:`,
2859
+ `// the kit is AUTHORED in SolidJS, so a Solid app renders the real components`,
2860
+ `// with real props and real fine-grained reactivity. Going through the`,
2861
+ `// web-component facade would ship the Solid runtime twice and cross a reactive`,
2862
+ `// boundary for nothing.`,
2863
+ `//`,
2864
+ `// SETUP, once. These components are Tailwind-v4 SOURCE (not shadow-encapsulated),`,
2865
+ `// so their class names have to reach YOUR stylesheet:`,
2866
+ `// npm i -D tailwindcss @tailwindcss/vite vite-plugin-solid`,
2867
+ `// vite.config.ts -> plugins: [solid(), tailwindcss()]`,
2868
+ `// src/styles.css -> @import "tailwindcss";`,
2869
+ `// @import "@kitn.ai/ui/theme.css"; /* --color-* tokens */`,
2870
+ `// @source "../node_modules/@kitn.ai/ui"; /* scan the kit for classes */`,
2871
+ `// The @source line is NOT optional: without it Tailwind scans only src/, strips`,
2872
+ `// every kit utility class as unused, and the whole UI renders unstyled.`,
2873
+ `// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
2874
+ `import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
2875
+ `import {`,
2876
+ ...componentImports.map((n) => ` ${n},`),
2877
+ `} from '@kitn.ai/ui';`,
2878
+ `// The kit's own types, from the same entry the components come from.`,
2879
+ `import type { ChatMessage, MessagePart, MessageSource } from '@kitn.ai/ui';`,
2880
+ ...wireImportLines({
2881
+ typed: false,
2882
+ toolLoop: emitToolLoop,
2883
+ cards: ctx.emitCards,
2884
+ cardTools: ctx.cardProvider !== null,
2885
+ mock: isMock,
2886
+ }),
2887
+ ``,
2888
+ `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
2889
+ ...(p.altNote ?? []).map((l) => `// ${l}`),
2890
+ ``,
2891
+ ...(isMock ? [...mockResponderInit(), ``] : []),
2892
+ ...cardsInit,
2893
+ `// Narrow a part to ONE variant, or false. One read, one cast — and the JSX`,
2894
+ `// below re-runs it on every delta, which is what keeps a growing text or`,
2895
+ `// reasoning block updating while its row stays put.`,
2896
+ `function partAs<T extends MessagePart['type']>(`,
2897
+ ` part: MessagePart,`,
2898
+ ` type: T,`,
2899
+ `): Extract<MessagePart, { type: T }> | false {`,
2900
+ ` return part.type === type ? (part as Extract<MessagePart, { type: T }>) : false;`,
2901
+ `}`,
2902
+ ``,
2903
+ `// Two part kinds render as a RUN rather than one row each: the N citations one`,
2904
+ `// search produced are ONE wrapped row, and consecutive attachments share one`,
2905
+ `// attachment row. This returns the whole run when \`index\` is where it STARTS`,
2906
+ `// and false at every later member of it, so a run renders exactly once and`,
2907
+ `// stays exactly where it sat in \`parts\`.`,
2908
+ `function runAt<T extends MessagePart['type']>(`,
2909
+ ` parts: MessagePart[],`,
2910
+ ` index: number,`,
2911
+ ` type: T,`,
2912
+ `): Extract<MessagePart, { type: T }>[] | false {`,
2913
+ ` if (index > 0 && parts[index - 1].type === type) return false;`,
2914
+ ` const run: Extract<MessagePart, { type: T }>[] = [];`,
2915
+ ` for (let i = index; i < parts.length && parts[i].type === type; i += 1) {`,
2916
+ ` run.push(parts[i] as Extract<MessagePart, { type: T }>);`,
2917
+ ` }`,
2918
+ ` return run.length > 0 && run;`,
2919
+ `}`,
2920
+ ``,
2921
+ `// The citation chip's label: the model's own number when it numbered its`,
2922
+ `// citations, otherwise nothing so SourceTrigger falls back to the domain. A`,
2923
+ `// source with no url has no domain to fall back to, so its title stands in`,
2924
+ `// rather than rendering an empty chip.`,
2925
+ `function citationLabel(s: MessageSource): string | number | undefined {`,
2926
+ ` if (s.index !== undefined) return s.index;`,
2927
+ ` if (s.url) return undefined;`,
2928
+ ` return s.title || 'Source';`,
2929
+ `}`,
2930
+ ``,
2931
+ `// EVERY variant of MessagePart, in thread order: text, reasoning, tool, card,`,
2932
+ `// source, file. <kai-chat> owns that switch internally for the element-based`,
2933
+ `// targets; composing from the SolidJS layer moves it here, so the thread`,
2934
+ `// renders exactly what this function renders and a branch that is missing is a`,
2935
+ `// part that arrives in the data and renders NOTHING. The compiler cannot help`,
2936
+ `// — a <Switch> with fewer <Match>es is valid code — so the kit's`,
2937
+ `// verify-scaffold-compiles gate derives the variant list from the MessagePart`,
2938
+ `// union and fails when one has no branch here.`,
2939
+ `//`,
2940
+ `// Branch for branch this is what the kit's own message renderer does (the one`,
2941
+ `// behind <kai-chat>), runs included: consecutive \`source\` parts collapse into`,
2942
+ `// ONE citation row and consecutive \`file\` parts into one attachment row, and`,
2943
+ `// the citation row is a SIBLING of the text bubble rather than a child of it.`,
2944
+ `// The one thing deliberately NOT copied over is the bubble's own padding and`,
2945
+ `// radius — styling, and yours to change.`,
2946
+ ...(ctx.emitCards
2947
+ ? [
2948
+ `// The card branch DOES carry the registry through: \`types\` is the`,
2949
+ `// Solid-component half of <kai-chat>'s \`cardTypes\` (a component, not a tag`,
2950
+ `// name) and \`schemas\` is its \`cardSchemas\`.`,
2951
+ ]
2952
+ : []),
2953
+ `//`,
2954
+ `// \`part\` is an ACCESSOR, not a value. The <Index> that calls this keeps each`,
2955
+ `// row MOUNTED while the message streams, so the row outlives the delta that`,
2956
+ `// replaced its part object: every read has to go through part() for the new`,
2957
+ `// content to reach the DOM. Capturing part() once freezes the row at its first`,
2958
+ `// delta — a panel that stays open but stops updating. \`index\` and \`parts\` are`,
2959
+ `// what let the source/file branches see their neighbours.`,
2960
+ `function renderPart(`,
2961
+ ` part: () => MessagePart,`,
2962
+ ` index: number,`,
2963
+ ` parts: () => MessagePart[],`,
2964
+ ` role: ChatMessage['role'],`,
2965
+ `) {`,
2966
+ ` return (`,
2967
+ ` // Nothing reaches this fallback today: MessagePart is a closed union and`,
2968
+ ` // every variant of it has a branch below. It is where a variant ADDED to`,
2969
+ ` // the union after this file was generated would land.`,
2970
+ ` <Switch fallback={null}>`,
2971
+ ` <Match when={partAs(part(), 'text')}>`,
2972
+ ` {(p) =>`,
2973
+ ` role === 'user' ? (`,
2974
+ ` <MessageContent class="bg-muted text-primary max-w-[85%] rounded-3xl px-5 py-2.5">`,
2975
+ ` {p().text}`,
2976
+ ` </MessageContent>`,
2977
+ ` ) : (`,
2978
+ ` <MessageContent markdown class="text-foreground prose flex-1 rounded-lg bg-transparent p-0">`,
2979
+ ` {p().text}`,
2980
+ ` </MessageContent>`,
2981
+ ` )`,
2982
+ ` }`,
2983
+ ` </Match>`,
2984
+ ` <Match when={partAs(part(), 'reasoning')}>`,
2985
+ ` {(p) => (`,
2986
+ ` // A reasoning part with NO text is a round-trip carrier, not something`,
2987
+ ` // to show: Anthropic's redacted_thinking blocks carry an opaque blob`,
2988
+ ` // with no readable text, and the encoder needs them kept in \`parts\`,`,
2989
+ ` // in order. Render one and you get a blank disclosure.`,
2990
+ ` <Show when={p().text !== ''}>`,
2991
+ ` <Reasoning class="w-full">`,
2992
+ ` <ReasoningTrigger>{p().label ?? 'Reasoning'}</ReasoningTrigger>`,
2993
+ ` <ReasoningContent markdown>{p().text}</ReasoningContent>`,
2994
+ ` </Reasoning>`,
2995
+ ` </Show>`,
2996
+ ` )}`,
2997
+ ` </Match>`,
2998
+ ` <Match when={partAs(part(), 'tool')}>`,
2999
+ ` {(p) => <Tool toolPart={p().tool} />}`,
3000
+ ` </Match>`,
3001
+ ` <Match when={partAs(part(), 'card')}>`,
3002
+ ` {(p) => (`,
3003
+ ` // Generative-UI cards. An envelope whose type is not registered gets`,
3004
+ ` // the built-in fallback card, so an unknown card is visible rather`,
3005
+ ` // than absent; register your own components on the card registry.`,
3006
+ ...(ctx.emitCards
3007
+ ? [
3008
+ ` <CardRenderer`,
3009
+ ` envelope={p().envelope}`,
3010
+ ...cardRendererProps,
3011
+ ` />`,
3012
+ ]
3013
+ : [` <CardRenderer envelope={p().envelope} />`]),
3014
+ ` )}`,
3015
+ ` </Match>`,
3016
+ ` <Match when={partAs(part(), 'source')}>`,
3017
+ ` {/* ONE citation row per RUN, and a SIBLING of the bubble rather than a`,
3018
+ ` child of it: a citation rendered inside the prose is indistinguishable`,
3019
+ ` from a link the model typed itself. part="citations" is the same`,
3020
+ ` ::part name the kai-chat element exposes, so a stylesheet written`,
3021
+ ` against one target works against the other. */}`,
3022
+ ` <Show when={runAt(parts(), index, 'source')}>`,
3023
+ ` {(run) => (`,
3024
+ ` <SourceList part="citations" class={role === 'user' ? 'justify-end' : undefined}>`,
3025
+ ` {/* Reference-keyed <For> is right HERE: the run's part objects are`,
3026
+ ` carried over untouched by the state folds. */}`,
3027
+ ` <For each={run()}>`,
3028
+ ` {(sp) => (`,
3029
+ ` <Source href={sp.source.url}>`,
3030
+ ` <SourceTrigger label={citationLabel(sp.source)} />`,
3031
+ ` <SourceContent`,
3032
+ ` title={sp.source.title ?? sp.source.url ?? 'Source'}`,
3033
+ ` description={sp.source.snippet ?? ''}`,
3034
+ ` />`,
3035
+ ` </Source>`,
3036
+ ` )}`,
3037
+ ` </For>`,
3038
+ ` </SourceList>`,
3039
+ ` )}`,
3040
+ ` </Show>`,
3041
+ ` </Match>`,
3042
+ ` <Match when={partAs(part(), 'file')}>`,
3043
+ ` {/* Same run-collapse as citations: consecutive attachments share one`,
3044
+ ` row instead of each opening its own. */}`,
3045
+ ` <Show when={runAt(parts(), index, 'file')}>`,
3046
+ ` {(run) => (`,
3047
+ ` <Attachments variant="inline" class={role === 'user' ? 'mb-2 justify-end' : 'mb-2'}>`,
3048
+ ` <For each={run()}>`,
3049
+ ` {(fp) => (`,
3050
+ ` <Attachment data={fp.attachment}>`,
3051
+ ` <AttachmentPreview />`,
3052
+ ` <AttachmentInfo />`,
3053
+ ` </Attachment>`,
3054
+ ` )}`,
3055
+ ` </For>`,
3056
+ ` </Attachments>`,
3057
+ ` )}`,
3058
+ ` </Show>`,
3059
+ ` </Match>`,
3060
+ ` </Switch>`,
3061
+ ` );`,
3062
+ `}`,
3063
+ ``,
3064
+ `export default function App() {`,
3065
+ ` // Each write assigns a NEW array, and createAssistantStream rebuilds the`,
3066
+ ` // streaming message as a NEW OBJECT on every delta: a new reference IS the`,
3067
+ ` // re-render signal. Which is exactly why the thread below is keyed by message`,
3068
+ ` // id rather than by the message objects — see messageKeys.`,
3069
+ ` const [messages, setMessages] = createSignal<ChatMessage[]>([]);`,
3070
+ ` const [loading, setLoading] = createSignal(false);`,
3071
+ ` // PromptInput is CONTROLLED here, so this signal — not a kai-submit event — is`,
3072
+ ` // where the submitted text comes from.`,
3073
+ ` const [input, setInput] = createSignal('');`,
3074
+ ` // THE MESSAGE LIST'S KEY. <For> is REFERENCE-keyed, so keying it on the`,
3075
+ ` // message objects makes every streaming delta look like an entirely new list`,
3076
+ ` // and tears the whole row down — taking with it everything the user did`,
3077
+ ` // inside it (expanding a tool or reasoning panel mid-stream would silently do`,
3078
+ ` // nothing: the disclosure opens and the next token discards it). Ids are`,
3079
+ ` // stable across the object churn, so <For> over this array diffs by string`,
3080
+ ` // value, no row moves, and the content updates through accessors instead.`,
3081
+ ` const messageKeys = createMemo(() => messages().map((m) => m.id));`,
3082
+ ` const suggestions = ${jsArray(suggestions)};`,
3083
+ ...sourcesInit,
3084
+ ...modelInit,
3085
+ ...toolsLines,
3086
+ ...runnerLines,
3087
+ ``,
3088
+ ` async function onSubmit() {`,
3089
+ onSubmitBody,
3090
+ ` }`,
3091
+ ``,
3092
+ ` return (`,
3093
+ ` // ChatConfig carries prose size / code theme / portal target to every`,
3094
+ ` // component below it. Mount it once, at the top.`,
3095
+ ` <ChatConfig>`,
3096
+ ` <div style={{ ${solidStyle(p.style)} }}>`,
3097
+ ...tree,
3098
+ ` </div>`,
3099
+ ` </ChatConfig>`,
1352
3100
  ` );`,
1353
3101
  `}`,
1354
- ]
1355
- .filter((l) => l !== '')
1356
- .join('\n');
3102
+ ].join('\n');
3103
+ }
3104
+
3105
+ /**
3106
+ * Translate an inline CSS string into a SOLID style-object entry list.
3107
+ *
3108
+ * Not `jsxStyle`. Solid's `style` prop is typed from csstype's HYPHENATED
3109
+ * property set (`'flex-direction'`) and applied with `style.setProperty(key,
3110
+ * value)`; React's is camelCased (`flexDirection`). Handing Solid React's shape
3111
+ * is a TS2561 on every cell AND, if it ever got past the compiler, a declaration
3112
+ * that silently does nothing at runtime. Keys are quoted because
3113
+ * `flex-direction` is not a bare identifier.
3114
+ */
3115
+ function solidStyle(style: string): string {
3116
+ return style
3117
+ .split(';')
3118
+ .map((d) => d.trim())
3119
+ .filter(Boolean)
3120
+ .map((d) => {
3121
+ const [prop, ...rest] = d.split(':');
3122
+ return `'${prop.trim()}': '${rest.join(':').trim()}'`;
3123
+ })
3124
+ .join(', ');
1357
3125
  }
1358
3126
 
1359
3127
  /** Translate an inline CSS string into JSX style-object entries. */
@@ -1370,31 +3138,127 @@ function jsxStyle(style: string): string {
1370
3138
  .join(', ');
1371
3139
  }
1372
3140
 
1373
- function renderFrontend(
1374
- framework: string,
1375
- archetype: Archetype,
1376
- placement: string,
1377
- emptyHint: string,
1378
- suggestions: string[],
1379
- isMock: boolean,
1380
- defaultModel?: string,
1381
- ): string {
1382
- const ctx: RenderCtx = { p: placementStyle(placement), emptyHint, suggestions, isMock, defaultModel };
3141
+ /**
3142
+ * A human label for a components list, for the emitted banner comment.
3143
+ *
3144
+ * Derived, never passed in — see `RenderCtx.label`. `kai-chat` is dropped because
3145
+ * every surface has it, so naming it in every banner says nothing; a surface that
3146
+ * is ONLY the chat says so.
3147
+ */
3148
+ function surfaceLabel(components: readonly string[]): string {
3149
+ const rest = components.filter((c) => c !== 'kai-chat');
3150
+ if (rest.length === 0) return 'chat';
3151
+ return `chat + ${rest.map((c) => c.replace(/^kai-/, '')).join(' + ')}`;
3152
+ }
3153
+
3154
+ /** What `renderSurface` needs to emit one chat surface. */
3155
+ export interface SurfaceRequest {
3156
+ /** html | react | next | vue | svelte | angular | solid | tanstack-start */
3157
+ framework: string;
3158
+ /**
3159
+ * The `kai-*` components this surface composes — THE AXIS, not an archetype id.
3160
+ *
3161
+ * A list rather than a preset name is the whole point of this function. The six
3162
+ * archetypes are six points in this space and the CLI's feature multi-select
3163
+ * reaches the rest of it, so a renderer keyed on a preset id could only ever
3164
+ * emit what someone had already thought to name. `kai-chat` is expected to be
3165
+ * present; everything else is a capability.
3166
+ */
3167
+ components: readonly string[];
3168
+ /** The backend this surface talks to. `mock` takes the no-backend branch. */
3169
+ integration: Integration;
3170
+ /** full-page | side | docked-widget | inline. Defaults to full-page. */
3171
+ placement?: string;
3172
+ /** Starter prompts for the empty thread. Defaults to `DEFAULT_SUGGESTIONS`. */
3173
+ suggestions?: string[];
3174
+ /** Audience hint — tweaks the empty-state comment only. */
3175
+ audience?: string;
3176
+ }
3177
+
3178
+ /**
3179
+ * THE ONE RENDERER. Emits the front-end block for a chat surface.
3180
+ *
3181
+ * Shared by the `kai` MCP (via `compose`, which wraps it in the backend route and
3182
+ * the run note) and, by design, by `create-kai`. There is deliberately no second
3183
+ * entry point keyed on an archetype: `compose` resolves a preset to its
3184
+ * `components` and calls this, so a preset is DATA over this function rather than
3185
+ * a parallel path through it. `assertPresetsAreData` in
3186
+ * scripts/verify-scaffold-compiles.mjs holds that open by requiring the two
3187
+ * request shapes to emit byte-identical surfaces.
3188
+ *
3189
+ * It takes the `integration` rather than the six booleans it derives from it,
3190
+ * because those derivations ARE the contract between the surface and the wire —
3191
+ * whether a tools array is forwarded, whether there is a backend to POST a second
3192
+ * round to, which envelope a card tool takes — and a caller that computed them
3193
+ * itself would be free to compute them differently. `create-kai` gets them right
3194
+ * by not being asked.
3195
+ */
3196
+ export function renderSurface(req: SurfaceRequest): string {
3197
+ const { framework, components, integration } = req;
3198
+ const placement = req.placement || 'full-page';
3199
+ const suggestions = req.suggestions?.length ? req.suggestions : DEFAULT_SUGGESTIONS;
3200
+ const emptyHint = req.audience
3201
+ ? `tuned for ${req.audience} — keep the empty state and tone audience-appropriate`
3202
+ : 'add an empty-state prompt that fits your product';
3203
+
3204
+ const isMock = integration.id === 'mock';
3205
+ // SCAF-8: compute the default model only for non-mock integrations that forward model.
3206
+ const defaultModel = isMock ? undefined : defaultModelFor(integration);
3207
+ const emitTools = !isMock && emitsToolSchemas(components, integration);
3208
+ // The loop needs a backend to POST the second round to; `mock` has none.
3209
+ // It does NOT require `emitTools`: an integration that builds its tools
3210
+ // server-side (langgraph, mastra, pi) still streams tool calls back, and the
3211
+ // loop that answers them is the same loop.
3212
+ const emitToolLoop = !isMock && hasToolPanel(components);
3213
+ // Cards need a MODEL to ask for one, so `mock` (which streams a canned reply in
3214
+ // the browser and never sees a tool call) is out for the same reason the loop is.
3215
+ const emitCards = !isMock && bearsCards(components);
3216
+ // Only shape card tools when the array is really forwarded AND we know which
3217
+ // envelope this route's provider takes. Either half missing means the registry
3218
+ // is still wired to the client (cardTypes/cardSchemas) while the model is not
3219
+ // offered a card tool — which is the honest state for langgraph/mastra/pi, whose
3220
+ // routes own their tool list server-side.
3221
+ // `assertCardToolFormat`, not `cardToolProviderFor`: reaching here with both
3222
+ // flags true means a tools array IS going into the request body, so an
3223
+ // undeclared envelope is a scaffold that silently ships no card tool. Fail
3224
+ // loudly instead. (`cardEmitPlan` keeps returning null for the same case on
3225
+ // purpose — it is a planning function the guards read, and its job is to let
3226
+ // them report the gap in their own words rather than blow up first.)
3227
+ const cardProvider = emitCards && emitTools ? assertCardToolFormat(integration) : null;
3228
+
3229
+ const ctx: RenderCtx = {
3230
+ p: placementStyle(placement),
3231
+ emptyHint,
3232
+ suggestions,
3233
+ label: surfaceLabel(components),
3234
+ isMock,
3235
+ defaultModel,
3236
+ emitTools,
3237
+ emitToolLoop,
3238
+ emitCards,
3239
+ cardProvider,
3240
+ };
1383
3241
  switch (framework) {
1384
3242
  case 'react':
1385
3243
  case 'next':
1386
- return renderJsx(archetype, ctx, framework);
3244
+ return renderJsx(components, ctx, framework);
1387
3245
  case 'vue':
1388
- return renderVue(archetype, ctx);
3246
+ return renderVue(components, ctx);
1389
3247
  case 'svelte':
1390
- return renderSvelte(archetype, ctx);
3248
+ return renderSvelte(components, ctx);
3249
+ case 'angular':
3250
+ return renderAngular(components, ctx);
3251
+ case 'solid':
3252
+ return renderSolid(components, ctx);
1391
3253
  case 'tanstack-start':
1392
- return renderTanstackStart(archetype, ctx);
3254
+ return renderTanstackStart(components, ctx);
1393
3255
  case 'html':
1394
3256
  default:
1395
3257
  // html, and any backend-only framework (fastapi/express/worker) gets the
1396
- // framework-agnostic web-components surface.
1397
- return renderHtml(archetype, ctx);
3258
+ // framework-agnostic web-components surface. The Vite/tsc setup note
3259
+ // (SCAF-19) only applies to the actual `html` target — the backend-only
3260
+ // frameworks aren't paired with a `tsc && vite build` script.
3261
+ return renderHtml(components, ctx, framework === 'html');
1398
3262
  }
1399
3263
  }
1400
3264
 
@@ -1413,9 +3277,489 @@ const RUNTIME_LABEL: Record<string, string> = {
1413
3277
  worker: 'Cloudflare Worker',
1414
3278
  fastapi: 'FastAPI (Python)',
1415
3279
  html: 'browser-direct (no server route)',
1416
- 'tanstack-start': 'TanStack Start server function (Node)',
3280
+ 'tanstack-start': 'TanStack Start server route',
3281
+ angular: 'Angular SSR server (Express, src/server.ts)',
3282
+ solid: 'Vite dev-server middleware (Node)',
3283
+ };
3284
+
3285
+ /** How the emitted framework reads in a warning: "will NOT run in ___". */
3286
+ const FRAMEWORK_LABEL: Record<string, string> = {
3287
+ html: 'a static HTML page',
3288
+ react: 'a Vite React SPA',
3289
+ next: 'a Next.js app',
3290
+ vue: 'a Vite Vue SPA',
3291
+ svelte: 'a SvelteKit app',
3292
+ angular: 'an Angular app',
3293
+ solid: 'a Vite SolidJS SPA',
3294
+ 'tanstack-start': 'a TanStack Start app',
3295
+ express: 'an Express server',
3296
+ worker: 'a Cloudflare Worker',
3297
+ fastapi: 'a FastAPI service',
3298
+ };
3299
+
3300
+ /**
3301
+ * Why the emitted route cannot be dropped into THIS framework, one line each.
3302
+ *
3303
+ * The warning used to be gated on `framework === 'react'`, so the two targets
3304
+ * with the worst failure modes got nothing: svelte compiled a Next.js handler
3305
+ * and threw `req.json is not a function` on the first submit, and html was
3306
+ * handed a server snippet with no server anywhere to put it in.
3307
+ */
3308
+ const CANNOT_HOST_NOTE: Record<string, string> = {
3309
+ html: 'a static page has no server at all — run this route on a separate server (framework: "express" | "worker" | "next") and either proxy /api/chat to it or point the fetch at its absolute URL (CORS applies).',
3310
+ react: 'a Vite React SPA has no /api routes — add a dev-server middleware (Vite: server.middlewares in a plugin), proxy /api/chat to a separate server, or use framework: "next" | "tanstack-start".',
3311
+ vue: 'a Vite Vue SPA has no /api routes — add a dev-server middleware (Vite: server.middlewares in a plugin), proxy /api/chat to a separate server, or use framework: "next".',
3312
+ svelte: 'SvelteKit routes live at src/routes/api/chat/+server.ts and are called as POST(event) — `request` is a FIELD on that event, so a handler written to take a bare Request typechecks here and then throws "req.json is not a function" on the first submit.',
3313
+ angular: "Angular's server route lives in src/server.ts — the Express app `ng add @angular/ssr` generates — and has to be registered BEFORE the Angular catch-all `app.use(...)` that renders the app, or the renderer answers /api/chat with HTML. A file exporting POST is never called.",
3314
+ solid: 'a Vite SolidJS SPA has no /api routes — add a dev-server middleware (Vite: server.middlewares in a plugin), proxy /api/chat to a separate server, or run the handler on a real server (framework: "express" | "worker" | "next").',
3315
+ 'tanstack-start': "TanStack Start routes the FILE: it needs createFileRoute('/api/chat')({ server: { handlers: { POST } } }) in src/routes/api/chat.ts. A bare `export async function POST` is never called.",
3316
+ next: 'Next.js needs the handler exported as POST from app/api/chat/route.ts.',
3317
+ express: 'Express hands the handler (req, res) — it does not take a Request or return a Response, so the code needs bridging.',
3318
+ worker: 'a Worker exports `default { fetch(request) }` and reads secrets off `env`, not process.env.',
3319
+ fastapi: 'FastAPI is Python — this route is TypeScript.',
3320
+ };
3321
+
3322
+ // ── the portable route: one web-standard handler, wrapped per framework ───────
3323
+
3324
+ /**
3325
+ * How a framework DECLARES a route around the portable `chatHandler`.
3326
+ *
3327
+ * The body of a chat route is the same everywhere: read the request JSON, call
3328
+ * the provider, stream the response back. Only the declaration differs. That is
3329
+ * the whole reason every non-next framework used to be handed a Next.js
3330
+ * handler — the catalogs only ever filled in `next` and the rest fell through
3331
+ * to it.
3332
+ *
3333
+ * `before` is emitted above the integration's fragment (framework imports),
3334
+ * `after` below it (the declaration that calls `chatHandler`). The fragment
3335
+ * itself may open with its own imports; they all land at the top of the file.
3336
+ */
3337
+ interface WebRouteAdapter {
3338
+ runtime: string;
3339
+ /** Path comment that opens the block, so the code has somewhere to go. */
3340
+ file: string;
3341
+ before?: string[];
3342
+ after: string[];
3343
+ /**
3344
+ * Rewrite the integration's portable handler into this framework's own idioms,
3345
+ * returning the new fragment plus any imports the rewrite needs.
3346
+ *
3347
+ * The portable fragments are written for a Node-shaped host, and one of them
3348
+ * does not port: `process.env`. Only SvelteKit uses this so far — see
3349
+ * `svelteEnvAccess`.
3350
+ */
3351
+ adaptFragment?: (fragment: string) => { fragment: string; imports: string[] };
3352
+ }
3353
+
3354
+ /**
3355
+ * `process.env.X` -> SvelteKit's own `env.X`.
3356
+ *
3357
+ * A fresh `sv create` app installs no `@types/node`, so `process` is not a name
3358
+ * that exists: every emitted route reading a key failed `svelte-check` with
3359
+ * TS2580 on the first run. `$env/dynamic/private` is Kit's own accessor, is
3360
+ * typed by the `.svelte-kit/ambient.d.ts` its own `sync` step generates, and is
3361
+ * the form its docs prescribe — so this is the idiomatic fix, not a workaround
3362
+ * for a missing dependency. It also keeps the key off the client by
3363
+ * construction: importing `$env/dynamic/private` from client code is an error
3364
+ * Kit raises for you.
3365
+ */
3366
+ function svelteEnvAccess(fragment: string): { fragment: string; imports: string[] } {
3367
+ if (!/\bprocess\.env\./.test(fragment)) return { fragment, imports: [] };
3368
+ return {
3369
+ fragment: fragment.replace(/\bprocess\.env\.([A-Za-z_$][\w$]*)/g, 'env.$1'),
3370
+ imports: [
3371
+ `// SvelteKit's own env accessor. \`process.env\` would need @types/node, which`,
3372
+ `// a fresh \`sv create\` app does not install — and this is the idiomatic form:`,
3373
+ `// it is typed by .svelte-kit/ambient.d.ts and cannot be imported client-side.`,
3374
+ `import { env } from '$env/dynamic/private';`,
3375
+ ],
3376
+ };
3377
+ }
3378
+
3379
+ /**
3380
+ * The Vite dev-server middleware, which is what a plain SPA (react/vue) can
3381
+ * actually host. Dev-only on purpose, and it says so: a Vite SPA has no
3382
+ * production server to deploy a route to.
3383
+ */
3384
+ function viteMiddlewareAdapter(plugin: string): WebRouteAdapter {
3385
+ return {
3386
+ runtime: 'Vite dev-server middleware (Node)',
3387
+ // `server/chat.ts`, NOT `src/server/chat.ts`. A create-vite app splits its
3388
+ // tsconfig in two: tsconfig.app.json is `"include": ["src"]` with
3389
+ // `types: ["vite/client"]` and no node types, and tsconfig.node.json covers
3390
+ // vite.config.ts and what it imports. A handler under `src/` is therefore
3391
+ // compiled by the BROWSER project as well, where `process.env` is
3392
+ // TS2591 "Cannot find name 'process'" — measured in a stock react-ts app.
3393
+ // Outside `src/` only the node project claims it, which is the one with the
3394
+ // node types it needs.
3395
+ file: 'server/chat.ts',
3396
+ after: [
3397
+ ``,
3398
+ `// vite.config.ts imports it from here.`,
3399
+ `export { chatHandler };`,
3400
+ ``,
3401
+ `// ── vite-chat-api.ts ─────────────────────────────────────────────────────────`,
3402
+ `// A Vite SPA has no server routes, so fetch('/api/chat') has nothing to answer`,
3403
+ `// it. This plugin mounts the SAME handler on the dev server. DEV ONLY: for`,
3404
+ `// production, deploy the handler to a real server (Next, SvelteKit, a Worker,`,
3405
+ `// Express) or point the fetch at one.`,
3406
+ `import type { Plugin } from 'vite';`,
3407
+ `// The '.js' is REQUIRED and is not a typo: the stock tsconfig.node.json sets`,
3408
+ `// "module": "nodenext", where an extensionless relative import is TS2835. TS`,
3409
+ `// resolves './server/chat.js' to server/chat.ts, and so does Vite.`,
3410
+ `import { chatHandler } from './server/chat.js';`,
3411
+ ``,
3412
+ `export function chatApiPlugin(): Plugin {`,
3413
+ ` return {`,
3414
+ ` name: 'chat-api',`,
3415
+ ` configureServer(server) {`,
3416
+ ` server.middlewares.use('/api/chat', async (req, res) => {`,
3417
+ ` let body = '';`,
3418
+ ` req.setEncoding('utf8');`,
3419
+ ` for await (const chunk of req) body += chunk;`,
3420
+ ``,
3421
+ ` const response = await chatHandler(`,
3422
+ ` new Request('http://localhost/api/chat', {`,
3423
+ ` method: 'POST',`,
3424
+ ` headers: { 'Content-Type': 'application/json' },`,
3425
+ ` body,`,
3426
+ ` }),`,
3427
+ ` );`,
3428
+ ``,
3429
+ ` // The STATUS has to survive the bridge: a 401 from the provider that`,
3430
+ ` // arrives at the browser as a 200 is a blank bubble and no error.`,
3431
+ ` res.statusCode = response.status;`,
3432
+ ` // Annotated: a vite.config / server tsconfig has no DOM lib, so Headers`,
3433
+ ` // comes from @types/node and these params are implicitly \`any\` (TS7006)`,
3434
+ ` // under a stock \`npm create vite\` app's noImplicitAny.`,
3435
+ ` response.headers.forEach((value: string, key: string) => res.setHeader(key, value));`,
3436
+ ` if (!response.body) { res.end(); return; }`,
3437
+ ``,
3438
+ ` // Write each chunk as it lands — buffering here defeats streaming.`,
3439
+ ` const reader = response.body.getReader();`,
3440
+ ` for (;;) {`,
3441
+ ` const { value, done } = await reader.read();`,
3442
+ ` if (done) break;`,
3443
+ ` res.write(value);`,
3444
+ ` }`,
3445
+ ` res.end();`,
3446
+ ` });`,
3447
+ ` },`,
3448
+ ` };`,
3449
+ `}`,
3450
+ ``,
3451
+ `// ── vite.config.ts ───────────────────────────────────────────────────────────`,
3452
+ `// Again '.js', for the same reason: tsconfig.node.json is "module": "nodenext",`,
3453
+ `// where './vite-chat-api' is TS2835 and fails \`npm run build\`.`,
3454
+ `// import { chatApiPlugin } from './vite-chat-api.js';`,
3455
+ `// export default defineConfig({ plugins: [${plugin}, chatApiPlugin()] });`,
3456
+ ],
3457
+ };
3458
+ }
3459
+
3460
+ const WEB_ROUTE_ADAPTERS: Record<string, WebRouteAdapter> = {
3461
+ next: {
3462
+ runtime: 'Next.js route handler (Node/Edge)',
3463
+ file: 'app/api/chat/route.ts',
3464
+ after: [
3465
+ ``,
3466
+ `// Next.js App Router: the file exports the HTTP method.`,
3467
+ `export async function POST(req: Request): Promise<Response> {`,
3468
+ ` return chatHandler(req);`,
3469
+ `}`,
3470
+ ],
3471
+ },
3472
+ svelte: {
3473
+ runtime: 'SvelteKit +server.ts endpoint',
3474
+ file: 'src/routes/api/chat/+server.ts',
3475
+ before: [`import type { RequestHandler } from './$types';`],
3476
+ adaptFragment: svelteEnvAccess,
3477
+ after: [
3478
+ ``,
3479
+ `// SvelteKit calls POST(event), NOT POST(request): \`request\` is a FIELD on the`,
3480
+ `// event. That one line is the whole difference from the Next.js route — a`,
3481
+ `// Next-shaped \`export async function POST(req: Request)\` typechecks here and`,
3482
+ `// then throws "req.json is not a function" on the first submit.`,
3483
+ `export const POST: RequestHandler = ({ request }) => chatHandler(request);`,
3484
+ ],
3485
+ },
3486
+ 'tanstack-start': {
3487
+ runtime: 'TanStack Start server route',
3488
+ file: 'src/routes/api/chat.ts',
3489
+ before: [
3490
+ `import { createFileRoute } from '@tanstack/react-router';`,
3491
+ `// Side-effect import, REQUIRED: it loads the server-route type augmentation`,
3492
+ `// (@tanstack/start-client-core) that adds \`server\` to the route options. With`,
3493
+ `// nothing under src/ importing @tanstack/react-start the augmentation never`,
3494
+ `// loads and the block below fails to typecheck: TS2353 on \`server\`, then`,
3495
+ `// TS7031 on \`request\`.`,
3496
+ `import '@tanstack/react-start';`,
3497
+ ],
3498
+ after: [
3499
+ ``,
3500
+ `// TanStack Start routes the FILE. A bare \`export async function POST\` is never`,
3501
+ `// called — the handler has to hang off the route's \`server.handlers\`.`,
3502
+ `export const Route = createFileRoute('/api/chat')({`,
3503
+ ` server: { handlers: { POST: ({ request }) => chatHandler(request) } },`,
3504
+ `});`,
3505
+ ],
3506
+ },
3507
+ react: viteMiddlewareAdapter('react()'),
3508
+ // NOT a bare `vue()`. This one-liner and block (0) configure the same file, and
3509
+ // a consumer who pastes this over that silently drops `isCustomElement`, at
3510
+ // which point every kai-* tag stops resolving and the page renders empty.
3511
+ vue: viteMiddlewareAdapter('vue({ /* keep the template.compilerOptions from block (0) */ })'),
3512
+ // A Solid app is a Vite SPA, so it hosts the route exactly the way react/vue
3513
+ // do. `tailwindcss()` is in the plugin list because the Solid front end needs
3514
+ // it (the components are Tailwind source) — see renderSolid's setup note.
3515
+ solid: viteMiddlewareAdapter('solid(), tailwindcss()'),
3516
+ /**
3517
+ * Angular DOES have a server, and this is where it lives.
3518
+ *
3519
+ * `ng add @angular/ssr` (or `ng new --ssr`) generates `src/server.ts` as an
3520
+ * Express app, and the Angular CLI loads it through the `reqHandler` export at
3521
+ * the bottom for BOTH `ng serve` and `ng build` — the generated file's own
3522
+ * comment reads "Request handler used by the Angular CLI (for dev-server and
3523
+ * during build)". So an endpoint registered here answers in development and in
3524
+ * production, unlike the react/vue Vite middleware, which is dev-only.
3525
+ *
3526
+ * There is no second option: `@angular/build:dev-server` exposes no middleware
3527
+ * hook and takes no Vite plugins, so an Angular app WITHOUT SSR enabled cannot
3528
+ * host `/api/chat` at all — it has to proxy to a separate server.
3529
+ *
3530
+ * The whole file is emitted rather than a fragment because placement is the
3531
+ * part that goes wrong: the route must be registered BEFORE the catch-all
3532
+ * `app.use` that renders the Angular app, or the renderer answers /api/chat
3533
+ * with an HTML page and the front end tries to parse it as SSE.
3534
+ */
3535
+ angular: {
3536
+ runtime: 'Angular SSR server (Express, src/server.ts)',
3537
+ file: 'src/server.ts',
3538
+ before: [
3539
+ `// This is the file \`ng add @angular/ssr\` generates (\`ng new --ssr\` too), with`,
3540
+ `// the chat endpoint added. The Angular CLI loads it via the \`reqHandler\` export`,
3541
+ `// at the bottom for BOTH \`ng serve\` and \`ng build\`, so the route below answers`,
3542
+ `// in development and in production.`,
3543
+ `//`,
3544
+ `// If your app has no src/server.ts, you have no SSR: run \`ng add @angular/ssr\`.`,
3545
+ `// @angular/build's dev server takes no middleware and no Vite plugins, so a`,
3546
+ `// non-SSR Angular app cannot host /api/chat — proxy it to a separate server`,
3547
+ `// instead (framework: "express" | "worker" | "next").`,
3548
+ `import {`,
3549
+ ` AngularNodeAppEngine,`,
3550
+ ` createNodeRequestHandler,`,
3551
+ ` isMainModule,`,
3552
+ ` writeResponseToNodeResponse,`,
3553
+ `} from '@angular/ssr/node';`,
3554
+ `import express from 'express';`,
3555
+ `import { join } from 'node:path';`,
3556
+ ],
3557
+ after: [
3558
+ ``,
3559
+ `const browserDistFolder = join(import.meta.dirname, '../browser');`,
3560
+ ``,
3561
+ `const app = express();`,
3562
+ `const angularApp = new AngularNodeAppEngine();`,
3563
+ ``,
3564
+ `// ORDER MATTERS: this has to come BEFORE the catch-all below, or Angular`,
3565
+ `// renders the app for /api/chat and the browser parses an HTML page as SSE.`,
3566
+ `// express.json() is scoped to this one route on purpose — page requests have`,
3567
+ `// no reason to be body-parsed.`,
3568
+ `app.post('/api/chat', express.json(), async (req, res) => {`,
3569
+ ` const response = await chatHandler(`,
3570
+ ` new Request('http://localhost/api/chat', {`,
3571
+ ` method: 'POST',`,
3572
+ ` headers: { 'Content-Type': 'application/json' },`,
3573
+ ` body: JSON.stringify(req.body),`,
3574
+ ` }),`,
3575
+ ` );`,
3576
+ ``,
3577
+ ` // The STATUS has to survive the bridge: a 401 from the provider that arrives`,
3578
+ ` // at the browser as a 200 is a blank bubble and no error.`,
3579
+ ` res.status(response.status);`,
3580
+ ` // Annotated: a server-side tsconfig has no DOM lib, so Headers comes from`,
3581
+ ` // @types/node and these params are implicitly \`any\` (TS7006) under noImplicitAny.`,
3582
+ ` response.headers.forEach((value: string, key: string) => res.setHeader(key, value));`,
3583
+ ` if (!response.body) { res.end(); return; }`,
3584
+ ``,
3585
+ ` // Write each chunk as it lands — buffering here defeats streaming.`,
3586
+ ` const reader = response.body.getReader();`,
3587
+ ` for (;;) {`,
3588
+ ` const { value, done } = await reader.read();`,
3589
+ ` if (done) break;`,
3590
+ ` res.write(value);`,
3591
+ ` }`,
3592
+ ` res.end();`,
3593
+ `});`,
3594
+ ``,
3595
+ `// Serve the built browser assets.`,
3596
+ `app.use(`,
3597
+ ` express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }),`,
3598
+ `);`,
3599
+ ``,
3600
+ `// Everything else renders the Angular application.`,
3601
+ `app.use((req, res, next) => {`,
3602
+ ` angularApp`,
3603
+ ` .handle(req)`,
3604
+ ` .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))`,
3605
+ ` .catch(next);`,
3606
+ `});`,
3607
+ ``,
3608
+ `if (isMainModule(import.meta.url)) {`,
3609
+ ` const port = process.env['PORT'] || 4000;`,
3610
+ ` app.listen(port, (error) => {`,
3611
+ ` if (error) {`,
3612
+ ` throw error;`,
3613
+ ` }`,
3614
+ ``,
3615
+ ` console.log(\`Node Express server listening on http://localhost:\${port}\`);`,
3616
+ ` });`,
3617
+ `}`,
3618
+ ``,
3619
+ `// The Angular CLI (dev-server and build) picks the app up from here.`,
3620
+ `export const reqHandler = createNodeRequestHandler(app);`,
3621
+ ],
3622
+ },
3623
+ worker: {
3624
+ runtime: 'Cloudflare Worker',
3625
+ file: 'src/index.ts',
3626
+ after: [
3627
+ ``,
3628
+ `// A Worker IS the web standard: fetch(Request) -> Response.`,
3629
+ `export default {`,
3630
+ ` fetch(request: Request): Promise<Response> {`,
3631
+ ` return chatHandler(request);`,
3632
+ ` },`,
3633
+ `};`,
3634
+ `// Secrets live on \`env\`, not process.env: either thread \`env\` through to the`,
3635
+ `// handler, or set compatibility_date "2025-04-01" (or later) with nodejs_compat,`,
3636
+ `// which populates process.env from your bindings.`,
3637
+ ],
3638
+ },
3639
+ express: {
3640
+ runtime: 'Express handler (Node)',
3641
+ file: 'server.ts',
3642
+ before: [`import express from 'express';`],
3643
+ after: [
3644
+ ``,
3645
+ `const app = express();`,
3646
+ `app.use(express.json());`,
3647
+ ``,
3648
+ `// Express is (req, res) — it neither takes a Request nor returns a Response, so`,
3649
+ `// the web handler is bridged here. Node 18+ has Request/Response as globals.`,
3650
+ `app.post('/api/chat', async (req, res) => {`,
3651
+ ` const response = await chatHandler(`,
3652
+ ` new Request('http://localhost/api/chat', {`,
3653
+ ` method: 'POST',`,
3654
+ ` headers: { 'Content-Type': 'application/json' },`,
3655
+ ` body: JSON.stringify(req.body),`,
3656
+ ` }),`,
3657
+ ` );`,
3658
+ ``,
3659
+ ` // The STATUS has to survive the bridge: a 401 from the provider that arrives`,
3660
+ ` // at the browser as a 200 is a blank bubble and no error.`,
3661
+ ` res.status(response.status);`,
3662
+ ` // Annotated: a server-side tsconfig has no DOM lib, so Headers comes from`,
3663
+ ` // @types/node and these params are implicitly \`any\` (TS7006) under noImplicitAny.`,
3664
+ ` response.headers.forEach((value: string, key: string) => res.setHeader(key, value));`,
3665
+ ` if (!response.body) { res.end(); return; }`,
3666
+ ``,
3667
+ ` // Write each chunk as it lands — buffering here defeats streaming.`,
3668
+ ` const reader = response.body.getReader();`,
3669
+ ` for (;;) {`,
3670
+ ` const { value, done } = await reader.read();`,
3671
+ ` if (done) break;`,
3672
+ ` res.write(value);`,
3673
+ ` }`,
3674
+ ` res.end();`,
3675
+ `});`,
3676
+ ``,
3677
+ `app.listen(3001, () => console.log('chat api: http://localhost:3001/api/chat'));`,
3678
+ `// The front end fetches a RELATIVE /api/chat, so proxy that path to this port`,
3679
+ `// from your dev server, or serve both from one origin.`,
3680
+ ],
3681
+ },
1417
3682
  };
1418
3683
 
3684
+ /**
3685
+ * The request body, declared once per route file.
3686
+ *
3687
+ * `await request.json()` is `unknown` — it is whatever the client sent — so
3688
+ * destructuring it directly is TS2339 on EVERY field. That is not pedantry: it
3689
+ * is a hard `npm run build` failure the moment a Node-typed project compiles the
3690
+ * route, where `Request` comes from undici (`json(): Promise<unknown>`) rather
3691
+ * than from the DOM lib (`json(): Promise<any>`). A stock Vite app does exactly
3692
+ * that — `tsc -b` walks vite.config.ts → vite-chat-api.ts → src/server/chat.ts
3693
+ * with `lib` and no DOM — so the route ran fine and the build did not.
3694
+ *
3695
+ * `messages` is typed as the kit's OWN encoder output rather than restated
3696
+ * structurally, which keeps the two halves of the scaffold pinned to one type:
3697
+ * the front end sends `toOpenAIMessages(thread)`, and this is what that returns.
3698
+ * The import is type-only and erases at build time, so the route ships no
3699
+ * runtime dependency on the kit.
3700
+ */
3701
+ const CHAT_REQUEST_BODY_IMPORT = `import type { OpenAIWireMessage } from '@kitn.ai/ui/wire';`;
3702
+ const CHAT_REQUEST_BODY_DECL = [
3703
+ `/**`,
3704
+ ` * What the front end POSTs. \`request.json()\` is \`unknown\` (it is whatever the`,
3705
+ ` * client sent), so the body is narrowed once here instead of at every use —`,
3706
+ ` * without it this route does not compile under a server tsconfig. Widen it as`,
3707
+ ` * you add fields of your own.`,
3708
+ ` */`,
3709
+ `type ChatRequestBody = {`,
3710
+ ` messages: OpenAIWireMessage[];`,
3711
+ ` model?: string;`,
3712
+ ` tools?: unknown[];`,
3713
+ `};`,
3714
+ ``,
3715
+ `/** Narrow the JSON body once, at the edge. */`,
3716
+ `async function readChatRequest(request: Request): Promise<ChatRequestBody> {`,
3717
+ ` return (await request.json()) as ChatRequestBody;`,
3718
+ `}`,
3719
+ ];
3720
+
3721
+ /**
3722
+ * Slot the body type in just above `chatHandler`.
3723
+ *
3724
+ * Not at the very top: a fragment may open with its own imports (langgraph,
3725
+ * mastra, vercel-ai-sdk all do), and a type declaration wedged above them reads
3726
+ * like a mistake. Anchoring on the handler — and stepping back over the comment
3727
+ * block that documents it — puts the declaration where a person would have
3728
+ * written it.
3729
+ */
3730
+ function withChatRequestBody(fragment: string): string {
3731
+ const lines = fragment.split('\n');
3732
+ let at = lines.findIndex((l) => /^(?:export\s+)?async function chatHandler\b/.test(l));
3733
+ if (at < 0) return [...CHAT_REQUEST_BODY_DECL, ``, ...lines].join('\n');
3734
+ while (at > 0 && /^\s*(?:\/\/|\/\*|\*)/.test(lines[at - 1])) at -= 1;
3735
+ return [...lines.slice(0, at), ...CHAT_REQUEST_BODY_DECL, ``, ...lines.slice(at)].join('\n');
3736
+ }
3737
+
3738
+ /** Wrap an integration's portable handler in the target framework's declaration. */
3739
+ function webRouteFor(integration: Integration, framework: string): RouteChoice | undefined {
3740
+ const fragment = integration.webRoute;
3741
+ const adapter = WEB_ROUTE_ADAPTERS[framework];
3742
+ if (!fragment || !adapter) return undefined;
3743
+ // A framework may have to rewrite the portable handler into its own idioms and
3744
+ // pull in an import to do it (SvelteKit's $env accessor). Both land at the top
3745
+ // of the file, beside the adapter's own `before` lines.
3746
+ const adapted = adapter.adaptFragment?.(fragment) ?? { fragment, imports: [] };
3747
+ return {
3748
+ framework,
3749
+ runtime: adapter.runtime,
3750
+ exact: true,
3751
+ template: [
3752
+ `// ${adapter.file}`,
3753
+ CHAT_REQUEST_BODY_IMPORT,
3754
+ ...(adapter.before ?? []),
3755
+ ...adapted.imports,
3756
+ ``,
3757
+ withChatRequestBody(adapted.fragment),
3758
+ ...adapter.after,
3759
+ ].join('\n'),
3760
+ };
3761
+ }
3762
+
1419
3763
  /** Prefer the language's canonical server framework when there's no exact match. */
1420
3764
  function preferredKeyFor(integration: Integration): string[] {
1421
3765
  return integration.language === 'python'
@@ -1426,19 +3770,41 @@ function preferredKeyFor(integration: Integration): string[] {
1426
3770
  function chooseRoute(integration: Integration, framework: string): RouteChoice | undefined {
1427
3771
  const templates = integration.routeTemplates;
1428
3772
 
1429
- // 1. exact match
1430
- if (templates[framework]) {
3773
+ // 1. exact match. Never for 'html': that key can only hold a browser snippet,
3774
+ // and block (1) already emits the whole browser side. Selecting one here
3775
+ // printed a SECOND <kai-chat id="chat"> under a "BACKEND ROUTE" heading,
3776
+ // with its own kai-submit listener, so pasting both blocks gave a duplicate
3777
+ // element id and two fetches per submit. Step 4 below has always skipped
3778
+ // 'html' for the same reason; step 1 did not.
3779
+ //
3780
+ // This is where a route that CANNOT be portable wins: a Worker on an `env`
3781
+ // binding, an Express bridge, a FastAPI service.
3782
+ if (framework !== 'html' && templates[framework]) {
1431
3783
  return { framework, template: templates[framework], runtime: RUNTIME_LABEL[framework] ?? framework, exact: true };
1432
3784
  }
1433
3785
 
1434
- // 2. language-canonical fallback (python fastapi; ts next/express/worker)
3786
+ // 2. the portable handler, wrapped in this framework's own route declaration.
3787
+ const portable = webRouteFor(integration, framework);
3788
+ if (portable) return portable;
3789
+
3790
+ // 3. no adapter for this framework at all (html, fastapi): the framework
3791
+ // cannot host ANY route, so emit the portable handler under a host that can
3792
+ // run it standalone — with the warning from step 5. Emitting nothing here
3793
+ // would leave `html` with no backend code whatsoever, which is worse than a
3794
+ // route it has to run elsewhere: the handler is still the thing to deploy.
3795
+ if (!WEB_ROUTE_ADAPTERS[framework] && integration.webRoute) {
3796
+ const host = webRouteFor(integration, 'express');
3797
+ if (host) return { ...host, exact: false };
3798
+ }
3799
+
3800
+ // 4. language-canonical fallback (python → fastapi; ts → next/express/worker)
1435
3801
  for (const key of preferredKeyFor(integration)) {
1436
3802
  if (templates[key]) {
1437
3803
  return { framework: key, template: templates[key], runtime: RUNTIME_LABEL[key] ?? key, exact: false };
1438
3804
  }
1439
3805
  }
1440
3806
 
1441
- // 3. anything usable that isn't a pure front-end snippet
3807
+ // 5. anything usable that isn't a pure front-end snippet
1442
3808
  for (const [key, template] of Object.entries(templates)) {
1443
3809
  if (key === 'html') continue;
1444
3810
  return { framework: key, template, runtime: RUNTIME_LABEL[key] ?? key, exact: false };
@@ -1447,29 +3813,262 @@ function chooseRoute(integration: Integration, framework: string): RouteChoice |
1447
3813
  return undefined;
1448
3814
  }
1449
3815
 
3816
+ /**
3817
+ * The honest warning for a route the target framework cannot host.
3818
+ *
3819
+ * Emitted for EVERY such framework. It used to be gated on
3820
+ * `framework === 'react'`, which left the two worst cases silent: svelte, whose
3821
+ * Next-shaped handler compiles and then throws at runtime, and html, which has
3822
+ * no server to paste anything into.
3823
+ */
3824
+ function cannotHostWarning(integration: Integration, route: RouteChoice, framework: string): string[] {
3825
+ const target = FRAMEWORK_LABEL[framework] ?? `a ${framework} app`;
3826
+ const options = [
3827
+ // Led by the option `keyExposure` unlocks, where there is one. The generic
3828
+ // note below tells a static page to "run this route on a separate server",
3829
+ // which for ollama is work nobody has to do: its route holds no credential
3830
+ // and reaches localhost, so the page can call it. That advice was emitted
3831
+ // identically for every integration because nothing read the flag that
3832
+ // distinguishes them.
3833
+ ...(integration.keyExposure === 'frontend-safe'
3834
+ ? [
3835
+ `# • or drop the route entirely: this integration needs no server hop — see the`,
3836
+ `# run note — so the page may call it directly (CORS applies).`,
3837
+ ]
3838
+ : []),
3839
+ `# • ${CANNOT_HOST_NOTE[framework] ?? "port it to this framework's route convention."}`,
3840
+ ...(integration.language === 'python'
3841
+ ? [
3842
+ `# • it is a separate SERVICE either way: run it (uvicorn main:app) and proxy`,
3843
+ `# /api/chat to it, or point the fetch at http://localhost:8000/api/chat.`,
3844
+ ]
3845
+ : [`# • or run it where it belongs: framework: "${route.framework}".`]),
3846
+ `# • or use integration: "mock" for a zero-config local stream (no backend, no key).`,
3847
+ ];
3848
+ return [
3849
+ `#`,
3850
+ `# WARNING: the route below is written for ${route.runtime} and will NOT run`,
3851
+ `# as-is in ${target}.`,
3852
+ ...options,
3853
+ ];
3854
+ }
3855
+
3856
+ // ── block (0): setup a framework REQUIRES before block (1) runs at all ────────
3857
+
3858
+ /**
3859
+ * SCAF-3, promoted from a comment to a step.
3860
+ *
3861
+ * The placement was the defect. This used to be an HTML comment sitting ABOVE the
3862
+ * `<script setup>` block, and block (1) is a `<script setup>` + `<template>` pair:
3863
+ * an agent or a developer copying "the component" copies that pair, and the one
3864
+ * thing that configures it is not inside it. As its own labelled block, ordered
3865
+ * first, it cannot be lost to that copy.
3866
+ *
3867
+ * WHAT IT ACTUALLY DOES, measured rather than assumed. On Vue 3.5.39, skipping it
3868
+ * does NOT blank the page: Vue falls back to rendering the unresolved tag as a
3869
+ * native element, and its runtime sets `key in el` bindings as DOM properties, so
3870
+ * a scaffold built from this output still runs — verified in a stock `vue-ts` app
3871
+ * in dev AND in a production build, with and without the `.prop` modifiers, and
3872
+ * the mock reply streamed in every time. What you get instead is
3873
+ *
3874
+ * [Vue warn]: Failed to resolve component: kai-chat
3875
+ *
3876
+ * on every kai-* tag in development, which reads like a bug and is the exact
3877
+ * warning Vue tells you to fix with `compilerOptions.isCustomElement`.
3878
+ *
3879
+ * So the emitted copy says that, and does not claim a blank page it cannot
3880
+ * produce. Overstating it would be worse than the buried comment was: the first
3881
+ * developer to skip the step and find their app working stops believing the rest
3882
+ * of the scaffold.
3883
+ *
3884
+ * The whole file is emitted rather than a fragment because the plugin list is
3885
+ * where this collides with block (2): a Vite SPA's dev API route adds
3886
+ * `chatApiPlugin()` to the SAME array, and a consumer who pastes one config over
3887
+ * the other silently loses whichever came first. The emitted comment says so.
3888
+ */
3889
+ function setupBlock(framework: string): string | undefined {
3890
+ if (framework !== 'vue') return undefined;
3891
+ return [
3892
+ `=== (0) REQUIRED SETUP — do this FIRST ===`,
3893
+ ``,
3894
+ `// vite.config.ts`,
3895
+ `//`,
3896
+ `// Tell Vue that kai-* tags are CUSTOM ELEMENTS rather than Vue components.`,
3897
+ `// This is its own step, not a note inside block (1), because block (1) is a`,
3898
+ `// <script setup> + <template> pair and this configures it from outside — copy`,
3899
+ `// just the component and you never see it.`,
3900
+ `//`,
3901
+ `// Without it, every kai-* tag logs this in development:`,
3902
+ `// [Vue warn]: Failed to resolve component: kai-chat`,
3903
+ `// If this is a native custom element, make sure to exclude it from component`,
3904
+ `// resolution via compilerOptions.isCustomElement.`,
3905
+ `// The app does still render — Vue falls back to a native element — so this is`,
3906
+ `// a warning to remove, not a crash to avoid. Removing it is the point: it is`,
3907
+ `// the fix Vue's own message asks for, and it stops the console reading like`,
3908
+ `// something is broken.`,
3909
+ `import vue from '@vitejs/plugin-vue';`,
3910
+ `import { defineConfig } from 'vite';`,
3911
+ ``,
3912
+ `export default defineConfig({`,
3913
+ ` plugins: [`,
3914
+ ` vue({`,
3915
+ ` template: {`,
3916
+ ` compilerOptions: {`,
3917
+ ` // Every kai-* tag is a custom element, not a Vue component.`,
3918
+ ` isCustomElement: (tag) => tag.startsWith('kai-'),`,
3919
+ ` },`,
3920
+ ` },`,
3921
+ ` }),`,
3922
+ ` // If you also add the dev API route from block (2), its chatApiPlugin() goes`,
3923
+ ` // in THIS array — do not replace this file with the one-liner shown there.`,
3924
+ ` ],`,
3925
+ `});`,
3926
+ ].join('\n');
3927
+ }
3928
+
3929
+ // ── block (3): what to install, and where the key may live ───────────────────
3930
+
3931
+ /**
3932
+ * The install command, DERIVED from the catalog's `deps`.
3933
+ *
3934
+ * `deps` was declared by all eleven integrations and consumed by nothing: the
3935
+ * schema typed it, `registry.test.ts` checked its SHAPE, and the fact a developer
3936
+ * actually needs — which packages to install — lived a SECOND time as prose in
3937
+ * each integration's `runNote`. Two copies of one fact drift silently, and both
3938
+ * copies already had: langgraph's prose named three packages where `deps.npm`
3939
+ * carries four (it never mentioned `zod`, which its route imports and its app
3940
+ * therefore fails to build without), and pydantic-ai's named three where
3941
+ * `deps.pip` carries four (it never mentioned `pydantic`, which its route
3942
+ * imports). Nothing could catch either, because nothing compared them.
3943
+ *
3944
+ * So the prose is gone and this is the only place the scaffold names a package.
3945
+ * `registry.test.ts` pins `deps.npm` to the imports of the route's own source, so
3946
+ * the line below cannot drift from the code printed above it either.
3947
+ *
3948
+ * `@kitn.ai/ui` leads and is deliberately in no integration's `deps`: it is the
3949
+ * kit, a dependency of every scaffold whatever the backend, so declaring it
3950
+ * per-integration would be eleven copies of one constant.
3951
+ *
3952
+ * Only what the emitted CODE imports is listed. The host a route runs on (Express,
3953
+ * an Angular SSR server, a Vite dev server) comes from the app template the
3954
+ * developer created, not from here.
3955
+ */
3956
+ function installLines(integration: Integration): string[] {
3957
+ const lines = [
3958
+ `Install:`,
3959
+ ` npm install ${['@kitn.ai/ui', ...integration.deps.npm].join(' ')}`,
3960
+ ];
3961
+ if (integration.deps.pip.length > 0) {
3962
+ lines.push(
3963
+ ` # ...and block (2) is Python — a separate service, with its own install:`,
3964
+ ` pip install ${integration.deps.pip.join(' ')}`,
3965
+ );
3966
+ }
3967
+ return lines;
3968
+ }
3969
+
3970
+ /**
3971
+ * The two halves of the proxy decision, as the exact strings the emitted run note
3972
+ * carries. Exported so the guards in scaffold.test.ts can look for the claim the
3973
+ * scaffold really makes instead of restating it — a copied string is a guard that
3974
+ * goes green the day the wording changes.
3975
+ */
3976
+ export const PROXY_REQUIRED_CLAIM = 'a server hop is REQUIRED';
3977
+ export const NO_PROXY_CLAIM = 'no server hop is required';
3978
+
3979
+ /**
3980
+ * Where this integration's key may live, DERIVED from the catalog's `keyExposure`.
3981
+ *
3982
+ * This is the field's whole point of existence (see `KeyExposure` in types.ts) and
3983
+ * until now nothing read it, which meant every one of the eleven values could have
3984
+ * been wrong with every test still green.
3985
+ *
3986
+ * `'frontend-safe'` is tested POSITIVELY and everything else — including an absent
3987
+ * flag, which the type still permits — falls through to the proxy branch. That
3988
+ * asymmetry is the safety property: declaring a proxy where none was needed costs
3989
+ * a server hop, and reading a missing flag as "safe" costs the key.
3990
+ */
3991
+ function keyHandlingLines(integration: Integration, route: RouteChoice | undefined): string[] {
3992
+ if (integration.keyExposure === 'frontend-safe') {
3993
+ return [
3994
+ `Key handling: frontend-safe — ${NO_PROXY_CLAIM}.`,
3995
+ ` Nothing here is secret and nothing needs a capability a browser lacks, so the`,
3996
+ ` page may talk to this integration itself.`,
3997
+ ...(route
3998
+ ? [
3999
+ ` The route in block (2) is a convenience — one origin, one place to log — and`,
4000
+ ` not a requirement.`,
4001
+ ]
4002
+ : []),
4003
+ ];
4004
+ }
4005
+
4006
+ // Named rather than described: "OPENAI_API_KEY stays on the server" is a
4007
+ // sentence a developer can act on. `SECRET_ENV_VAR` is the schema's own pattern,
4008
+ // imported rather than restated, so this list and the refinement that rejects a
4009
+ // false `frontend-safe` cannot disagree about what counts as a secret.
4010
+ const secrets = integration.envVars.filter((name) => SECRET_ENV_VAR.test(name));
4011
+ return [
4012
+ `Key handling: needs-proxy — ${PROXY_REQUIRED_CLAIM}.`,
4013
+ ...(secrets.length > 0
4014
+ ? [
4015
+ ` ${secrets.join(' and ')} ${secrets.length > 1 ? 'stay' : 'stays'} on the server: the front end POSTs to`,
4016
+ ` /api/chat, and only the route reads ${secrets.length > 1 ? 'them' : 'it'}.`,
4017
+ ` Do NOT re-export ${secrets.length > 1 ? 'them' : 'it'} through a client-bundle env var — a bundler INLINES`,
4018
+ ` VITE_*, NEXT_PUBLIC_* and PUBLIC_* into the JavaScript the browser downloads,`,
4019
+ ` so a key put there is published, not configured.`,
4020
+ ]
4021
+ : [
4022
+ // Two integrations are here, for two different reasons, and the sentence
4023
+ // has to be true of both: `pi` spawns a local process, which a browser
4024
+ // cannot do at any price, and `mastra` points at an unauthenticated agent
4025
+ // endpoint that a public bundle must not be aimed at. Neither declares a
4026
+ // key, and neither is frontend-safe.
4027
+ ` No API key is involved. What keeps this on a server is the route itself —`,
4028
+ ` either a capability a browser does not have, or an endpoint a public bundle`,
4029
+ ` must not be pointed at. Block (2) is not optional.`,
4030
+ ]),
4031
+ ];
4032
+ }
4033
+
1450
4034
  // ── compose ───────────────────────────────────────────────────────────────────
1451
4035
 
4036
+ /**
4037
+ * The three labeled blocks the MCP tool returns: the surface, the backend route
4038
+ * and the run note.
4039
+ *
4040
+ * It owns NO rendering. `renderSurface` emits block (1) and `chooseRoute` picks
4041
+ * block (2); what is left here is the provenance header and the assembly. That
4042
+ * split is what lets `create-kai` reuse the renderer without inheriting the MCP's
4043
+ * output format, which is the reason this extraction exists.
4044
+ *
4045
+ * `preset` is provenance only — the archetype id and title, when the request came
4046
+ * in as `useCase`. It is printed in the header and NEVER reaches `renderSurface`,
4047
+ * so it cannot change a single byte of the emitted surface.
4048
+ */
1452
4049
  function compose(
1453
- archetype: Archetype,
4050
+ components: readonly string[],
1454
4051
  integration: Integration,
1455
4052
  placement: string,
1456
4053
  framework: string,
1457
4054
  suggestions: string[],
1458
4055
  audience?: string,
4056
+ preset?: { id: string; title: string },
1459
4057
  ): string {
1460
- const audienceHint = audience
1461
- ? `tuned for ${audience} — keep the empty state and tone audience-appropriate`
1462
- : 'add an empty-state prompt that fits your product';
1463
-
4058
+ const frontend = renderSurface({
4059
+ framework,
4060
+ components,
4061
+ integration,
4062
+ placement,
4063
+ suggestions,
4064
+ audience,
4065
+ });
1464
4066
  const isMock = integration.id === 'mock';
1465
- // SCAF-8: compute the default model only for non-mock integrations that forward model.
1466
- const defaultModel = isMock ? undefined : defaultModelFor(integration);
1467
- const frontend = renderFrontend(framework, archetype, placement, audienceHint, suggestions, isMock, defaultModel);
1468
4067
  const route = isMock ? undefined : chooseRoute(integration, framework);
1469
4068
 
1470
4069
  const header = [
1471
- `# AI/UI scaffold — ${archetype.title} × ${integration.title}`,
1472
- `combo: ${archetype.id} × ${integration.id} × ${placement} × ${framework}`,
4070
+ `# AI/UI scaffold — ${preset ? preset.title : surfaceLabel(components)} × ${integration.title}`,
4071
+ `combo: ${preset ? preset.id : components.join('+')} × ${integration.id} × ${placement} × ${framework}`,
1473
4072
  `stream: ${integration.streamMapping}`,
1474
4073
  ].join('\n');
1475
4074
 
@@ -1490,21 +4089,11 @@ function compose(
1490
4089
  } else if (route) {
1491
4090
  if (!route.exact) {
1492
4091
  block2Parts.push(
1493
- `# Note: ${integration.title} has no template for "${framework}". Emitting its native`,
4092
+ `# Note: ${integration.title} has no route for "${framework}". Emitting its native`,
1494
4093
  `# ${route.runtime} route instead (matches the integration's ${integration.language} language).`,
4094
+ // For EVERY framework that cannot host it, not just react.
4095
+ ...cannotHostWarning(integration, route, framework),
1495
4096
  );
1496
- // Honest warning: a Next.js/server route will NOT run inside a Vite SPA.
1497
- if (framework === 'react') {
1498
- block2Parts.push(
1499
- `#`,
1500
- `# WARNING: this is a Next.js route handler — it will NOT run in a Vite SPA`,
1501
- `# (a Vite \`react\` app has no /api routes). To make the front-end above work, either:`,
1502
- `# • use Next.js (framework: "next"), or`,
1503
- `# • add a Vite dev-server middleware/proxy to a server, or`,
1504
- `# • run a separate server (framework: "express" | "worker"), or`,
1505
- `# • use integration: "mock" for a zero-config local stream (no backend, no key).`,
1506
- );
1507
- }
1508
4097
  block2Parts.push(``);
1509
4098
  } else {
1510
4099
  block2Parts.push(`# Runtime: ${route.runtime}`, ``);
@@ -1521,25 +4110,57 @@ function compose(
1521
4110
  const envLines = integration.envVars.length
1522
4111
  ? integration.envVars.map((v) => ` - ${v}`).join('\n')
1523
4112
  : ' (none)';
4113
+ // The catalog's two machine-readable facts, emitted rather than described: what
4114
+ // to install comes from `deps`, and where the key may live comes from
4115
+ // `keyExposure`. `runNote` is prose ABOUT running it and no longer restates
4116
+ // either — see `installLines` for the drift that duplication had already caused.
1524
4117
  const block3 = [
1525
4118
  `=== (3) RUN NOTE ===`,
1526
4119
  ``,
1527
4120
  integration.runNote,
1528
4121
  ``,
4122
+ ...installLines(integration),
4123
+ ``,
1529
4124
  `Env vars to set:`,
1530
4125
  envLines,
4126
+ ``,
4127
+ ...keyHandlingLines(integration, route),
1531
4128
  ].join('\n');
1532
4129
 
1533
4130
  // SCAF-16: loading-options note — inform consumers about the two opt-in load modes
1534
4131
  // (per-element tree-shaking + autoloader) without changing the default import above.
1535
4132
  // Leads with "the default is right" rather than a size headline; the debug tool
1536
4133
  // carries the full KB breakdown for developers who ask for it.
4134
+ // The default varies by framework, so describe what THIS scaffold actually emits:
4135
+ // every framework but `next` emits a top-level `import '@kitn.ai/ui/elements'`;
4136
+ // the next output loads the React wrappers through next/dynamic instead, and each
4137
+ // wrapper lazy-registers its own element on first client mount.
4138
+ const defaultLoadNote =
4139
+ framework === 'solid'
4140
+ ? [
4141
+ `The scaffold emits NO \`import '@kitn.ai/ui/elements'\` — a Solid app renders`,
4142
+ `the SolidJS components straight from the root entry, so no custom element is`,
4143
+ `registered at all and your bundler already tree-shakes what you never import.`,
4144
+ `Leave it as is. The two modes below matter only if you ALSO put raw \`<kai-*>\``,
4145
+ `tags on the page (you do not need to):`,
4146
+ ]
4147
+ : framework === 'next'
4148
+ ? [
4149
+ `The scaffold emits NO \`import '@kitn.ai/ui/elements'\` — it loads the React`,
4150
+ `wrappers through next/dynamic, and each wrapper lazy-registers ITS element on`,
4151
+ `first client mount, so you already ship only the elements you use. Leave it as`,
4152
+ `is. Two other modes exist if you drop the wrappers for raw \`<kai-*>\` tags:`,
4153
+ ]
4154
+ : [
4155
+ `The scaffold uses \`import '@kitn.ai/ui/elements'\` (register-all) — the right`,
4156
+ `default: it registers every kai-* element and is SSR-safe, so leave it as is.`,
4157
+ `Two opt-in modes load less if a page only ever uses a few elements:`,
4158
+ ];
4159
+
1537
4160
  const block4 = [
1538
4161
  `=== LOADING OPTIONS ===`,
1539
4162
  ``,
1540
- `The scaffold uses \`import '@kitn.ai/ui/elements'\` (register-all) — the right`,
1541
- `default: it registers every kai-* element and is SSR-safe, so leave it as is.`,
1542
- `Two opt-in modes load less if a page only ever uses a few elements:`,
4163
+ ...defaultLoadNote,
1543
4164
  ``,
1544
4165
  ` Per-element (bundler apps): import '@kitn.ai/ui/elements/<file>'`,
1545
4166
  ` Registers just that element; your bundler tree-shakes the rest away.`,
@@ -1558,7 +4179,11 @@ function compose(
1558
4179
  // capture without leaving the scaffold. Does not change blocks 1–4.
1559
4180
  const block5 = interactionPatternsBlock();
1560
4181
 
1561
- return [header, block1, block2, block3, block4, block5].join('\n\n');
4182
+ // Block (0) is emitted only where the framework genuinely needs setup before
4183
+ // block (1) will run — today that is vue's isCustomElement.
4184
+ const block0 = setupBlock(framework);
4185
+
4186
+ return [header, ...(block0 ? [block0] : []), block1, block2, block3, block4, block5].join('\n\n');
1562
4187
  }
1563
4188
 
1564
4189
  // ── SCAF-17: reusable interaction-pattern snippets ─────────────────────────────
@@ -1658,14 +4283,14 @@ function interactionPatternsBlock(): string {
1658
4283
  `// const set = (fn) => { el.messages = fn(el.messages ?? []); };`,
1659
4284
  ``,
1660
4285
  `// Low-level helpers from @kitn.ai/ui/state:`,
1661
- `// import { appendMessage, updateMessage, appendContent, createAssistantStream } from '@kitn.ai/ui/state';`,
4286
+ `// import { appendMessage, updateMessage, appendText, createAssistantStream } from '@kitn.ai/ui/state';`,
1662
4287
  ``,
1663
4288
  `// Streaming loop (framework-agnostic):`,
1664
4289
  `// const stream = createAssistantStream(set);`,
1665
- `// stream.appendText(chunk); // text delta`,
1666
- `// stream.appendReasoning(chunk); // reasoning delta`,
1667
- `// stream.upsertTool(toolCall); // tool call delta`,
1668
- `// stream.done(); // seal the message`,
4290
+ `// stream.appendText(chunk); // text delta, appended to the trailing text part`,
4291
+ `// stream.appendReasoning(chunk); // reasoning delta`,
4292
+ `// stream.upsertTool(toolCallId, patch); // tool call delta (patch merges into the ToolPart)`,
4293
+ `// stream.done(); // seal the message`,
1669
4294
  ``,
1670
4295
  `// One-liner for React (batteries-included):`,
1671
4296
  `// import { useKaiChat } from '@kitn.ai/ui/react';`,
@@ -1710,13 +4335,20 @@ function rejectIntegration(id: string): string {
1710
4335
 
1711
4336
  function rejectUseCase(id: string): string {
1712
4337
  const valid = listArchetypes()
1713
- .map((a) => `${a.id} (${a.title})`)
4338
+ .map((a) => `${a.id} (${a.title}: ${a.components.join(', ')})`)
1714
4339
  .join(', ');
1715
4340
  return [
1716
- `Unknown useCase: "${id}".`,
4341
+ id ? `Unknown useCase: "${id}".` : `No surface given: pass either \`components\` or \`useCase\`.`,
1717
4342
  ``,
1718
- `Valid useCases (archetypes): ${valid}.`,
1719
- `Pick one of those ids and call scaffold again.`,
4343
+ `Valid useCases (presets): ${valid}.`,
4344
+ ``,
4345
+ // The presets are six points, not the space. A harness that only ever learns
4346
+ // the six ids will ask for the nearest one instead of the surface it wants,
4347
+ // so the rejection that teaches the id list is the right place to say so.
4348
+ `These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
4349
+ `preset names, pass the list directly, e.g. components: ["kai-chat", "kai-tool",`,
4350
+ `"kai-reasoning", "kai-artifact", "kai-resizable"] for a workspace that also renders its`,
4351
+ `tool calls. Pick a preset id or pass \`components\`, then call scaffold again.`,
1720
4352
  ].join('\n');
1721
4353
  }
1722
4354
 
@@ -1725,18 +4357,33 @@ function rejectUseCase(id: string): string {
1725
4357
  export const scaffold: Tool = {
1726
4358
  name: 'scaffold',
1727
4359
  description:
1728
- 'Scaffold a working AI/UI chat surface from four axes: useCase (archetype) × integration × placement × framework. ' +
4360
+ 'Scaffold a working AI/UI chat surface from: components (or a useCase preset) × integration × placement × framework. ' +
1729
4361
  'Emits a copy-pasteable front-end (kai-* components wired with messages + kai-submit + starter suggestions), the backend ' +
1730
- 'route for the chosen framework, and a run note with env vars. Use integration: "mock" for a zero-config local preview.',
4362
+ 'route for the chosen framework, and a run note with env vars. Use integration: "mock" for a zero-config local preview. ' +
4363
+ 'Pass `components` to compose any feature set; `useCase` is a named preset over the same axis and the two cannot disagree.',
1731
4364
  inputSchema: z.object({
1732
4365
  // useCase + integration are dynamic catalog ids — kept as strings and
1733
4366
  // validated against the registry in the handler (the handler is called
1734
4367
  // directly in tests, bypassing this schema). Use component_reference / the
1735
4368
  // catalogs to discover valid ids.
4369
+ //
4370
+ // `useCase` is OPTIONAL because `components` can carry the surface instead —
4371
+ // the archetypes are six points in the components space, not the space. A
4372
+ // request must still name one of the two, and the handler says so when it
4373
+ // names neither.
1736
4374
  useCase: z
1737
4375
  .string()
4376
+ .optional()
4377
+ .describe(
4378
+ 'Archetype PRESET id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice". ' +
4379
+ 'Shorthand for the preset\'s `components`. Omit it and pass `components` to compose a surface no preset names.',
4380
+ ),
4381
+ components: z
4382
+ .array(z.string())
4383
+ .optional()
1738
4384
  .describe(
1739
- 'Archetype id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice".',
4385
+ 'The kai-* components this surface composes, e.g. ["kai-chat", "kai-tool", "kai-reasoning", "kai-artifact", "kai-resizable"]. ' +
4386
+ 'The real axis: any combination is renderable, not just the six presets. Include "kai-chat". Wins over `useCase` when both are given.',
1740
4387
  ),
1741
4388
  integration: z
1742
4389
  .string()
@@ -1747,7 +4394,8 @@ export const scaffold: Tool = {
1747
4394
  'Where the surface lives: full-page | side | docked-widget | inline.',
1748
4395
  ),
1749
4396
  framework: Framework.describe(
1750
- 'Target front-end/back-end framework: html | react | next | vue | svelte | fastapi | express | worker | tanstack-start.',
4397
+ 'Target front-end/back-end framework: html | react | next | vue | svelte | angular | solid | fastapi | express | worker | tanstack-start. ' +
4398
+ 'Note "solid" emits the SolidJS components from the @kitn.ai/ui root entry, not <kai-*> elements — the kit is authored in Solid.',
1751
4399
  ),
1752
4400
  suggestions: z
1753
4401
  .array(z.string())
@@ -1762,7 +4410,6 @@ export const scaffold: Tool = {
1762
4410
  .describe('Optional audience hint (tweaks the empty-state comment only).'),
1763
4411
  }),
1764
4412
  handler: async (args) => {
1765
- const useCase = String(args.useCase ?? '');
1766
4413
  const integrationId = String(args.integration ?? '');
1767
4414
  const placement = String(args.placement ?? '');
1768
4415
  const framework = String(args.framework ?? 'html');
@@ -1773,16 +4420,39 @@ export const scaffold: Tool = {
1773
4420
  ? args.suggestions.map(String)
1774
4421
  : DEFAULT_SUGGESTIONS;
1775
4422
 
1776
- // Validate against the registry BEFORE composing — graceful, self-correcting text.
1777
- const archetype = getArchetype(useCase);
1778
- if (!archetype) return text(rejectUseCase(useCase));
4423
+ // The surface arrives one of two ways, and `components` wins: it is the axis,
4424
+ // and `useCase` is a preset over it. A caller that passes an explicit list has
4425
+ // said something more specific than a preset name can.
4426
+ const explicit = Array.isArray(args.components)
4427
+ ? args.components.map(String).filter(Boolean)
4428
+ : undefined;
4429
+
4430
+ let components: readonly string[];
4431
+ let preset: { id: string; title: string } | undefined;
4432
+ let effectivePlacement = placement;
4433
+
4434
+ if (explicit && explicit.length > 0) {
4435
+ components = explicit;
4436
+ // No preset means no `defaultPlacement` to fall back to. Full-page is the
4437
+ // same default `renderSurface` applies, stated here so the header prints
4438
+ // the placement that was really used.
4439
+ effectivePlacement = placement || 'full-page';
4440
+ } else {
4441
+ const useCase = String(args.useCase ?? '');
4442
+ // Validate against the registry BEFORE composing — graceful, self-correcting text.
4443
+ const archetype = getArchetype(useCase);
4444
+ if (!archetype) return text(rejectUseCase(useCase));
4445
+ components = archetype.components;
4446
+ preset = { id: archetype.id, title: archetype.title };
4447
+ // Fall back to the archetype's default placement only if none was provided.
4448
+ effectivePlacement = placement || archetype.defaultPlacement;
4449
+ }
1779
4450
 
1780
4451
  const integration = getIntegration(integrationId);
1781
4452
  if (!integration) return text(rejectIntegration(integrationId));
1782
4453
 
1783
- // Fall back to the archetype's default placement only if none was provided.
1784
- const effectivePlacement = placement || archetype.defaultPlacement;
1785
-
1786
- return text(compose(archetype, integration, effectivePlacement, framework, suggestions, audience));
4454
+ return text(
4455
+ compose(components, integration, effectivePlacement, framework, suggestions, audience, preset),
4456
+ );
1787
4457
  },
1788
4458
  };